diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..a72e94af --- /dev/null +++ b/conftest.py @@ -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 diff --git a/core/tests/test_shipped_nodes.py b/core/tests/test_shipped_nodes.py index 53ade0fd..49af25c9 100644 --- a/core/tests/test_shipped_nodes.py +++ b/core/tests/test_shipped_nodes.py @@ -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"], } diff --git a/modules/loaders/clip/__init__.py b/modules/loaders/clip/__init__.py new file mode 100644 index 00000000..774a9974 --- /dev/null +++ b/modules/loaders/clip/__init__.py @@ -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] diff --git a/modules/loaders/clip/nodes.py b/modules/loaders/clip/nodes.py new file mode 100644 index 00000000..a1650b82 --- /dev/null +++ b/modules/loaders/clip/nodes.py @@ -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}") diff --git a/modules/loaders/common.py b/modules/loaders/common.py new file mode 100644 index 00000000..594ee13d --- /dev/null +++ b/modules/loaders/common.py @@ -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) diff --git a/modules/loaders/diffusion_model/__init__.py b/modules/loaders/diffusion_model/__init__.py new file mode 100644 index 00000000..d607f858 --- /dev/null +++ b/modules/loaders/diffusion_model/__init__.py @@ -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] diff --git a/modules/loaders/diffusion_model/nodes.py b/modules/loaders/diffusion_model/nodes.py new file mode 100644 index 00000000..4854b0bd --- /dev/null +++ b/modules/loaders/diffusion_model/nodes.py @@ -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)) diff --git a/modules/loaders/lora/__init__.py b/modules/loaders/lora/__init__.py new file mode 100644 index 00000000..b61e329e --- /dev/null +++ b/modules/loaders/lora/__init__.py @@ -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] diff --git a/modules/loaders/lora/nodes.py b/modules/loaders/lora/nodes.py new file mode 100644 index 00000000..814f4665 --- /dev/null +++ b/modules/loaders/lora/nodes.py @@ -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)})") diff --git a/modules/loaders/tests/test_attention_path.py b/modules/loaders/tests/test_attention_path.py new file mode 100644 index 00000000..ce05435f --- /dev/null +++ b/modules/loaders/tests/test_attention_path.py @@ -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") diff --git a/modules/loaders/tests/test_loader_behaviour.py b/modules/loaders/tests/test_loader_behaviour.py new file mode 100644 index 00000000..f969be14 --- /dev/null +++ b/modules/loaders/tests/test_loader_behaviour.py @@ -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 diff --git a/modules/loaders/tests/test_loader_schemas.py b/modules/loaders/tests/test_loader_schemas.py new file mode 100644 index 00000000..aabad313 --- /dev/null +++ b/modules/loaders/tests/test_loader_schemas.py @@ -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" + )