Picked in the diffusion model loader's attention list, not wired as a node. Six settings folded under Advanced; offered only where CUDA and Triton run it and ignored on any model that is not H3. Kernel vendored from LightX2V. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
320 lines
16 KiB
Python
320 lines
16 KiB
Python
"""Tests for the pluggable node-slot discovery (object_info filtering)."""
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from movie_editor.backend import nodes # noqa: E402
|
|
|
|
OI = {
|
|
"CheckpointLoaderSimple": {"input": {"required": {"ckpt_name": [["a.safetensors", "b.ckpt"]]}},
|
|
"output": ["MODEL", "CLIP", "VAE"], "display_name": "Load Checkpoint"},
|
|
"VAELoader": {"input": {"required": {"vae_name": [["ltxv-vae.safetensors"]]}},
|
|
"output": ["VAE"], "display_name": "Load VAE"},
|
|
"LoraLoader": {"input": {"required": {"model": ["MODEL"], "clip": ["CLIP"],
|
|
"lora_name": [["l.safetensors"]],
|
|
"strength_model": ["FLOAT", {"default": 1.0}]}},
|
|
"output": ["MODEL", "CLIP"], "display_name": "Load LoRA"},
|
|
"EmptyLTXVLatentVideo": {"input": {"required": {"width": ["INT", {"default": 768}],
|
|
"length": ["INT", {"default": 97}]}},
|
|
"output": ["LATENT"], "display_name": "Empty LTXV Latent"},
|
|
"KSampler": {"input": {"required": {"model": ["MODEL"], "latent_image": ["LATENT"]}},
|
|
"output": ["LATENT"], "display_name": "KSampler"},
|
|
"LTXVPreprocess": {"input": {"required": {"image": ["IMAGE"], "crf": ["INT", {"default": 35}]}},
|
|
"output": ["IMAGE"], "display_name": "LTXV Preprocess"},
|
|
}
|
|
|
|
|
|
def names(role):
|
|
return [c["class"] for c in nodes.candidates(OI, role)]
|
|
|
|
|
|
def test_source_roles_exclude_patchers():
|
|
assert names("unet") == ["CheckpointLoaderSimple"] # LoraLoader (consumes MODEL) excluded
|
|
assert names("empty_latent") == ["EmptyLTXVLatentVideo"] # KSampler (consumes LATENT) excluded
|
|
assert names("clip") == ["CheckpointLoaderSimple"]
|
|
|
|
|
|
def test_vae_role_matches_all_vae_sources():
|
|
assert set(names("video_vae")) == {"CheckpointLoaderSimple", "VAELoader"}
|
|
assert set(names("audio_vae")) == {"CheckpointLoaderSimple", "VAELoader"}
|
|
|
|
|
|
def test_patcher_and_processor_roles():
|
|
assert names("lora") == ["LoraLoader"] # MODEL in + MODEL out
|
|
assert names("image_processing") == ["LTXVPreprocess"] # IMAGE in + IMAGE out
|
|
|
|
|
|
def test_widget_inputs_skip_connections_and_expose_combos():
|
|
lora = next(c for c in nodes.candidates(OI, "lora") if c["class"] == "LoraLoader")
|
|
kinds = {w["name"]: w["kind"] for w in lora["inputs"]}
|
|
assert "model" not in kinds and "clip" not in kinds # connections skipped
|
|
assert kinds["lora_name"] == "combo"
|
|
assert kinds["strength_model"] == "float"
|
|
ckpt = next(c for c in nodes.candidates(OI, "unet") if c["class"] == "CheckpointLoaderSimple")
|
|
assert ckpt["inputs"][0]["choices"] == ["a.safetensors", "b.ckpt"]
|
|
|
|
|
|
def test_node_outputs_and_connection_inputs():
|
|
lora = next(c for c in nodes.candidates(OI, "lora") if c["class"] == "LoraLoader")
|
|
assert {o["type"] for o in lora["outputs"]} == {"MODEL", "CLIP"}
|
|
ci = {c["name"]: c["type"] for c in lora["connection_inputs"]}
|
|
assert ci == {"model": "MODEL", "clip": "CLIP"} # widgets (lora_name/strength) excluded
|
|
|
|
|
|
def test_ports_from_input_types_derives_connection_points():
|
|
it = {
|
|
"required": {"rating": (["a", "b"], {}), "studio_settings": ("STRING", {})},
|
|
"optional": {"model": ("MODEL",), "clip": ("CLIP",), "source_image": ("IMAGE",),
|
|
"latent": ("LATENT",), "positive_prompt": ("STRING", {"forceInput": True})},
|
|
}
|
|
ports = nodes.ports_from_input_types("Studio", "FunPackStudio", it)
|
|
by_input = {p["input"]: p["type"] for p in ports}
|
|
assert by_input == {
|
|
"model": "MODEL",
|
|
"clip": "CLIP",
|
|
"source_image": "IMAGE",
|
|
"latent": "LATENT",
|
|
"positive_prompt": "STRING",
|
|
}
|
|
assert all(p["id"].startswith("FunPackStudio.") for p in ports) # STRING widgets excluded
|
|
|
|
|
|
def test_all_nodes_and_describe_node():
|
|
listing = nodes.all_nodes(OI)
|
|
assert {n["class"] for n in listing} == set(OI) # every node addable via "any node"
|
|
spec = nodes.describe_node(OI, "LTXVPreprocess")
|
|
assert spec["class"] == "LTXVPreprocess"
|
|
assert any(o["type"] == "IMAGE" for o in spec["outputs"])
|
|
assert nodes.describe_node(OI, "NoSuchNode") is None
|
|
|
|
|
|
def test_ports_from_object_info_exposes_core_inputs():
|
|
oi = {"LTXVAudioVAEDecode": {"input": {"required": {"samples": ["LATENT"], "audio_vae": ["VAE"]}},
|
|
"output": ["AUDIO"]}}
|
|
ports = nodes.ports_from_object_info(oi, "LTXVAudioVAEDecode", "Audio VAE Decode")
|
|
by = {p["input"]: p["type"] for p in ports}
|
|
assert by == {"samples": "LATENT", "audio_vae": "VAE"}
|
|
assert all(p["label"].startswith("Audio VAE Decode · ") for p in ports)
|
|
|
|
|
|
def test_malformed_node_is_skipped_not_fatal():
|
|
# a node with V3-style dict outputs / odd shapes must not crash the whole list
|
|
bad = dict(OI)
|
|
bad["WeirdNode"] = {"input": {"required": {"x": {"nested": "dict"}}},
|
|
"output": {"a": "MODEL"}, "output_name": {"a": "model"},
|
|
"display_name": "Weird", "category": "x"}
|
|
bad["AlsoBad"] = {"input": "not-a-dict", "output": None}
|
|
# candidates for a role still returns the good loaders, no exception
|
|
got = {c["class"] for c in nodes.candidates(bad, "unet")}
|
|
assert "CheckpointLoaderSimple" in got
|
|
# all_nodes lists everything (incl. the weird ones) without crashing
|
|
assert {n["class"] for n in nodes.all_nodes(bad)} >= set(OI)
|
|
|
|
|
|
def test_v1_combo_and_force_input():
|
|
"""V1/io.ComfyNode nodes encode combos as ("COMBO", {"options": [...]}) and mark
|
|
socket-only fields with forceInput=True. Both must be handled correctly."""
|
|
v1_node = {
|
|
"input": {
|
|
"required": {
|
|
"model": ["MODEL"],
|
|
"lora_name": ["COMBO", {"options": ["a.safetensors", "b.safetensors"], "tooltip": "pick a LoRA"}],
|
|
"strength_model": ["FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0}],
|
|
},
|
|
"optional": {
|
|
"opt_path": ["STRING", {"forceInput": True, "tooltip": "absolute path override"}],
|
|
"blocks": ["SELECTEDDITBLOCKS"],
|
|
},
|
|
},
|
|
"output": ["MODEL"],
|
|
"display_name": "LTX2 LoRA Advanced",
|
|
"category": "test",
|
|
}
|
|
oi = dict(OI)
|
|
oi["LTX2LoraLoaderAdvanced"] = v1_node
|
|
desc = nodes.describe_node(oi, "LTX2LoraLoaderAdvanced")
|
|
widget_map = {w["name"]: w for w in desc["inputs"]}
|
|
# lora_name is a V1-style COMBO — must appear as combo widget with choices
|
|
assert "lora_name" in widget_map
|
|
assert widget_map["lora_name"]["kind"] == "combo"
|
|
assert "a.safetensors" in widget_map["lora_name"]["choices"]
|
|
# strength_model is a normal FLOAT widget
|
|
assert widget_map["strength_model"]["kind"] == "float"
|
|
# opt_path has forceInput=True → must be a socket, NOT a widget
|
|
assert "opt_path" not in widget_map
|
|
# blocks is a custom connection type — also not a widget
|
|
assert "blocks" not in widget_map
|
|
# model is a connection — not a widget
|
|
assert "model" not in widget_map
|
|
# connection_inputs: model and blocks (custom type)
|
|
ci = {c["name"]: c["type"] for c in desc["connection_inputs"]}
|
|
assert ci["model"] == "MODEL"
|
|
assert ci["blocks"] == "SELECTEDDITBLOCKS"
|
|
|
|
|
|
def test_multitype_widget_is_a_widget_not_a_required_socket():
|
|
"""A V3 MultiType built around a widget input serializes comma-joined with a widgetType
|
|
hint — e.g. LTXVEmptyLatentAudio.frame_rate as ("FLOAT,INT", {"widgetType": "INT"}).
|
|
It is a field the user types into; treating it as a socket demanded a source that no
|
|
node produces and blocked generation."""
|
|
oi = dict(OI)
|
|
oi["LTXVEmptyLatentAudio"] = {
|
|
"input": {"required": {
|
|
"frames_number": ["INT", {"default": 97}],
|
|
"frame_rate": ["FLOAT,INT", {"widgetType": "INT", "default": 25, "min": 1}],
|
|
"batch_size": ["INT", {"default": 1}],
|
|
"audio_vae": ["VAE"],
|
|
}},
|
|
"output": ["LATENT"], "output_name": ["Latent"],
|
|
"display_name": "LTXV Empty Latent Audio",
|
|
}
|
|
desc = nodes.describe_node(oi, "LTXVEmptyLatentAudio")
|
|
widgets = {w["name"]: w for w in desc["inputs"]}
|
|
assert widgets["frame_rate"]["kind"] == "int" # renders as the widget it is
|
|
assert widgets["frame_rate"]["default"] == 25
|
|
ci = {c["name"]: c["type"] for c in desc["connection_inputs"]}
|
|
assert ci == {"audio_vae": "VAE"} # frame_rate is NOT a socket
|
|
# ...and it still qualifies for the audio_encoder role (union widget isn't an input type)
|
|
assert "LTXVEmptyLatentAudio" in [c["class"] for c in nodes.candidates(oi, "audio_encoder")]
|
|
|
|
|
|
def test_multitype_socket_stays_a_socket_and_matches_any_member():
|
|
"""A MultiType with no widget member ("IMAGE,MASK") is a real socket — it must still be
|
|
offered as one, and fed by a producer of either member type."""
|
|
oi = {"ImageResizeMatch": {"input": {"required": {"image": ["IMAGE"],
|
|
"match": ["IMAGE,MASK"]}},
|
|
"output": ["IMAGE"], "display_name": "Resize to Match"}}
|
|
ci = {c["name"]: (c["type"], c["required"]) for c in nodes.describe_node(oi, "ImageResizeMatch")["connection_inputs"]}
|
|
assert ci["match"] == ("IMAGE,MASK", True)
|
|
assert nodes.type_accepts("IMAGE,MASK", "MASK")
|
|
assert nodes.type_accepts("IMAGE,MASK", "IMAGE")
|
|
assert not nodes.type_accepts("IMAGE,MASK", "LATENT")
|
|
assert nodes.widget_type_of("IMAGE,MASK", {}) is None
|
|
assert nodes.widget_type_of("FLOAT,INT", {}) == "FLOAT" # no hint → first member wins
|
|
|
|
|
|
def _autogrow_oi():
|
|
"""MiniMaxH3ReferenceToVideo-shaped node: V3 autogrow list inputs. ComfyUI serializes
|
|
them as one COMFY_AUTOGROW_V3 entry carrying the per-element template."""
|
|
return {"MiniMaxH3ReferenceToVideo": {
|
|
"input": {
|
|
"required": {
|
|
"clip": ["CLIP"],
|
|
"prompt": ["STRING", {"multiline": True}],
|
|
"ref_images": ["COMFY_AUTOGROW_V3", {
|
|
"template": {"input": {"optional": {"ref_image": ["IMAGE", {}]}},
|
|
"prefix": "ref_image", "min": 0, "max": 3}}],
|
|
"ref_audios": ["COMFY_AUTOGROW_V3", {
|
|
"template": {"input": {"optional": {"ref_audio": ["AUDIO", {}]}},
|
|
"names": ["ref_audio_a", "ref_audio_b"], "min": 0}}],
|
|
},
|
|
},
|
|
"output": ["CONDITIONING"], "display_name": "MiniMax H3 Reference To Video",
|
|
}}
|
|
|
|
|
|
def test_autogrow_list_input_expands_into_wireable_sockets():
|
|
"""An autogrow input is a TEMPLATE, not a socket — nothing can be wired to
|
|
'ref_images' itself. Expand it into the indexed sockets ComfyUI actually accepts
|
|
(ref_image0…), typed as the element type, or the timeline image and every IMAGE
|
|
producer are missing from the source picker."""
|
|
desc = nodes.describe_node(_autogrow_oi(), "MiniMaxH3ReferenceToVideo")
|
|
ci = {c["name"]: c for c in desc["connection_inputs"]}
|
|
assert "ref_images" not in ci and "ref_audios" not in ci
|
|
# the socket id is the dotted path ComfyUI splits to rebuild the list — the bare name
|
|
# is rejected as missing, and reaches execute() as an unexpected keyword argument
|
|
names = ("ref_images.ref_image0", "ref_images.ref_image1", "ref_images.ref_image2")
|
|
assert [ci[n]["type"] for n in names] == ["IMAGE"] * 3
|
|
assert ci["ref_images.ref_image0"]["autogrow"] == {"parent": "ref_images", "index": 0}
|
|
assert ci["ref_images.ref_image0"]["display"] == "ref_image0" # UI shows the short name
|
|
# explicit-name templates keep their own names, in order
|
|
assert ci["ref_audios.ref_audio_a"]["type"] == "AUDIO"
|
|
assert ci["ref_audios.ref_audio_b"]["autogrow"] == {"parent": "ref_audios", "index": 1}
|
|
# never required: only the wired indices are sent, so an empty one must not block
|
|
assert not any(ci[n]["required"] for n in ci if n.startswith("ref_"))
|
|
# the list wrapper is not a user widget either
|
|
assert {w["name"] for w in desc["inputs"]} == {"prompt"}
|
|
|
|
|
|
def test_autogrow_element_type_drives_role_matching():
|
|
"""Role matching reads a node's INPUT types; an autogrow list consumes its element
|
|
type (IMAGE), not the COMFY_AUTOGROW_V3 wrapper."""
|
|
oi = _autogrow_oi()
|
|
oi["MiniMaxH3ReferenceToVideo"]["output"] = ["IMAGE"]
|
|
# consumes IMAGE + emits IMAGE = a processor, not a pure source
|
|
assert "MiniMaxH3ReferenceToVideo" in [c["class"] for c in nodes.candidates(oi, "image_processing")]
|
|
|
|
|
|
def test_unreadable_autogrow_template_falls_back_to_the_raw_input():
|
|
"""A template we can't parse (future shape) must leave the input as it was — visible,
|
|
if unwireable — rather than dropping it from the node entirely."""
|
|
oi = {"Weird": {"input": {"required": {"refs": ["COMFY_AUTOGROW_V3", {"template": {}}]}},
|
|
"output": ["CONDITIONING"], "display_name": "Weird"}}
|
|
ci = {c["name"]: c["type"] for c in nodes.describe_node(oi, "Weird")["connection_inputs"]}
|
|
assert ci == {"refs": "COMFY_AUTOGROW_V3"}
|
|
|
|
|
|
def test_models_store_roundtrip(tmp_path, monkeypatch):
|
|
from movie_editor.backend import config
|
|
monkeypatch.setattr(config, "DATA_DIR", tmp_path)
|
|
assert nodes.load_models() == {"slots": []}
|
|
nodes.save_models({"slots": [{"id": "x", "role": "unet", "node_class": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "a.safetensors"}}]})
|
|
assert nodes.load_models()["slots"][0]["node_class"] == "CheckpointLoaderSimple"
|
|
|
|
|
|
# ── FunPack list inputs ───────────────────────────────────────────────────────
|
|
# One STRING widget holding a JSON array of rows, with the row shape declared beside it, is
|
|
# how a FunPack node asks for N of something (text encoders, LoRAs). It must reach the
|
|
# editor as a list, not as a string field showing raw JSON.
|
|
LIST_SPEC = {"item": "text encoder", "add_label": "+ Add slot",
|
|
"fields": [{"name": "clip_name", "kind": "combo", "label": "file",
|
|
"choices": ["gemma4.safetensors"]}]}
|
|
LIST_OI = {
|
|
"FunPackCLIPLoader": {
|
|
"input": {"required": {
|
|
"clip_list": ["STRING", {"default": "[]", "funpack_list": LIST_SPEC}],
|
|
"type": [["ltxv", "minimax"]],
|
|
}},
|
|
"output": ["CLIP"], "display_name": "FunPack CLIP Loader"},
|
|
}
|
|
|
|
|
|
def test_a_list_input_reaches_the_editor_as_a_list_not_a_string():
|
|
spec = nodes.describe_node(LIST_OI, "FunPackCLIPLoader")
|
|
clip_list = next(w for w in spec["inputs"] if w["name"] == "clip_list")
|
|
assert clip_list["kind"] == "list"
|
|
assert clip_list["list"] == LIST_SPEC
|
|
assert clip_list["default"] == "[]"
|
|
|
|
|
|
def test_a_list_input_is_a_widget_not_a_socket():
|
|
spec = nodes.describe_node(LIST_OI, "FunPackCLIPLoader")
|
|
assert [c["name"] for c in spec["connection_inputs"]] == []
|
|
|
|
|
|
def test_a_plain_string_widget_is_still_a_string():
|
|
oi = {"N": {"input": {"required": {"text": ["STRING", {"default": "hi"}]}}, "output": []}}
|
|
assert nodes.describe_node(oi, "N")["inputs"][0]["kind"] == "string"
|
|
|
|
|
|
def test_an_input_the_node_calls_advanced_is_marked_as_such():
|
|
"""The LoRA list is the way to add a LoRA; the trained stack must not read as the way in."""
|
|
nd = {"input": {"optional": {
|
|
"lora_stack": ["FP_LORA_STACK", {"advanced": True}],
|
|
"clip": ["CLIP", {}]}}}
|
|
cis = {ci["name"]: ci for ci in nodes.connection_inputs(nd)}
|
|
assert cis["lora_stack"]["advanced"] is True
|
|
assert "advanced" not in cis["clip"]
|
|
|
|
|
|
def test_a_widget_the_node_calls_advanced_is_marked_as_such():
|
|
"""Five validated SLA knobs must not sit between the user and the file picker."""
|
|
nd = {"input": {"optional": {
|
|
"sla_sparsity": ["FLOAT", {"default": 0.9, "advanced": True}],
|
|
"fp16_accumulation": ["BOOLEAN", {"default": False}]}}}
|
|
widgets = {w["name"]: w for w in nodes.widget_inputs(nd)}
|
|
assert widgets["sla_sparsity"]["advanced"] is True
|
|
assert "advanced" not in widgets["fp16_accumulation"]
|