loaders: the four FunPack loaders, as V3 nodes

Ported from v4, minus two mechanisms that are no longer needed. The LoRA
loader takes one LoRA and chains, so the FlexibleOptionalInputType dict
subclass that lied to ComfyUI's validator has nothing left to do. The CLIP
loader uses numbered slots like core's DualCLIPLoader instead of a JSON
array in a string widget.

Autogrow looked like the right answer for the encoder list and is not: it
sets force_input on any widget input, so a list of combos renders as sockets
and the file picker stops being a picker. Every core use of it is a
connection type.

GGUF and SLA are deliberately not here. Both are capabilities rather than
loader options, and both belong in modules of their own.

The tests follow what the loaders PRODUCE rather than stopping at the call:
the attention override is carried into comfy's real dispatch and checked
against every backend this machine registers, because an override that only
fails at the first attention call fails at sampling, not at load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-29 03:32:06 +03:00
co-authored by Claude Opus 5
parent eaae196c1f
commit ab9cbdb556
12 changed files with 772 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
"""Shared test setup for the whole tree.
Kept at the root so `modules/**/tests` get the same fixtures as `core/tests`
without each one re-deriving the path to ComfyUI.
"""
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _comfyui_root():
candidates = [os.environ.get("COMFYUI_ROOT"), str(Path.home() / "Documents" / "ComfyUI")]
for candidate in candidates:
if candidate and (Path(candidate) / "comfy" / "supported_models.py").is_file():
if candidate not in sys.path:
sys.path.append(candidate)
return candidate
return None
@pytest.fixture(scope="session")
def comfyui():
"""ComfyUI's source tree on sys.path, or skip.
Everything that touches a node schema needs it, because a schema is only
meaningful in terms of comfy_api's own types.
"""
root = _comfyui_root()
if root is None:
pytest.skip("no ComfyUI source on this machine")
return root
+9
View File
@@ -43,6 +43,15 @@ def test_every_shipped_node_is_a_real_comfy_node(shipped, comfyui):
# breaks graphs that already exist, so this test is meant to FAIL on a rename and
# make that a decision rather than an accident.
FROZEN = {
"FunPackCLIPLoader": [
"clip_name1", "clip_name2", "clip_name3", "clip_name4", "type", "device",
],
"FunPackDiffusionModelLoader": [
"model_name", "weight_dtype", "compute_dtype", "attention", "fp16_accumulation",
],
"FunPackLoraLoader": [
"model", "lora_name", "strength_model", "clip", "strength_clip",
],
"FunPackVAELoader": ["vae_name", "dtype"],
}
+10
View File
@@ -0,0 +1,10 @@
"""Text encoder loader. A node-only module."""
from .nodes import FunPackCLIPLoader
ID = "loader_clip"
TITLE = "CLIP loader"
STAGE = "load"
STATUS = "proven"
NODES = [FunPackCLIPLoader]
+113
View File
@@ -0,0 +1,113 @@
"""The text encoder loader.
One node, up to four files, one CLIP. Unlike the LoRA loader this cannot be a
chain: the files are combined into a single encoder, so they have to arrive
together. One slot for LTX-2.5 (Gemma4); two for LTX-2.3 (Gemma3 plus its
connector).
The slots are numbered combos, which is what core's DualCLIPLoader and
TripleCLIPLoader use. Two alternatives were tried and rejected:
* **ComfyUI's autogrow input** looks like the right answer and is not.
`_AutogrowTemplate.__init__` sets `force_input = True` on any WidgetInput, so a
Combo template becomes a row of SOCKETS instead of dropdowns -- the file picker
stops being a picker. Every core usage of autogrow is a connection type (Image,
Audio, Splat), never a Combo.
* **v4's `funpack_list`**, a JSON array in a STRING widget, needed a hand-written
canvas widget to draw it, and without one it renders as raw JSON. The file it
documented (`web/funpack_list.js`) did not even exist, so on the stock canvas
the list was raw JSON in v4 too.
The cost of numbered slots is a fixed ceiling. Four is above anything shipping
(LTX-2.3 needs two), and raising it later only adds optional inputs, which is
backwards-compatible with every saved graph.
"""
import comfy.sd
import folder_paths
import torch
from comfy_api.latest import io
MAX_ENCODERS = 4
def clip_types():
try:
return [t.name.lower() for t in comfy.sd.CLIPType]
except Exception: # noqa: BLE001
return ["stable_diffusion"]
def encoder_files():
return folder_paths.get_filename_list("text_encoders")
class FunPackCLIPLoader(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
types = clip_types()
files = encoder_files()
default_type = "ltxv" if "ltxv" in types else (types[0] if types else "stable_diffusion")
slots = [io.Combo.Input("clip_name1", options=files,
tooltip="Text encoder file. The first slot is the encoder itself.")]
# Optional, and "" means "no file here" -- an empty slot must not be a
# validation failure, because most families only need one.
slots += [
io.Combo.Input(f"clip_name{n}", options=[""] + files, default="", optional=True,
tooltip="Another encoder file, loaded after the ones above. "
"Leave empty if the family needs no more.")
for n in range(2, MAX_ENCODERS + 1)
]
return io.Schema(
node_id="FunPackCLIPLoader",
display_name="FunPack CLIP Loader",
category="FunPack/Loaders",
description="Load one or more text encoder files into a single CLIP.",
inputs=[
*slots,
io.Combo.Input("type", options=types, default=default_type,
tooltip="Which family the encoder is for. LTX-2 is 'ltxv'; "
"MiniMax H3 is 'minimax'."),
io.Combo.Input("device", options=["default", "cpu"], default="default",
optional=True,
tooltip="'cpu' keeps the encoder off the GPU: slower prompts, "
"more VRAM for the diffusion model."),
],
outputs=[
io.Clip.Output(display_name="clip"),
io.String.Output(display_name="status"),
],
)
@classmethod
def execute(cls, clip_name1: str, type: str, device: str = "default", **slots) -> io.NodeOutput:
# Order matters to load_clip, so the slots are read in slot order rather
# than in whatever order kwargs arrive.
names = [clip_name1] + [slots.get(f"clip_name{n}") for n in range(2, MAX_ENCODERS + 1)]
names = [name for name in names if name]
if not names:
raise RuntimeError("FunPack CLIP Loader: pick at least one text encoder file.")
clip_type = getattr(comfy.sd.CLIPType, str(type).upper(),
comfy.sd.CLIPType.STABLE_DIFFUSION)
model_options = {}
if device == "cpu":
model_options["load_device"] = model_options["offload_device"] = torch.device("cpu")
paths = [folder_paths.get_full_path_or_raise("text_encoders", name) for name in names]
clip = comfy.sd.load_clip(
ckpt_paths=paths,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
clip_type=clip_type,
model_options=model_options,
)
if clip is None:
# comfy returns None for an unrecognised encoder rather than raising,
# and a None CLIP fails at encode time, far from the mistake.
raise RuntimeError(
f"Could not load a text encoder from {', '.join(names)} as type {type!r}.")
listing = "\n".join(f" {i + 1}. {name}" for i, name in enumerate(names))
return io.NodeOutput(clip, f"FunPack CLIP Loader | type={type} device={device}\n{listing}")
+103
View File
@@ -0,0 +1,103 @@
"""Shared loading options.
Not a module: it announces nothing and ships no node. `registry.scan` only walks
two levels of DIRECTORIES, so a plain file at the domain level is a helper the
loaders import and nothing else.
These options are on the loader node rather than in a settings payload, and the
line is worth stating: a loader widget describes the FILE and how it is read,
which the loader cannot be handed by anyone else. Feature knobs -- strengths,
sigma windows, anything a modifier owns -- are settings, and never appear here.
"""
import logging
import torch
# Named exactly as core's UNETLoader names them where they overlap, so a value
# copied from a stock workflow means the same thing here.
WEIGHT_DTYPES = ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2", "fp16", "bf16", "fp32"]
COMPUTE_DTYPES = ["default", "fp16", "bf16", "fp32"]
VAE_DTYPES = ["bfloat16", "float16", "float32"]
_BY_NAME = {
"fp16": torch.float16,
"bf16": torch.bfloat16,
"fp32": torch.float32,
"float16": torch.float16,
"bfloat16": torch.bfloat16,
"float32": torch.float32,
"fp8_e4m3fn": getattr(torch, "float8_e4m3fn", None),
"fp8_e5m2": getattr(torch, "float8_e5m2", None),
# fp8_e4m3fn plus comfy's fp8 matmul path; see weight_model_options.
"fp8_e4m3fn_fast": getattr(torch, "float8_e4m3fn", None),
}
def dtype_of(name):
"""The torch dtype a choice names, or None for 'default'/an unsupported build."""
return _BY_NAME.get(str(name or "default"))
def weight_model_options(weight_dtype):
"""comfy `model_options` for a weight dtype. The mapping core's UNETLoader uses."""
options = {}
dtype = dtype_of(weight_dtype)
if dtype is not None:
options["dtype"] = dtype
if weight_dtype == "fp8_e4m3fn_fast":
options["fp8_optimizations"] = True
return options
def attention_choices():
"""Backends this ComfyUI can actually run.
Read from ComfyUI's own registry rather than a private import, so the list
offers exactly what the machine has -- sage where SageAttention is installed,
flash where flash-attn is -- and never lies about the rest. 'default' leaves
whatever ComfyUI itself selected from its launch flags.
"""
names = []
try:
from comfy.ldm.modules.attention import REGISTERED_ATTENTION_FUNCTIONS
names = sorted(REGISTERED_ATTENTION_FUNCTIONS.keys())
except Exception: # noqa: BLE001 -- older ComfyUI
pass
return ["default"] + names
def attention_override(name):
"""A transformer_options override routing every attention call to `name`.
ComfyUI wraps each implementation with `wrap_attn`, which hands the override
the original function plus the call's arguments -- so calling the WRAPPED
replacement would re-enter that machinery. The unwrapped one is what gets
called.
"""
if not name or name == "default":
return None
try:
from comfy.ldm.modules.attention import get_attention_function
except ImportError:
logging.warning("[FunPack] this ComfyUI has no attention registry; leaving attention alone")
return None
chosen = get_attention_function(name, None)
if chosen is None:
logging.warning("[FunPack] attention backend %r is not available; leaving attention alone", name)
return None
inner = getattr(chosen, "__wrapped__", chosen)
def override(_func, *args, **kwargs):
return inner(*args, **kwargs)
return override
def set_fp16_accumulation(enabled):
"""torch's fp16 accumulation switch, or None where the build has no such knob."""
matmul = getattr(getattr(torch.backends, "cuda", None), "matmul", None)
if matmul is None or not hasattr(matmul, "allow_fp16_accumulation"):
return None
matmul.allow_fp16_accumulation = bool(enabled)
return bool(enabled)
@@ -0,0 +1,10 @@
"""Diffusion model loader. A node-only module."""
from .nodes import FunPackDiffusionModelLoader
ID = "loader_diffusion_model"
TITLE = "Diffusion model loader"
STAGE = "load"
STATUS = "proven"
NODES = [FunPackDiffusionModelLoader]
+86
View File
@@ -0,0 +1,86 @@
"""The diffusion model loader.
Picking a file and how to read it. Every option here changes what the loaded
model IS, which is why they are node widgets and not settings: nobody downstream
can supply them, and a saved workflow has to record them.
"""
import comfy.sd
import comfy.utils
import folder_paths
from comfy_api.latest import io
from ..common import (COMPUTE_DTYPES, WEIGHT_DTYPES, attention_choices,
attention_override, dtype_of, set_fp16_accumulation,
weight_model_options)
class FunPackDiffusionModelLoader(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="FunPackDiffusionModelLoader",
display_name="FunPack Diffusion Model Loader",
category="FunPack/Loaders",
description="Load a diffusion model, choosing precision and attention backend.",
inputs=[
io.Combo.Input("model_name", options=folder_paths.get_filename_list("diffusion_models")),
io.Combo.Input("weight_dtype", options=WEIGHT_DTYPES, default="default",
tooltip="How the weights are stored. 'default' keeps whatever "
"the file already is."),
io.Combo.Input("compute_dtype", options=COMPUTE_DTYPES, default="default",
tooltip="What the maths runs in, independently of storage."),
io.Combo.Input("attention", options=attention_choices(), default="default",
tooltip="Attention backend. Only what this machine can run is "
"listed; 'default' keeps ComfyUI's launch choice."),
io.Boolean.Input("fp16_accumulation", default=False, optional=True,
tooltip="Faster fp16 matmuls where the torch build supports it."),
],
outputs=[
io.Model.Output(display_name="model"),
io.String.Output(display_name="status"),
],
)
@classmethod
def execute(cls, model_name: str, weight_dtype: str, compute_dtype: str,
attention: str, fp16_accumulation: bool = False) -> io.NodeOutput:
notes = [f"FunPack Diffusion Model Loader | {model_name}"]
accumulation = set_fp16_accumulation(fp16_accumulation)
if fp16_accumulation and accumulation is None:
notes.append("fp16_accumulation: unsupported by this torch build, ignored")
elif accumulation is not None:
notes.append(f"fp16_accumulation: {accumulation}")
model_options = weight_model_options(weight_dtype)
notes.append(f"weight dtype: {weight_dtype}")
path = folder_paths.get_full_path_or_raise("diffusion_models", model_name)
state_dict, metadata = comfy.utils.load_torch_file(path, return_metadata=True)
model = comfy.sd.load_diffusion_model_state_dict(
state_dict, model_options=model_options, metadata=metadata)
if model is None:
# comfy returns None rather than raising, and a None reaching the
# sampler fails much further away than the mistake.
raise RuntimeError(
f"Could not detect a diffusion model in {model_name}. Loading a text "
f"encoder or a VAE file here is the usual cause.")
dtype = dtype_of(compute_dtype)
if dtype is not None and hasattr(model, "set_model_compute_dtype"):
model.set_model_compute_dtype(dtype)
model.force_cast_weights = False
notes.append(f"compute dtype: {compute_dtype}")
elif dtype is not None:
notes.append("compute dtype: unsupported by this ComfyUI, ignored")
override = attention_override(attention)
if override is not None:
model.model_options.setdefault("transformer_options", {})[
"optimized_attention_override"] = override
notes.append(f"attention: {attention}")
else:
notes.append("attention: default (as launched)")
return io.NodeOutput(model, "\n".join(notes))
+10
View File
@@ -0,0 +1,10 @@
"""LoRA loader. A node-only module."""
from .nodes import FunPackLoraLoader
ID = "loader_lora"
TITLE = "LoRA loader"
STAGE = "load"
STATUS = "proven"
NODES = [FunPackLoraLoader]
+58
View File
@@ -0,0 +1,58 @@
"""The LoRA loader.
One LoRA per node, chained for more. v4 carried N LoRAs in a single node behind a
JSON list widget and a `FlexibleOptionalInputType` -- a dict subclass that lied to
ComfyUI's validator so arbitrary keys would pass. Chaining needs neither: it is
what ComfyUI's own LoraLoader does, every strength is a real socket, and the graph
shows how many LoRAs are actually applied instead of hiding the count in a string.
"""
import comfy.sd
import comfy.utils
import folder_paths
from comfy_api.latest import io
class FunPackLoraLoader(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="FunPackLoraLoader",
display_name="FunPack LoRA Loader",
category="FunPack/Loaders",
description="Apply one LoRA to a model, and optionally its text encoder. "
"Chain these for more than one.",
inputs=[
io.Model.Input("model"),
io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras")),
io.Float.Input("strength_model", default=1.0, min=-100.0, max=100.0, step=0.01),
io.Clip.Input("clip", optional=True,
tooltip="Leave unwired for a model-only LoRA."),
io.Float.Input("strength_clip", default=1.0, min=-100.0, max=100.0, step=0.01,
optional=True),
],
outputs=[
io.Model.Output(display_name="model"),
io.Clip.Output(display_name="clip"),
io.String.Output(display_name="status"),
],
)
@classmethod
def execute(cls, model, lora_name: str, strength_model: float,
clip=None, strength_clip: float = 1.0) -> io.NodeOutput:
# Doing nothing is a valid request, but it must return the SAME objects
# rather than clones: a clone here would silently drop patches a later
# loader in the chain applied to the original.
if strength_model == 0 and (clip is None or strength_clip == 0):
return io.NodeOutput(model, clip, f"{lora_name}: both strengths are 0, not applied")
path = folder_paths.get_full_path_or_raise("loras", lora_name)
lora = comfy.utils.load_torch_file(path, safe_load=True)
patched_model, patched_clip = comfy.sd.load_lora_for_models(
model, clip, lora, strength_model, strength_clip)
applied = [f"model={strength_model}"]
applied.append(f"clip={strength_clip}" if clip is not None else "clip=not wired")
return io.NodeOutput(patched_model, patched_clip,
f"FunPack LoRA Loader | {lora_name} ({', '.join(applied)})")
@@ -0,0 +1,119 @@
"""The attention override, carried all the way to a real attention call.
This is the seam the loader is most likely to get silently wrong: a bad override
loads perfectly and then fails at the first attention call, which is sampling.
None of it needs model weights, so there is no excuse for not running it.
"""
import pytest
@pytest.fixture(scope="module")
def torch_and_attention(comfyui):
import torch
import comfy.ldm.modules.attention as attention
return torch, attention
@pytest.fixture
def qkv(torch_and_attention):
torch, _ = torch_and_attention
torch.manual_seed(0)
heads, dim = 2, 16
shape = (1, 8, heads * dim)
return torch.randn(shape), torch.randn(shape), torch.randn(shape), heads
def test_every_backend_this_machine_offers_survives_the_override(torch_and_attention, qkv):
"""The override is handed the WRAPPED function plus the wrapper's own kwargs
(`_inside_attn_wrapper` among them). If the unwrapped backend chokes on any
of that, choosing a backend breaks sampling and nothing else."""
torch, attention = torch_and_attention
from modules.loaders.common import attention_override
q, k, v, heads = qkv
reference = attention.get_attention_function("pytorch")(q, k, v, heads=heads)
for name in sorted(attention.REGISTERED_ATTENTION_FUNCTIONS):
override = attention_override(name)
assert override is not None, f"{name} is registered but produced no override"
out = override(attention.get_attention_function(name), q, k, v, heads=heads,
transformer_options={}, _inside_attn_wrapper=True)
assert out.shape == reference.shape, f"{name} changed the output shape"
def test_an_installed_override_is_actually_reached_by_comfys_dispatch(torch_and_attention, qkv):
"""Proves the KEY the loader writes is the key comfy reads. A typo here is
invisible: the model loads, sampling runs, and the chosen backend is simply
never used."""
torch, attention = torch_and_attention
from modules.loaders.common import attention_override
q, k, v, heads = qkv
inner = attention_override("pytorch")
reached = []
def spy(func, *args, **kwargs):
reached.append(True)
return inner(func, *args, **kwargs)
# Exactly the dict the diffusion loader writes into model_options.
transformer_options = {"optimized_attention_override": spy}
out = attention.optimized_attention(q, k, v, heads=heads,
transformer_options=transformer_options)
assert reached, "the override was installed where nothing reads it"
plain = attention.optimized_attention(q, k, v, heads=heads, transformer_options={})
assert torch.allclose(out, plain, atol=1e-5), (
"routing through the override changed the result")
def test_the_loader_writes_the_override_where_dispatch_looks(comfyui, monkeypatch):
"""The loader's own installation step, without loading a model: the key name
and its nesting under model_options are what this pins."""
import comfy.sd
import comfy.utils
import folder_paths
from comfy.ldm.modules import attention as attn
from modules.loaders.diffusion_model import nodes
class FakeModel:
def __init__(self):
self.model_options = {}
def wrapped(*args, **kwargs):
raise AssertionError("called the wrapped function, re-entering wrap_attn")
wrapped.__wrapped__ = lambda *a, **k: "attended"
monkeypatch.setattr(attn, "get_attention_function", lambda name, default: wrapped)
monkeypatch.setattr(folder_paths, "get_full_path_or_raise", lambda kind, name: f"/fake/{name}")
monkeypatch.setattr(comfy.utils, "load_torch_file", lambda p, **kw: ({}, None))
monkeypatch.setattr(comfy.sd, "load_diffusion_model_state_dict",
lambda sd, **kw: FakeModel())
out = nodes.FunPackDiffusionModelLoader.execute(
model_name="m.safetensors", weight_dtype="default",
compute_dtype="default", attention="pytorch")
model = out.result[0]
installed = model.model_options["transformer_options"]["optimized_attention_override"]
assert callable(installed)
assert installed(wrapped, "q") == "attended"
def test_a_model_comfy_could_not_identify_fails_at_the_loader(comfyui, monkeypatch):
"""comfy returns None instead of raising, and a None model reaches the
sampler before anything complains."""
import comfy.sd
import comfy.utils
import folder_paths
from modules.loaders.diffusion_model import nodes
monkeypatch.setattr(folder_paths, "get_full_path_or_raise", lambda kind, name: f"/fake/{name}")
monkeypatch.setattr(comfy.utils, "load_torch_file", lambda p, **kw: ({}, None))
monkeypatch.setattr(comfy.sd, "load_diffusion_model_state_dict", lambda sd, **kw: None)
with pytest.raises(RuntimeError, match="Could not detect a diffusion model"):
nodes.FunPackDiffusionModelLoader.execute(
model_name="actually_a_vae.safetensors", weight_dtype="default",
compute_dtype="default", attention="default")
@@ -0,0 +1,156 @@
"""What the loaders actually do with what they are given.
Real weights cannot be loaded here (this machine's model files are zero-byte
placeholders), so the file-reading boundary is stubbed and everything on OUR side
of it runs for real: slot ordering, empty slots, no-op strengths, and where the
attention override is installed.
"""
import pytest
@pytest.fixture(autouse=True)
def _needs_comfy(comfyui):
"""Every test here imports a node module, which imports comfy."""
def test_empty_encoder_slots_are_skipped_not_looked_up(monkeypatch):
"""An empty optional slot means "no file here". Passing "" through to
get_full_path_or_raise turns a normal one-encoder setup into a hard error."""
import comfy.sd
import folder_paths
from modules.loaders.clip import nodes
asked = []
monkeypatch.setattr(folder_paths, "get_full_path_or_raise",
lambda kind, name: asked.append(name) or f"/fake/{name}")
monkeypatch.setattr(folder_paths, "get_folder_paths", lambda kind: [])
monkeypatch.setattr(comfy.sd, "load_clip", lambda **kw: object())
nodes.FunPackCLIPLoader.execute(
clip_name1="encoder.safetensors", type="ltxv",
clip_name2="", clip_name3=None, clip_name4="")
assert asked == ["encoder.safetensors"]
def test_encoder_slots_load_in_slot_order(monkeypatch):
"""load_clip cares about order: the encoder comes before its connector.
Reading **kwargs in arrival order would make that arbitrary."""
import comfy.sd
import folder_paths
from modules.loaders.clip import nodes
seen = {}
monkeypatch.setattr(folder_paths, "get_full_path_or_raise", lambda kind, name: f"/fake/{name}")
monkeypatch.setattr(folder_paths, "get_folder_paths", lambda kind: [])
monkeypatch.setattr(comfy.sd, "load_clip",
lambda **kw: seen.update(kw) or object())
# Deliberately out of order in the call, which is how kwargs can arrive.
nodes.FunPackCLIPLoader.execute(
clip_name3="third.safetensors", clip_name2="second.safetensors",
clip_name1="first.safetensors", type="ltxv")
assert seen["ckpt_paths"] == ["/fake/first.safetensors",
"/fake/second.safetensors",
"/fake/third.safetensors"]
def test_an_unrecognised_encoder_fails_here_not_at_encode_time(monkeypatch):
"""comfy returns None rather than raising, and a None CLIP fails much later,
a long way from the wrong file that caused it."""
import comfy.sd
import folder_paths
from modules.loaders.clip import nodes
monkeypatch.setattr(folder_paths, "get_full_path_or_raise", lambda kind, name: f"/fake/{name}")
monkeypatch.setattr(folder_paths, "get_folder_paths", lambda kind: [])
monkeypatch.setattr(comfy.sd, "load_clip", lambda **kw: None)
with pytest.raises(RuntimeError, match="Could not load a text encoder"):
nodes.FunPackCLIPLoader.execute(clip_name1="wrong.safetensors", type="ltxv")
def test_no_encoder_at_all_is_refused(monkeypatch):
from modules.loaders.clip import nodes
with pytest.raises(RuntimeError, match="at least one"):
nodes.FunPackCLIPLoader.execute(clip_name1="", type="ltxv")
def test_a_lora_at_zero_strength_passes_the_originals_through(monkeypatch):
"""Not clones. A clone here silently discards patches an earlier loader in
the chain applied to the same objects."""
import comfy.sd
from modules.loaders.lora import nodes
def _explode(*a, **k):
raise AssertionError("a zero-strength LoRA still read the file")
monkeypatch.setattr(comfy.sd, "load_lora_for_models", _explode)
model, clip = object(), object()
out = nodes.FunPackLoraLoader.execute(
model=model, lora_name="x.safetensors", strength_model=0.0,
clip=clip, strength_clip=0.0)
assert out.result[0] is model and out.result[1] is clip
def test_a_model_only_lora_still_applies_at_zero_clip_strength(monkeypatch):
"""clip is unwired, so strength_clip is meaningless -- but strength_model is
not, and skipping the whole thing would silently drop the LoRA."""
import comfy.sd
import comfy.utils
import folder_paths
from modules.loaders.lora import nodes
called = {}
monkeypatch.setattr(folder_paths, "get_full_path_or_raise", lambda kind, name: f"/fake/{name}")
monkeypatch.setattr(comfy.utils, "load_torch_file", lambda p, **kw: {"fake": 1})
monkeypatch.setattr(comfy.sd, "load_lora_for_models",
lambda m, c, l, sm, sc: called.update(sm=sm, sc=sc) or ("patched", None))
out = nodes.FunPackLoraLoader.execute(
model=object(), lora_name="x.safetensors", strength_model=0.8, clip=None)
assert called["sm"] == 0.8
assert out.result[0] == "patched"
def test_the_attention_override_calls_the_unwrapped_backend(monkeypatch):
"""ComfyUI wraps each backend with wrap_attn and hands the override the
ORIGINAL function. Calling the wrapped one re-enters that machinery."""
from comfy.ldm.modules import attention as attn
from modules.loaders import common
calls = []
def wrapped(*args, **kwargs):
calls.append("wrapped")
def unwrapped(*args, **kwargs):
calls.append("unwrapped")
return "result"
wrapped.__wrapped__ = unwrapped
monkeypatch.setattr(attn, "get_attention_function", lambda name, default: wrapped)
override = common.attention_override("anything")
assert override(wrapped, "q", "k", "v") == "result"
assert calls == ["unwrapped"], "the override re-entered wrap_attn"
def test_default_attention_installs_nothing(monkeypatch):
"""'default' must leave ComfyUI's own launch-flag choice alone."""
from modules.loaders import common
assert common.attention_override("default") is None
assert common.attention_override("") is None
assert common.attention_override(None) is None
def test_an_unavailable_backend_does_not_silently_become_a_broken_override(monkeypatch):
from comfy.ldm.modules import attention as attn
from modules.loaders import common
monkeypatch.setattr(attn, "get_attention_function", lambda name, default: None)
assert common.attention_override("sage") is None
@@ -0,0 +1,60 @@
"""What the loaders present to the graph, and to the person wiring it.
These are seam tests, not unit tests: a loader that "loads" is worth nothing if
what it offers cannot be filled in, or if what it produces is not what the next
stage reads. No model weights are needed for any of this.
"""
import pytest
@pytest.fixture(scope="module")
def schemas(comfyui):
from modules.loaders.clip.nodes import FunPackCLIPLoader
from modules.loaders.diffusion_model.nodes import FunPackDiffusionModelLoader
from modules.loaders.lora.nodes import FunPackLoraLoader
from modules.loaders.vae.nodes import FunPackVAELoader
return [FunPackCLIPLoader, FunPackDiffusionModelLoader,
FunPackLoraLoader, FunPackVAELoader]
def test_a_file_picker_is_a_widget_not_a_socket(schemas):
"""The failure this exists for: ComfyUI's autogrow input sets
force_input=True on any WidgetInput, so a list of Combos renders as a row of
sockets and the file picker stops being a picker. A dropdown nobody can drop
down is not a loader."""
for node in schemas:
spec = node.INPUT_TYPES()
for section in ("required", "optional"):
for name, (kind, options) in (spec.get(section) or {}).items():
if kind != "COMBO":
continue
assert not options.get("forceInput"), (
f"{node.__name__}.{name} is a COMBO forced to be a socket; "
f"nobody can pick a file from it"
)
def test_every_combo_offers_something_to_pick(schemas):
for node in schemas:
spec = node.INPUT_TYPES()
for section in ("required", "optional"):
for name, (kind, options) in (spec.get(section) or {}).items():
if kind != "COMBO":
continue
assert "options" in options, f"{node.__name__}.{name} has no options at all"
def test_a_default_is_actually_one_of_the_options(schemas):
"""A default outside its own list makes ComfyUI refuse to queue the whole
prompt with 'Value not in list' -- and the node looks configured."""
for node in schemas:
spec = node.INPUT_TYPES()
for section in ("required", "optional"):
for name, (kind, options) in (spec.get(section) or {}).items():
if kind != "COMBO" or "default" not in options:
continue
assert options["default"] in options["options"], (
f"{node.__name__}.{name} defaults to {options['default']!r}, "
f"which is not one of its options"
)