The wizard sat at z-index 800, above .modal-overlay (500) and below .fw (4000), so "Open Models…" opened behind it and did nothing visible, and the extras step worked around that by closing the wizard first — which dropped you into a half-finished editor. It now sits at 400: Models, Settings and the Composer layer over it the way they layer over the editor, and closing one returns to the step. Prompt splits called Composer.open(), which does not exist; the export is toggle(). Also: MiniMax H3 no longer pulls in ComfyUI-LTXVideo. LTXFloatToInt only converted the project's frame rate for user slots, and H3's rate is fixed at 24 by the model, so an LTX pack was a hard requirement of a pipeline with no use for it. Core producers are now filtered by what the family's graph actually built. The Cutting Room brand links to the hub, as Easy Gen's already did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
852 lines
47 KiB
Python
852 lines
47 KiB
Python
"""Tests for the code graph builder (fixed core + slot wiring + auto-wire)."""
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from movie_editor.backend import builder # noqa: E402
|
|
|
|
OI = {
|
|
"FunPackStudio": {
|
|
"input": {"required": {"rating": [["a", "b"], {"default": "a"}],
|
|
"studio_settings": ["STRING", {"default": "{}"}],
|
|
"adjustments": ["STRING", {"default": "[]"}]},
|
|
"optional": {"model": ["MODEL"], "clip": ["CLIP"], "source_image": ["IMAGE"],
|
|
"latent": ["LATENT"], "positive_conditioning": ["CONDITIONING"],
|
|
"positive_prompt": ["STRING", {"forceInput": True}],
|
|
"negative_prompt": ["STRING", {"forceInput": True}],
|
|
"refinement_key_input": ["STRING", {"forceInput": True}]}},
|
|
"output": ["MODEL", "CONDITIONING", "CONDITIONING", "INT", "SAMPLER", "SIGMAS",
|
|
"SAMPLER", "SIGMAS", "IMAGE", "STRING", "STRING", "STRING", "LATENT"],
|
|
"output_name": ["model", "modified_positive", "negative", "seed", "high_pass_sampler",
|
|
"high_pass_sigmas", "low_pass_sampler", "low_pass_sigmas", "loss_graph",
|
|
"status", "training_info", "encoded_prompts", "video_latent"]},
|
|
"LTXVConditioning": {"input": {"required": {"positive": ["CONDITIONING"], "negative": ["CONDITIONING"],
|
|
"frame_rate": ["FLOAT", {"default": 25}]}},
|
|
"output": ["CONDITIONING", "CONDITIONING"], "output_name": ["positive", "negative"]},
|
|
"FunPackLTXAVSceneChainSampler": {
|
|
"input": {"required": {"model": ["MODEL"], "vae": ["VAE"], "positive": ["CONDITIONING"],
|
|
"negative": ["CONDITIONING"], "sampler": ["SAMPLER"], "sigmas": ["SIGMAS"],
|
|
"seed": ["INT", {"default": 1}], "latent_template": ["LATENT"],
|
|
"num_frames_per_scene": ["INT", {"default": 97}]}},
|
|
"output": ["LATENT", "IMAGE"], "output_name": ["latent", "images"]},
|
|
"LTXVConcatAVLatent": {"input": {"required": {"video_latent": ["LATENT"], "audio_latent": ["LATENT"]}},
|
|
"output": ["LATENT"]},
|
|
"LTXVSeparateAVLatent": {"input": {"required": {"av_latent": ["LATENT"]}},
|
|
"output": ["LATENT", "LATENT"], "output_name": ["video_latent", "audio_latent"]},
|
|
"LTXVAudioVAEDecode": {"input": {"required": {"samples": ["LATENT"], "audio_vae": ["VAE"]}}, "output": ["AUDIO"]},
|
|
"NormalizeAudioLoudness": {"input": {"required": {"audio": ["AUDIO"], "target": ["INT", {"default": -30}]}},
|
|
"output": ["AUDIO"]},
|
|
"VHS_VideoCombine": {"input": {"required": {"images": ["IMAGE"], "frame_rate": ["FLOAT", {"default": 30}]},
|
|
"optional": {"audio": ["AUDIO"]}}, "output": ["VHS_FILENAMES"]},
|
|
"FunPackSaveRefinementLatent": {"input": {"required": {"latent": ["LATENT"], "refinement_key": ["STRING"]}},
|
|
"output": ["LATENT", "STRING"]},
|
|
"FunPackRefinementKeyLoader": {"input": {"required": {"key": ["STRING", {"default": "k"}]}},
|
|
"output": ["STRING", "STRING"], "output_name": ["refinement_key", "status"]},
|
|
"PrimitiveStringMultiline": {"input": {"required": {"value": ["STRING", {"default": ""}]}}, "output": ["STRING"]},
|
|
"PrimitiveInt": {"input": {"required": {"value": ["INT", {"default": 0}]}}, "output": ["INT"]},
|
|
"PrimitiveFloat": {"input": {"required": {"value": ["FLOAT", {"default": 0.0}]}}, "output": ["FLOAT"]},
|
|
"LTXFloatToInt": {"input": {"required": {"a": ["FLOAT", {"default": 0}]}}, "output": ["INT"]},
|
|
# slot nodes
|
|
"UnetLoader": {"input": {"required": {"unet_name": [["m.safetensors"]]}}, "output": ["MODEL"]},
|
|
"ClipLoader": {"input": {"required": {"clip_name": [["c.safetensors"]]}}, "output": ["CLIP"]},
|
|
"VaeLoader": {"input": {"required": {"vae_name": [["v.safetensors"]]}}, "output": ["VAE"]},
|
|
"CondLoader": {"input": {"required": {"cond_name": [["c.json"]]}}, "output": ["CONDITIONING"]},
|
|
"LoadImage": {"input": {"required": {"image": [["a.png"]]}}, "output": ["IMAGE", "MASK"]},
|
|
"ImgProc": {"input": {"required": {"length": ["INT", {"default": 97}], "vae": ["VAE"], "image": ["IMAGE"]}},
|
|
"output": ["LATENT", "LATENT", "IMAGE"], "output_name": ["latent", "Latent", "output_image"]},
|
|
"UpscaleModelLoader": {"input": {"required": {"model_name": [["x4.pth"]]}}, "output": ["UPSCALE_MODEL"],
|
|
"output_name": ["UPSCALE_MODEL"]},
|
|
"ImageUpscaleWithModel": {"input": {"required": {"upscale_model": ["UPSCALE_MODEL"], "image": ["IMAGE"]}},
|
|
"output": ["IMAGE"], "output_name": ["IMAGE"]},
|
|
"LoraLoader": {"input": {"required": {"model": ["MODEL"], "clip": ["CLIP"],
|
|
"lora_name": [["x.safetensors"]],
|
|
"strength_model": ["FLOAT", {"default": 1.0}],
|
|
"strength_clip": ["FLOAT", {"default": 1.0}]}},
|
|
"output": ["MODEL", "CLIP"], "output_name": ["MODEL", "CLIP"]},
|
|
# Real shape of LTXICLoRALoaderModelOnly: a MODEL passthrough plus a FLOAT the graph
|
|
# never reads. strength_model is a widget, so no connection input matches that FLOAT.
|
|
"LTXICLoRALoaderModelOnly": {
|
|
"input": {"required": {"model": ["MODEL"],
|
|
"lora_name": [["x.safetensors"]],
|
|
"strength_model": ["FLOAT", {"default": 1.0}]}},
|
|
"output": ["MODEL", "FLOAT"], "output_name": ["model", "latent_downscale_factor"]},
|
|
}
|
|
|
|
PARAMS = {"prompt": "scene one", "seed": 42, "num_frames_per_scene": 121, "frame_rate": 24}
|
|
|
|
|
|
def test_core_skeleton_links_and_params():
|
|
graph, report = builder.build(OI, {"slots": []}, PARAMS)
|
|
# every fixed-core node emitted with correct class
|
|
for cid, cls in builder.CORE.items():
|
|
assert graph[cid]["class_type"] == cls
|
|
# internal links
|
|
assert graph["sampler"]["inputs"]["model"] == ["studio", 0]
|
|
assert graph["cond"]["inputs"]["positive"] == ["studio", 1]
|
|
assert graph["cond"]["inputs"]["negative"] == ["studio", 2]
|
|
assert graph["concat"]["inputs"]["video_latent"] == ["studio", 12]
|
|
assert graph["sampler"]["inputs"]["latent_template"] == ["concat", 0]
|
|
assert graph["separate"]["inputs"]["av_latent"] == ["sampler", 0]
|
|
assert graph["audiodec"]["inputs"]["samples"] == ["separate", 1]
|
|
assert graph["vhs"]["inputs"]["images"] == ["sampler", 1]
|
|
assert graph["vhs"]["inputs"]["audio"] == ["normaudio", 0]
|
|
# param overrides
|
|
assert graph["pos"]["inputs"]["value"] == "scene one"
|
|
assert graph["frames"]["inputs"]["value"] == 121
|
|
assert graph["fps"]["inputs"]["value"] == 24
|
|
assert graph["sampler"]["inputs"]["seed"] == 42
|
|
# with no slots, the open ports are unsatisfied
|
|
assert any("FunPackStudio" in u and ".model" in u for u in report["unsatisfied"])
|
|
|
|
|
|
def test_run_output_gets_unique_temp_prefix():
|
|
# Each build (= one generation run) must give the VHS preview node its OWN filename_prefix
|
|
# so a later run can't reuse/overwrite an earlier run's temp file — otherwise a previously
|
|
# rendered scene stops playing once the next scene generates. save_output stays False (temp).
|
|
g1, _ = builder.build(OI, {"slots": []}, PARAMS)
|
|
g2, _ = builder.build(OI, {"slots": []}, PARAMS)
|
|
p1 = g1["vhs"]["inputs"]["filename_prefix"]
|
|
p2 = g2["vhs"]["inputs"]["filename_prefix"]
|
|
assert p1.startswith("funpack_preview_")
|
|
assert p2.startswith("funpack_preview_")
|
|
assert p1 != p2 # distinct per run
|
|
assert g1["vhs"]["inputs"]["save_output"] is False
|
|
|
|
|
|
def test_explicit_wires_and_autowire():
|
|
models = {"full_control": True, "slots": [
|
|
{"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "m.safetensors"},
|
|
"wires": {"MODEL": "port:FunPackStudio.model"}},
|
|
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {"CLIP": "port:FunPackStudio.clip"}},
|
|
{"id": "v", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
|
|
{"id": "li", "node_class": "LoadImage", "inputs": {}, "wires": {"IMAGE": "node:ip:image"}},
|
|
{"id": "ip", "node_class": "ImgProc", "inputs": {"length": 121},
|
|
"wires": {"latent": "port:FunPackStudio.latent",
|
|
"Latent": "port:LTXVConcatAVLatent.audio_latent",
|
|
"output_image": "port:FunPackStudio.source_image"}},
|
|
]}
|
|
graph, report = builder.build(OI, models, PARAMS)
|
|
# explicit slot->core wires
|
|
assert graph["studio"]["inputs"]["model"] == ["slot_u", 0]
|
|
assert graph["studio"]["inputs"]["clip"] == ["slot_c", 0]
|
|
assert graph["studio"]["inputs"]["latent"] == ["slot_ip", 0]
|
|
assert graph["studio"]["inputs"]["source_image"] == ["slot_ip", 2]
|
|
assert graph["concat"]["inputs"]["audio_latent"] == ["slot_ip", 1]
|
|
# explicit slot->slot wire (LoadImage -> ImgProc.image)
|
|
assert graph["slot_ip"]["inputs"]["image"] == ["slot_li", 0]
|
|
# auto-wire by unique type: single VAE feeds both VAE consumers
|
|
assert graph["sampler"]["inputs"]["vae"] == ["slot_v", 0]
|
|
assert graph["audiodec"]["inputs"]["audio_vae"] == ["slot_v", 0]
|
|
# slot widget value carried through
|
|
assert graph["slot_ip"]["inputs"]["length"] == 121
|
|
# a complete config has nothing unsatisfied / ambiguous
|
|
assert report["unsatisfied"] == []
|
|
assert report["ambiguous"] == []
|
|
|
|
|
|
def test_wired_positive_conditioning_replaces_the_clip_requirement():
|
|
# Studio can run off a pre-encoded CONDITIONING. With two text encoders installed, CLIP
|
|
# is ambiguous — that must not block generation once positive_conditioning is wired, and
|
|
# the ambiguous CLIP must be left alone rather than auto-wired over the user's own
|
|
# conditioning (Studio prefers CLIP whenever it's connected).
|
|
models = {"full_control": True, "slots": [
|
|
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
|
|
{"id": "c1", "node_class": "ClipLoader", "inputs": {}, "wires": {}},
|
|
{"id": "c2", "node_class": "ClipLoader", "inputs": {}, "wires": {}},
|
|
{"id": "v", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
|
|
{"id": "li", "node_class": "LoadImage", "inputs": {}, "wires": {"IMAGE": "node:ip:image"}},
|
|
{"id": "ip", "node_class": "ImgProc", "inputs": {},
|
|
"wires": {"Latent": "port:LTXVConcatAVLatent.audio_latent"}},
|
|
{"id": "cd", "node_class": "CondLoader", "inputs": {},
|
|
"wires": {"CONDITIONING": "port:FunPackStudio.positive_conditioning"}},
|
|
]}
|
|
graph, report = builder.build(OI, models, PARAMS)
|
|
assert graph["studio"]["inputs"]["positive_conditioning"] == ["slot_cd", 0]
|
|
assert "clip" not in graph["studio"]["inputs"]
|
|
assert report["blocking"] == []
|
|
assert not any(".clip" in m for m in report["ambiguous"])
|
|
|
|
|
|
def test_clip_still_blocks_when_nothing_else_feeds_studio():
|
|
# Without the conditioning wire, an ambiguous CLIP is still a hard stop.
|
|
models = {"full_control": True, "slots": [
|
|
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
|
|
{"id": "c1", "node_class": "ClipLoader", "inputs": {}, "wires": {}},
|
|
{"id": "c2", "node_class": "ClipLoader", "inputs": {}, "wires": {}},
|
|
{"id": "v", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
|
|
{"id": "li", "node_class": "LoadImage", "inputs": {}, "wires": {"IMAGE": "node:ip:image"}},
|
|
{"id": "ip", "node_class": "ImgProc", "inputs": {},
|
|
"wires": {"Latent": "port:LTXVConcatAVLatent.audio_latent"}},
|
|
]}
|
|
_, report = builder.build(OI, models, PARAMS)
|
|
assert any(".clip" in m for m in report["blocking"])
|
|
|
|
|
|
def test_stale_combo_value_coerced_to_live_choice():
|
|
# "old.safetensors" was renamed/removed; the live spec only has "m.safetensors".
|
|
models = {"full_control": True, "slots": [
|
|
{"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "old.safetensors"},
|
|
"wires": {"MODEL": "port:FunPackStudio.model"}},
|
|
]}
|
|
graph, _ = builder.build(OI, models, PARAMS)
|
|
assert graph["slot_u"]["inputs"]["unet_name"] == "m.safetensors"
|
|
|
|
|
|
def test_linked_inputs_drive_multiple_node_values():
|
|
models = {
|
|
"slots": [
|
|
{"id": "a", "node_class": "ImgProc", "inputs": {"length": 50}, "wires": {}},
|
|
{"id": "b", "node_class": "ImgProc", "inputs": {"length": 50}, "wires": {}},
|
|
],
|
|
"links": [
|
|
{"id": "L1", "name": "size", "kind": "int", "value": 121,
|
|
"members": [{"slotId": "a", "input": "length"}, {"slotId": "b", "input": "length"}]},
|
|
],
|
|
}
|
|
graph, _ = builder.build(OI, models, PARAMS)
|
|
assert graph["slot_a"]["inputs"]["length"] == 121
|
|
assert graph["slot_b"]["inputs"]["length"] == 121
|
|
|
|
|
|
def test_editor_driven_link_pulls_from_params():
|
|
models = {
|
|
"slots": [{"id": "a", "node_class": "ImgProc", "inputs": {"length": 50}, "wires": {}}],
|
|
"links": [{"id": "L", "name": "len", "source": "editor", "editor_key": "num_frames_per_scene",
|
|
"members": [{"slotId": "a", "input": "length"}]}],
|
|
}
|
|
graph, _ = builder.build(OI, models, PARAMS) # PARAMS num_frames_per_scene = 121
|
|
assert graph["slot_a"]["inputs"]["length"] == 121
|
|
|
|
|
|
def test_media_injects_loadimage_wired_to_target():
|
|
models = {"slots": [{"id": "ip", "node_class": "ImgProc", "inputs": {}, "wires": {}}]}
|
|
media = {"filename": "funpack_movie_x.png", "target": "node:ip:image"}
|
|
graph, report = builder.build(OI, models, PARAMS, media=media)
|
|
assert graph["media_load"]["class_type"] == "LoadImage"
|
|
assert graph["media_load"]["inputs"]["image"] == "funpack_movie_x.png"
|
|
assert graph["slot_ip"]["inputs"]["image"] == ["media_load", 0]
|
|
|
|
|
|
def test_optional_open_ports_do_not_block():
|
|
# loaders + vae + audio handled, no image source: source_image/latent optional -> ok
|
|
models = {"full_control": True, "slots": [
|
|
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
|
|
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {"CLIP": "port:FunPackStudio.clip"}},
|
|
{"id": "v", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
|
|
{"id": "ip", "node_class": "ImgProc", "inputs": {},
|
|
"wires": {"latent": "port:FunPackStudio.latent", "Latent": "port:LTXVConcatAVLatent.audio_latent"}},
|
|
{"id": "li", "node_class": "LoadImage", "inputs": {}, "wires": {"IMAGE": "node:ip:image"}},
|
|
]}
|
|
graph, report = builder.build(OI, models, PARAMS)
|
|
assert report["blocking"] == [] # source_image/latent optional, everything required satisfied
|
|
|
|
|
|
def test_ambiguous_type_is_reported_not_guessed():
|
|
models = {"slots": [
|
|
{"id": "v1", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
|
|
{"id": "v2", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
|
|
]}
|
|
graph, report = builder.build(OI, models, PARAMS)
|
|
assert not isinstance(graph["sampler"]["inputs"].get("vae"), list) # left unwired
|
|
assert any("FunPackLTXAVSceneChainSampler" in a and ".vae" in a for a in report["ambiguous"])
|
|
|
|
|
|
def test_multitype_widget_input_does_not_block_and_keeps_its_value():
|
|
# LTXVEmptyLatentAudio.frame_rate arrives as a V3 MultiType widget ("FLOAT,INT" with a
|
|
# widgetType hint). It is a field, not a socket: it must not demand a source (nothing
|
|
# outputs "FLOAT,INT", so the run was blocked) and its value must reach the graph.
|
|
oi = dict(OI)
|
|
oi["LTXVEmptyLatentAudio"] = {
|
|
"input": {"required": {
|
|
"frames_number": ["INT", {"default": 97}],
|
|
"frame_rate": ["FLOAT,INT", {"widgetType": "INT", "default": 25}],
|
|
"audio_vae": ["VAE"],
|
|
}},
|
|
"output": ["LATENT"], "output_name": ["Latent"]}
|
|
models = {"full_control": True, "slots": [
|
|
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
|
|
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {"CLIP": "port:FunPackStudio.clip"}},
|
|
{"id": "v", "node_class": "VaeLoader", "inputs": {},
|
|
"wires": {"VAE": "port:FunPackLTXAVSceneChainSampler.vae"}},
|
|
{"id": "av", "node_class": "VaeLoader", "inputs": {},
|
|
"wires": {"VAE": ["node:ae:audio_vae", "port:LTXVAudioVAEDecode.audio_vae"]}},
|
|
{"id": "ae", "node_class": "LTXVEmptyLatentAudio", "inputs": {"frame_rate": 30},
|
|
"wires": {"Latent": "port:LTXVConcatAVLatent.audio_latent"}},
|
|
]}
|
|
graph, report = builder.build(oi, models, PARAMS)
|
|
assert report["blocking"] == []
|
|
assert not any("frame_rate" in m for m in report["unsatisfied"] + report["ambiguous"])
|
|
assert graph["slot_ae"]["inputs"]["frame_rate"] == 30 # user value, not a link
|
|
assert graph["slot_ae"]["inputs"]["frames_number"] == 97 # default still emitted
|
|
assert graph["slot_ae"]["inputs"]["audio_vae"] == ["slot_av", 0]
|
|
|
|
|
|
def _loaders():
|
|
return [
|
|
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
|
|
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {"CLIP": "port:FunPackStudio.clip"}},
|
|
{"id": "v", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
|
|
]
|
|
|
|
|
|
def test_upscale_before_export_wires_cleanly_and_loader_autofeeds():
|
|
# The user's scenario: tap decoded frames -> ImageUpscaleWithModel -> replace export frames,
|
|
# with an UpscaleModelLoader that has no explicit output wire (it should still auto-feed).
|
|
models = {"full_control": True, "slots": _loaders() + [
|
|
{"id": "um", "node_class": "UpscaleModelLoader", "inputs": {}, "wires": {}},
|
|
{"id": "up", "node_class": "ImageUpscaleWithModel", "inputs": {},
|
|
"input_sources": {"image": "core:sampler:1"}, # decoded video frames
|
|
"wires": {"IMAGE": "port:VHS_VideoCombine.images"}}, # replace the export frames
|
|
]}
|
|
graph, report = builder.build(OI, models, PARAMS)
|
|
assert graph["slot_up"]["inputs"]["image"] == ["sampler", 1] # decoded frames in
|
|
assert graph["vhs"]["inputs"]["images"] == ["slot_up", 0] # upscaled frames out to export
|
|
assert graph["slot_up"]["inputs"]["upscale_model"] == ["slot_um", 0] # loader auto-feeds
|
|
assert builder._find_cycle(graph) is None
|
|
assert not any("dependency cycle" in m.lower() for m in report["blocking"]) # no cycle block
|
|
|
|
|
|
def test_cycle_from_default_source_image_wire_is_dropped():
|
|
# An image_processing node consumes the decoded frames AND carries the role-default wire back
|
|
# to Studio · source_image — that loops. The default wire must be dropped, the user's source kept.
|
|
models = {"full_control": True, "slots": _loaders() + [
|
|
{"id": "ip", "node_class": "ImgProc", "role": "image_processing", "inputs": {},
|
|
"input_sources": {"image": "core:sampler:1", "vae": "out:v:VAE"},
|
|
"wires": {"output_image": "port:FunPackStudio.source_image"}}, # == role default → droppable
|
|
]}
|
|
graph, report = builder.build(OI, models, PARAMS)
|
|
assert builder._find_cycle(graph) is None # no cycle handed to ComfyUI
|
|
assert graph["slot_ip"]["inputs"]["image"] == ["sampler", 1] # user's source preserved
|
|
src_img = graph["studio"]["inputs"].get("source_image")
|
|
assert not (isinstance(src_img, list) and src_img[0] == "slot_ip") # looping default dropped
|
|
assert any("dependency cycle" in m.lower() for m in report["auto_wired"])
|
|
|
|
|
|
def test_all_explicit_cycle_is_reported_not_silently_built():
|
|
# Two nodes feeding each other through explicit input sources — nothing droppable, so it must
|
|
# surface as a clear blocking error rather than a graph ComfyUI rejects opaquely.
|
|
models = {"full_control": True, "slots": _loaders() + [
|
|
{"id": "a", "node_class": "ImgProc", "inputs": {},
|
|
"input_sources": {"image": "out:b:output_image", "vae": "out:v:VAE"}, "wires": {}},
|
|
{"id": "b", "node_class": "ImgProc", "inputs": {},
|
|
"input_sources": {"image": "out:a:output_image", "vae": "out:v:VAE"}, "wires": {}},
|
|
]}
|
|
graph, report = builder.build(OI, models, PARAMS)
|
|
assert any("dependency cycle" in m.lower() for m in report["blocking"])
|
|
|
|
|
|
def test_extract_widgets_handles_control_and_links():
|
|
nd = OI["FunPackLTXAVSceneChainSampler"]
|
|
# seed(+control), num_frames, then the rest are not declared widgets here, so map stops
|
|
vals = [999, "randomize", 97]
|
|
got = builder.extract_widgets(nd, vals)
|
|
assert got["seed"] == 999 # control_after_generate ("randomize") skipped
|
|
assert got["num_frames_per_scene"] == 97
|
|
|
|
# forceInput STRINGs are sockets, never widgets
|
|
studio = builder.extract_widgets(OI["FunPackStudio"], ["a", "{\"x\":1}", "[]", "ignored"])
|
|
assert studio == {"rating": "a", "studio_settings": "{\"x\":1}", "adjustments": "[]"}
|
|
|
|
# dict widget store (VHS) drops non-input keys
|
|
d = builder.extract_widgets(OI["VHS_VideoCombine"], {"frame_rate": 30, "videopreview": {"x": 1}})
|
|
assert d == {"frame_rate": 30}
|
|
|
|
|
|
def test_movie_editor_scene_ratings_in_studio_settings():
|
|
import json
|
|
params = {
|
|
**PARAMS,
|
|
"studio_inputs": {
|
|
"rating": "__funpack_continue__",
|
|
"_movie_editor_scene_ratings": [
|
|
{"index": 0, "rating": "Perfect"},
|
|
{"index": 2, "rating": "Missing action"},
|
|
],
|
|
},
|
|
}
|
|
graph, _report = builder.build(OI, {"slots": []}, params)
|
|
settings = json.loads(graph["studio"]["inputs"]["studio_settings"])
|
|
assert settings["refiner"]["split_by_transitions"] is True
|
|
assert settings["refiner"]["movie_editor_scene_ratings"] == [
|
|
{"index": 0, "rating": "Perfect"},
|
|
{"index": 2, "rating": "Missing action"},
|
|
]
|
|
assert "_movie_editor_scene_ratings" not in graph["studio"]["inputs"]
|
|
|
|
|
|
def test_refinement_key_injected_into_keyloader():
|
|
graph, _ = builder.build(OI, {"slots": []}, {**PARAMS, "refinement_key": "charA"})
|
|
assert graph["keyloader"]["inputs"]["key_name"] == "charA"
|
|
# combo forced to none so the typed key_name wins over any reference-seeded value
|
|
assert graph["keyloader"]["inputs"]["refinement_key"] == "-None-"
|
|
|
|
|
|
def test_refinement_key_defaults_when_absent():
|
|
graph, _ = builder.build(OI, {"slots": []}, PARAMS)
|
|
assert graph["keyloader"]["inputs"]["key_name"] == "default"
|
|
|
|
|
|
def test_bypassed_lora_drops_node_and_passes_model_clip_through():
|
|
models = {"full_control": True, "slots": [
|
|
{"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "m.safetensors"}, "wires": {}},
|
|
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {}},
|
|
{"id": "lora", "node_class": "LoraLoader", "bypassed": True,
|
|
"inputs": {"lora_name": "x.safetensors", "strength_model": 0.8, "strength_clip": 0.8,
|
|
"model": ["slot_u", 0], "clip": ["slot_c", 0]},
|
|
"wires": {"MODEL": "port:FunPackStudio.model", "CLIP": "port:FunPackStudio.clip"}},
|
|
]}
|
|
graph, report = builder.build(OI, models, PARAMS)
|
|
# the LoRA node itself is gone
|
|
assert "slot_lora" not in graph
|
|
# Studio's model/clip wire straight to the original loaders, skipping the LoRA
|
|
assert graph["studio"]["inputs"]["model"] == ["slot_u", 0]
|
|
assert graph["studio"]["inputs"]["clip"] == ["slot_c", 0]
|
|
assert any("LoraLoader bypassed" in w for w in report["wired"])
|
|
|
|
|
|
def test_non_bypassed_lora_stays_in_graph():
|
|
models = {"full_control": True, "slots": [
|
|
{"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "m.safetensors"}, "wires": {}},
|
|
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {}},
|
|
{"id": "lora", "node_class": "LoraLoader",
|
|
"inputs": {"lora_name": "x.safetensors", "model": ["slot_u", 0], "clip": ["slot_c", 0]},
|
|
"wires": {"MODEL": "port:FunPackStudio.model", "CLIP": "port:FunPackStudio.clip"}},
|
|
]}
|
|
graph, _ = builder.build(OI, models, PARAMS)
|
|
assert "slot_lora" in graph
|
|
assert graph["studio"]["inputs"]["model"] == ["slot_lora", 0]
|
|
assert graph["studio"]["inputs"]["clip"] == ["slot_lora", 1]
|
|
|
|
|
|
def test_bypass_ignores_outputs_nothing_is_wired_to():
|
|
"""LTXICLoRALoaderModelOnly emits a FLOAT (latent_downscale_factor) next to its MODEL,
|
|
and nothing in the editor's graph reads it. Demanding a matching input for an output
|
|
that feeds nothing refused a bypass that is unambiguous for every link that exists —
|
|
only CONSUMED outputs need a passthrough."""
|
|
models = {"full_control": True, "slots": [
|
|
{"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "m.safetensors"}, "wires": {}},
|
|
{"id": "ic", "node_class": "LTXICLoRALoaderModelOnly", "bypassed": True,
|
|
"inputs": {"lora_name": "x.safetensors", "strength_model": 0.8, "model": ["slot_u", 0]},
|
|
"wires": {"MODEL": "port:FunPackStudio.model"}},
|
|
]}
|
|
graph, report = builder.build(OI, models, PARAMS)
|
|
assert "slot_ic" not in graph
|
|
assert graph["studio"]["inputs"]["model"] == ["slot_u", 0]
|
|
assert not any("bypass" in b for b in report["blocking"])
|
|
assert any("LTXICLoRALoaderModelOnly bypassed" in w for w in report["wired"])
|
|
|
|
|
|
def test_bypass_on_node_with_no_matching_input_blocks_generation():
|
|
# VaeLoader has no connection_input at all, so there's nothing to pass an output through.
|
|
# A bypass a user explicitly asked for must never be silently ignored — generation blocks
|
|
# with a clear reason instead of quietly leaving the node active.
|
|
models = {"full_control": True, "slots": [
|
|
{"id": "v", "node_class": "VaeLoader", "bypassed": True, "inputs": {}, "wires": {}},
|
|
]}
|
|
graph, report = builder.build(OI, models, PARAMS)
|
|
assert "slot_v" in graph
|
|
assert any("bypass needs exactly one input" in u for u in report["unsatisfied"])
|
|
assert any("bypass needs exactly one input" in b for b in report["blocking"])
|
|
|
|
|
|
# ── MiniMax H3 family ─────────────────────────────────────────────────────────
|
|
# H3 keeps the core's SHAPE but three of its nodes do not apply. The graph has to be
|
|
# built for the right family up front: a wrong node class here fails deep inside
|
|
# ComfyUI at generation time, where the Models panel can no longer explain it.
|
|
|
|
H3_OI = dict(OI)
|
|
H3_OI["VAEDecodeAudio"] = {"input": {"required": {"samples": ["LATENT"], "vae": ["VAE"]}},
|
|
"output": ["AUDIO"]}
|
|
H3_OI["EmptyMiniMaxH3LatentAV"] = {
|
|
"input": {"required": {"width": ["INT", {"default": 1344}], "height": ["INT", {"default": 768}],
|
|
"length": ["INT", {"default": 124}]}},
|
|
"output": ["LATENT"], "output_name": ["LATENT"]}
|
|
|
|
H3_MODELS = {
|
|
"model_family": "minimax_h3",
|
|
"slots": [
|
|
{"id": "u", "role": "unet", "node_class": "UnetLoader", "label": "unet"},
|
|
{"id": "c", "role": "clip", "node_class": "ClipLoader", "label": "clip"},
|
|
{"id": "vv", "role": "video_vae", "node_class": "VaeLoader", "label": "video vae",
|
|
"wires": {"VAE": "port:FunPackLTXAVSceneChainSampler.vae"}},
|
|
{"id": "av", "role": "audio_vae", "node_class": "VaeLoader", "label": "audio vae",
|
|
"wires": {"VAE": "port:VAEDecodeAudio.vae"}},
|
|
{"id": "lat", "role": "empty_latent", "node_class": "EmptyMiniMaxH3LatentAV", "label": "av latent",
|
|
"wires": {"LATENT": "port:FunPackLTXAVSceneChainSampler.latent_template"}},
|
|
],
|
|
}
|
|
|
|
|
|
def _classes(graph):
|
|
return {n["class_type"] for n in graph.values()}
|
|
|
|
|
|
def test_h3_family_drops_the_ltx_only_core_nodes():
|
|
graph, report = builder.build(H3_OI, H3_MODELS, {"prompt": "a shot"})
|
|
classes = _classes(graph)
|
|
# LTXVConditioning stamps LTX's frame_rate onto conditioning — H3 has no such field
|
|
assert "LTXVConditioning" not in classes
|
|
# H3's own empty-latent node emits both streams, so there is nothing to concat
|
|
assert "LTXVConcatAVLatent" not in classes
|
|
# LTXVAudioVAEDecode reads an output_sample_rate H3's audio VAE does not define
|
|
assert "LTXVAudioVAEDecode" not in classes
|
|
assert "VAEDecodeAudio" in classes
|
|
# LTXFloatToInt only converted the project's frame rate for user slots. H3's rate is
|
|
# fixed at 24 by the model, so keeping it made an LTX pack a hard requirement of a
|
|
# pipeline that has no use for it.
|
|
assert "LTXFloatToInt" not in classes
|
|
# the shared half is untouched
|
|
assert {"FunPackStudio", "FunPackLTXAVSceneChainSampler", "LTXVSeparateAVLatent"} <= classes
|
|
assert not report["blocking"], report["blocking"]
|
|
|
|
|
|
def test_h3_does_not_offer_the_dropped_frame_rate_converter_as_a_producer():
|
|
"""A dropped core node must not be auto-wired into a slot that wants an INT."""
|
|
models = dict(H3_MODELS)
|
|
models["slots"] = H3_MODELS["slots"] + [
|
|
{"id": "ip", "role": "image_processing", "node_class": "IntEater", "label": "eats an int"},
|
|
]
|
|
oi = dict(H3_OI)
|
|
oi["IntEater"] = {"input": {"required": {"n": ["INT", {"default": 0}]}}, "output": ["IMAGE"]}
|
|
graph, _report = builder.build(oi, models, {"prompt": "a shot"})
|
|
for node in graph.values():
|
|
for value in node["inputs"].values():
|
|
assert not (isinstance(value, list) and value and value[0] == "f2i"), value
|
|
|
|
|
|
def test_h3_wires_studio_conditioning_straight_to_the_sampler():
|
|
graph, _ = builder.build(H3_OI, H3_MODELS, {"prompt": "a shot"})
|
|
sampler = graph["sampler"]["inputs"]
|
|
assert sampler["positive"] == ["studio", 1]
|
|
assert sampler["negative"] == ["studio", 2]
|
|
# audio decode source is asserted in its own test (it reads the sampler latent on H3)
|
|
|
|
|
|
def test_h3_latent_slot_feeds_the_sampler_directly():
|
|
graph, report = builder.build(H3_OI, H3_MODELS, {"prompt": "a shot"})
|
|
assert graph["sampler"]["inputs"]["latent_template"] == ["slot_lat", 0]
|
|
assert graph["audiodec"]["inputs"]["vae"] == ["slot_av", 0]
|
|
assert not report["blocking"], report["blocking"]
|
|
|
|
|
|
def test_h3_without_an_av_latent_slot_blocks_instead_of_generating_a_broken_graph():
|
|
models = {"model_family": "minimax_h3",
|
|
"slots": [s for s in H3_MODELS["slots"] if s["id"] != "lat"]}
|
|
_graph, report = builder.build(H3_OI, models, {"prompt": "a shot"})
|
|
assert any("latent_template" in m for m in report["blocking"]), report
|
|
|
|
|
|
# A core override is saved per core ID, not per node class, so switching family leaves the
|
|
# replaced node's input names behind on its successor. The panel lists the NEW node's inputs,
|
|
# so the leftover is invisible; emitting it hands ComfyUI a kwarg the node never declared and
|
|
# the run dies inside it ("VAEDecodeAudio.execute() got an unexpected keyword argument
|
|
# 'audio_vae'"). Guided mode filtered these out as a side effect; full control did not.
|
|
|
|
def test_a_core_override_from_another_family_is_not_emitted():
|
|
models = dict(H3_MODELS)
|
|
models["full_control"] = True # guided mode filtered this one out by accident
|
|
models["core_overrides"] = {"audiodec": {"audio_vae": "out:av:VAE"}}
|
|
graph, report = builder.build(H3_OI, models, {"prompt": "a shot"})
|
|
|
|
assert "audio_vae" not in graph["audiodec"]["inputs"]
|
|
assert graph["audiodec"]["inputs"]["vae"] == ["slot_av", 0] # the real wire is untouched
|
|
assert any("has no input 'audio_vae'" in u for u in report["unsatisfied"]), report
|
|
assert not report["blocking"], report["blocking"] # a leftover is not a reason to refuse
|
|
|
|
|
|
def test_a_valid_core_override_still_applies_under_full_control():
|
|
models = dict(H3_MODELS)
|
|
models["full_control"] = True
|
|
models["core_overrides"] = {"audiodec": {"vae": "out:vv:VAE"}}
|
|
graph, report = builder.build(H3_OI, models, {"prompt": "a shot"})
|
|
|
|
assert graph["audiodec"]["inputs"]["vae"] == ["slot_vv", 0]
|
|
assert not report["blocking"], report["blocking"]
|
|
|
|
|
|
def test_the_ltxav_graph_is_unchanged_by_the_family_split():
|
|
"""The default family must emit exactly what it emitted before H3 existed."""
|
|
ltx_models = {"slots": [
|
|
{"id": "u", "role": "unet", "node_class": "UnetLoader",
|
|
"wires": {"MODEL": "port:FunPackStudio.model"}},
|
|
{"id": "c", "role": "clip", "node_class": "ClipLoader",
|
|
"wires": {"CLIP": "port:FunPackStudio.clip"}},
|
|
{"id": "vv", "role": "video_vae", "node_class": "VaeLoader",
|
|
"wires": {"VAE": "port:FunPackLTXAVSceneChainSampler.vae"}},
|
|
{"id": "av", "role": "audio_vae", "node_class": "VaeLoader",
|
|
"wires": {"VAE": "port:LTXVAudioVAEDecode.audio_vae"}},
|
|
{"id": "al", "role": "audio_encoder", "node_class": "AudioEnc",
|
|
"wires": {"LATENT": "port:LTXVConcatAVLatent.audio_latent"}},
|
|
]}
|
|
oi = dict(OI)
|
|
oi["AudioEnc"] = {"input": {"required": {"seconds": ["FLOAT", {"default": 5.0}]}},
|
|
"output": ["LATENT"], "output_name": ["LATENT"]}
|
|
graph, report = builder.build(oi, ltx_models, {"prompt": "a shot"})
|
|
classes = _classes(graph)
|
|
assert {"LTXVConditioning", "LTXVConcatAVLatent", "LTXVAudioVAEDecode"} <= classes
|
|
assert "VAEDecodeAudio" not in classes
|
|
assert graph["sampler"]["inputs"]["positive"] == ["cond", 0]
|
|
assert graph["sampler"]["inputs"]["latent_template"] == ["concat", 0]
|
|
assert not report["blocking"], report["blocking"]
|
|
|
|
|
|
# ── frame geometry is a property of the model, not a setting ──────────────────
|
|
# LTX: 8k+1 frames at the project's fps. H3: 17k+5 frames at a fixed 24 fps. An off-grid
|
|
# length on H3 is not a rounding nuisance — the latent node snaps its own length up while
|
|
# the sampler is told the raw number, and the run dies on the mismatch.
|
|
|
|
def test_h3_frame_counts_snap_to_the_17k_plus_5_grid():
|
|
graph, _ = builder.build(H3_OI, H3_MODELS, {"prompt": "a shot", "num_frames_per_scene": 121})
|
|
assert graph["frames"]["inputs"]["value"] == 124
|
|
# a value already on the grid is left exactly as it is
|
|
graph, _ = builder.build(H3_OI, H3_MODELS, {"prompt": "a shot", "num_frames_per_scene": 124})
|
|
assert graph["frames"]["inputs"]["value"] == 124
|
|
|
|
|
|
def test_h3_renders_at_the_models_own_frame_rate():
|
|
graph, _ = builder.build(H3_OI, H3_MODELS, {"prompt": "a shot", "frame_rate": 30})
|
|
assert graph["fps"]["inputs"]["value"] == 24
|
|
|
|
|
|
def test_ltx_frame_geometry_is_untouched():
|
|
graph, _ = builder.build(OI, {"slots": []},
|
|
{"prompt": "a shot", "num_frames_per_scene": 121, "frame_rate": 30})
|
|
assert graph["frames"]["inputs"]["value"] == 121 # already 8k+1
|
|
assert graph["fps"]["inputs"]["value"] == 30 # LTX has no fixed rate
|
|
graph, _ = builder.build(OI, {"slots": []}, {"prompt": "a shot", "num_frames_per_scene": 100})
|
|
assert graph["frames"]["inputs"]["value"] == 105 # snapped up to 8k+1
|
|
|
|
|
|
def test_a_latent_node_driven_by_project_frames_gets_the_same_snapped_number():
|
|
"""A slot widget bound to "Project · Frames" must not receive the raw value while the
|
|
sampler receives the snapped one — that disagreement is the mismatch itself."""
|
|
models = dict(H3_MODELS)
|
|
models["links"] = [{"id": "l1", "source": "editor", "editor_key": "num_frames_per_scene",
|
|
"members": [{"slotId": "lat", "input": "length"}]}]
|
|
graph, _ = builder.build(H3_OI, models, {"prompt": "a shot", "num_frames_per_scene": 121})
|
|
assert graph["slot_lat"]["inputs"]["length"] == 124
|
|
assert graph["frames"]["inputs"]["value"] == 124
|
|
|
|
|
|
def test_an_unknown_family_falls_back_to_ltxav_rather_than_emitting_nothing():
|
|
assert builder.family_of({"model_family": "hailuo-9000"}) == "ltxav"
|
|
assert builder.family_of({}) == "ltxav"
|
|
assert builder.family_of(None) == "ltxav"
|
|
assert builder.family_of({"model_family": "MiniMax_H3"}) == "minimax_h3"
|
|
|
|
|
|
# ── autogrow list inputs (MiniMax H3 reference nodes) ─────────────────────────
|
|
|
|
REF_OI = dict(OI)
|
|
REF_OI["MiniMaxH3ReferenceToVideo"] = {
|
|
"input": {"required": {
|
|
"clip": ["CLIP"],
|
|
"prompt": ["STRING", {"default": ""}],
|
|
"ref_images": ["COMFY_AUTOGROW_V3", {
|
|
"template": {"input": {"optional": {"ref_image": ["IMAGE", {}]}},
|
|
"prefix": "ref_image", "min": 0, "max": 4}}],
|
|
}},
|
|
"output": ["CONDITIONING"], "output_name": ["conditioning"]}
|
|
|
|
|
|
def test_autogrow_entries_are_wired_by_their_expanded_names():
|
|
"""The API graph carries ref_image0/ref_image1, never the 'ref_images' wrapper — that
|
|
is the shape ComfyUI expands back into the node's list."""
|
|
models = {"slots": [
|
|
{"id": "li", "node_class": "LoadImage", "inputs": {}, "wires": {}},
|
|
{"id": "r", "node_class": "MiniMaxH3ReferenceToVideo", "inputs": {"prompt": "hi"},
|
|
"wires": {},
|
|
"input_sources": {"ref_images.ref_image0": "out:li:IMAGE", "ref_images.ref_image1": "timeline"}},
|
|
]}
|
|
graph, report = builder.build(REF_OI, models, PARAMS, media={"filename": "scene.png"})
|
|
ins = graph["slot_r"]["inputs"]
|
|
assert ins["ref_images.ref_image0"] == ["slot_li", 0]
|
|
assert ins["ref_images.ref_image1"] == ["media_load", 0]
|
|
assert "ref_images" not in ins # the template name itself is never sent
|
|
# an unwired entry is simply absent — never blocking, never an empty placeholder
|
|
assert "ref_images.ref_image2" not in ins and "ref_images.ref_image3" not in ins
|
|
assert not any("ref_image" in m for m in report["blocking"])
|
|
|
|
|
|
def test_a_single_image_producer_is_not_copied_into_every_autogrow_slot():
|
|
"""Auto-wire fills a lone unambiguous input; for a LIST input that would duplicate one
|
|
reference across all ten indices. Autogrow entries are explicit-only."""
|
|
models = {"slots": [
|
|
{"id": "li", "node_class": "LoadImage", "inputs": {}, "wires": {}},
|
|
{"id": "r", "node_class": "MiniMaxH3ReferenceToVideo", "inputs": {}, "wires": {}},
|
|
]}
|
|
graph, report = builder.build(REF_OI, models, PARAMS)
|
|
assert not [k for k in graph["slot_r"]["inputs"] if k.startswith("ref_image")]
|
|
assert not any("ref_image" in m for m in report["unsatisfied"] + report["ambiguous"])
|
|
|
|
|
|
def test_a_node_widget_can_be_driven_by_the_project_prompt():
|
|
"""A custom node with its own prompt field takes the run's prompt through a linked
|
|
input, instead of the user keeping two copies of the text in sync by hand."""
|
|
models = {"slots": [{"id": "r", "node_class": "MiniMaxH3ReferenceToVideo",
|
|
"inputs": {"prompt": "stale text"}, "wires": {}}],
|
|
"links": [{"id": "l1", "source": "editor", "editor_key": "prompt",
|
|
"members": [{"slotId": "r", "input": "prompt"}]}]}
|
|
graph, _ = builder.build(REF_OI, models, PARAMS)
|
|
assert graph["slot_r"]["inputs"]["prompt"] == "scene one"
|
|
|
|
|
|
def test_a_linked_text_takes_the_expanded_prompt_not_the_raw_one():
|
|
"""The node on the other end encodes the string as it arrives — it does none of what
|
|
Studio does to a prompt. So a linked text gets the shortcut/$variable-expanded version,
|
|
while Studio's own port keeps the raw text it expands per scene itself."""
|
|
models = {"slots": [{"id": "r", "node_class": "MiniMaxH3ReferenceToVideo",
|
|
"inputs": {}, "wires": {}}],
|
|
"links": [{"id": "l1", "source": "editor", "editor_key": "prompt",
|
|
"members": [{"slotId": "r", "input": "prompt"}]}]}
|
|
params = dict(PARAMS, prompt="/greet $hero", expanded={"prompt": "hello Rin"})
|
|
graph, _ = builder.build(REF_OI, models, params)
|
|
assert graph["slot_r"]["inputs"]["prompt"] == "hello Rin"
|
|
assert graph["pos"]["inputs"]["value"] == "/greet $hero" # Studio still expands its own
|
|
|
|
|
|
def test_anchor_and_postfix_are_linkable_texts_of_their_own():
|
|
"""A node encoding on its own has no idea Studio would wrap each scene in these."""
|
|
models = {"slots": [{"id": "r", "node_class": "MiniMaxH3ReferenceToVideo",
|
|
"inputs": {}, "wires": {}}],
|
|
"links": [{"id": "l1", "source": "editor", "editor_key": "postfix",
|
|
"members": [{"slotId": "r", "input": "prompt"}]}]}
|
|
params = dict(PARAMS, expanded={"postfix": "cinematic lighting"})
|
|
graph, _ = builder.build(REF_OI, models, params)
|
|
assert graph["slot_r"]["inputs"]["prompt"] == "cinematic lighting"
|
|
|
|
|
|
def test_a_core_combo_value_missing_on_this_machine_falls_back_instead_of_blocking():
|
|
"""ComfyUI validates combo values against the LIVE list and rejects the whole prompt if
|
|
one is stale — so a projector/LoRA left selected on a machine that doesn't have the file
|
|
stopped every run, even with the feature switched off."""
|
|
oi = dict(OI)
|
|
sampler = {k: dict(v) for k, v in oi["FunPackLTXAVSceneChainSampler"].items()
|
|
if k in ("input",)}
|
|
sampler["input"] = {"required": dict(oi["FunPackLTXAVSceneChainSampler"]["input"]["required"])}
|
|
sampler["input"]["required"]["identity_projector"] = [["None"], {"default": "None"}]
|
|
oi["FunPackLTXAVSceneChainSampler"] = {**oi["FunPackLTXAVSceneChainSampler"], **sampler}
|
|
params = dict(PARAMS)
|
|
params["sampler_inputs"] = {"identity_projector": "Best_FaceID_v1.0_ArcFace_Projector.safetensors"}
|
|
graph, report = builder.build(oi, {"slots": []}, params)
|
|
assert graph["sampler"]["inputs"]["identity_projector"] == "None"
|
|
assert any("identity_projector" in m and "not installed" in m for m in report["unsatisfied"])
|
|
|
|
|
|
def test_an_autogrow_entry_saved_under_its_bare_name_still_wires():
|
|
"""Configs written before the dotted socket id was known hold "ref_image0". Sending that
|
|
is what produced "execute() got an unexpected keyword argument" — map it back instead of
|
|
letting an old config keep failing every run."""
|
|
models = {"slots": [
|
|
{"id": "li", "node_class": "LoadImage", "inputs": {}, "wires": {"IMAGE": "node:r:ref_image1"}},
|
|
{"id": "r", "node_class": "MiniMaxH3ReferenceToVideo", "inputs": {}, "wires": {},
|
|
"input_sources": {"ref_image0": "out:li:IMAGE"}},
|
|
]}
|
|
graph, _ = builder.build(REF_OI, models, PARAMS)
|
|
ins = graph["slot_r"]["inputs"]
|
|
assert ins["ref_images.ref_image0"] == ["slot_li", 0] # via input_sources
|
|
assert ins["ref_images.ref_image1"] == ["slot_li", 0] # via the mirrored wire
|
|
assert "ref_image0" not in ins and "ref_image1" not in ins
|
|
|
|
|
|
# ── media marked "R" in the bin, wired to node inputs ─────────────────────────
|
|
|
|
REF_OI["LoadAudio"] = {"input": {"required": {"audio": [["a.wav"]]}}, "output": ["AUDIO"],
|
|
"output_name": ["AUDIO"]}
|
|
REF_OI["LoadVideo"] = {"input": {"required": {"file": [["a.mp4"]]}}, "output": ["VIDEO"],
|
|
"output_name": ["VIDEO"]}
|
|
|
|
|
|
def _ref_params(*refs):
|
|
p = dict(PARAMS)
|
|
p["references"] = [dict(r, index=i + 1) for i, r in enumerate(refs)]
|
|
return p
|
|
|
|
|
|
def test_a_marked_reference_injects_its_own_loader_and_wires_it():
|
|
models = {"slots": [
|
|
{"id": "r", "node_class": "MiniMaxH3ReferenceToVideo", "inputs": {}, "wires": {},
|
|
"input_sources": {"ref_images.ref_image0": "ref:m1"}},
|
|
]}
|
|
params = _ref_params({"id": "m1", "kind": "image", "name": "face.png",
|
|
"filename": "funpack_movie_m1.png"})
|
|
graph, report = builder.build(REF_OI, models, params)
|
|
load = next(n for n, d in graph.items() if d["class_type"] == "LoadImage" and n != "media_load")
|
|
assert graph[load]["inputs"]["image"] == "funpack_movie_m1.png"
|
|
assert graph["slot_r"]["inputs"]["ref_images.ref_image0"] == [load, 0]
|
|
assert not any("reference" in m for m in report["blocking"])
|
|
|
|
|
|
def test_two_sockets_on_one_reference_share_a_single_loader():
|
|
"""Decoding the same clip twice would cost real time on every run — and for a video
|
|
reference, that is an ffmpeg decode."""
|
|
models = {"slots": [
|
|
{"id": "r", "node_class": "MiniMaxH3ReferenceToVideo", "inputs": {}, "wires": {},
|
|
"input_sources": {"ref_images.ref_image0": "ref:m1", "ref_images.ref_image1": "ref:m1"}},
|
|
]}
|
|
params = _ref_params({"id": "m1", "kind": "image", "name": "face.png",
|
|
"filename": "funpack_movie_m1.png"})
|
|
graph, _ = builder.build(REF_OI, models, params)
|
|
loaders = [n for n, d in graph.items() if d["class_type"] == "LoadImage"]
|
|
assert len(loaders) == 1
|
|
ins = graph["slot_r"]["inputs"]
|
|
assert ins["ref_images.ref_image0"] == ins["ref_images.ref_image1"] == [loaders[0], 0]
|
|
|
|
|
|
def test_the_loader_matches_what_the_destination_socket_asks_for():
|
|
"""An audio reference feeding an AUDIO socket needs LoadAudio, not LoadImage — the
|
|
loader is chosen by the socket's type, not by guessing from the file."""
|
|
oi = dict(REF_OI)
|
|
oi["AudioNode"] = {"input": {"required": {"clip": ["AUDIO"]}}, "output": ["CONDITIONING"]}
|
|
models = {"slots": [
|
|
{"id": "a", "node_class": "AudioNode", "inputs": {}, "wires": {},
|
|
"input_sources": {"clip": "ref:m2"}},
|
|
]}
|
|
params = _ref_params({"id": "m2", "kind": "audio", "name": "voice.wav",
|
|
"filename": "funpack_movie_m2.wav"})
|
|
graph, _ = builder.build(oi, models, params)
|
|
load = next(n for n, d in graph.items() if d["class_type"] == "LoadAudio")
|
|
assert graph[load]["inputs"]["audio"] == "funpack_movie_m2.wav"
|
|
assert graph["slot_a"]["inputs"]["clip"] == [load, 0]
|
|
|
|
|
|
def test_a_reference_whose_file_is_gone_reports_instead_of_wiring_nothing():
|
|
models = {"slots": [
|
|
{"id": "r", "node_class": "MiniMaxH3ReferenceToVideo", "inputs": {}, "wires": {},
|
|
"input_sources": {"ref_images.ref_image0": "ref:missing"}},
|
|
]}
|
|
graph, report = builder.build(REF_OI, models, _ref_params())
|
|
assert "ref_images.ref_image0" not in graph["slot_r"]["inputs"]
|
|
assert any("no longer in the media bin" in m for m in report["unsatisfied"])
|
|
|
|
|
|
def test_no_installed_loader_for_the_socket_type_is_reported():
|
|
"""A video reference aimed at an IMAGE socket needs a frames loader; with none
|
|
installed, say so rather than silently leaving the input unwired."""
|
|
oi = {k: v for k, v in REF_OI.items() if k not in ("LoadVideo",)}
|
|
oi["FramesNode"] = {"input": {"required": {"frames": ["IMAGE"]}}, "output": ["CONDITIONING"]}
|
|
models = {"slots": [
|
|
{"id": "f", "node_class": "FramesNode", "inputs": {}, "wires": {},
|
|
"input_sources": {"frames": "ref:m3"}},
|
|
]}
|
|
params = _ref_params({"id": "m3", "kind": "video", "name": "clip.mp4",
|
|
"filename": "funpack_movie_m3.mp4"})
|
|
graph, report = builder.build(oi, models, params)
|
|
assert "frames" not in graph["slot_f"]["inputs"]
|
|
assert any("no installed node can load a video reference" in m for m in report["unsatisfied"])
|
|
|
|
|
|
def test_h3_audio_decodes_from_the_sampler_latent_not_a_separated_stream():
|
|
"""ComfyUI's official H3 templates feed the raw sampler latent to BOTH decodes and let
|
|
each VAE take its own stream. Unbinding first hands the audio VAE a different object
|
|
than the reference graph does. LTX is unchanged — it keeps the separate step."""
|
|
graph, _ = builder.build(H3_OI, H3_MODELS, {"prompt": "a shot"})
|
|
assert graph["audiodec"]["inputs"]["samples"] == ["sampler", 0]
|
|
# `separate` stays: it still supplies the VIDEO latent to the refinement save.
|
|
assert graph["saveref"]["inputs"]["latent"] == ["separate", 0]
|
|
|
|
ltx, _ = builder.build(OI, {"slots": []}, PARAMS)
|
|
assert ltx["audiodec"]["inputs"]["samples"] == ["separate", 1]
|