From e1ae4acea9949053cf20f74dba0e8d1c3ee42cd3 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:56:32 +0300 Subject: [PATCH] fix: wire H3 reference lists, bind node prompts to the project prompt, survive a missing projector file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things blocked the MiniMax-H3 reference node in Models & Pipeline: Autogrow list inputs (COMFY_AUTOGROW_V3) were offered as a single unwireable socket typed after the list wrapper, so nothing — not the timeline image, not another node's IMAGE output — matched them. They are a TEMPLATE, not a socket: ComfyUI expands them into ref_image0, ref_image1, … and rebuilds the dict from whichever indices the API graph carries. connection_inputs() now expands them the same way, typed as the element type, and the editor shows the filled entries plus one empty slot so a 50-entry list doesn't bury the node. Auto-wire skips them — a lone IMAGE producer would otherwise be copied into every index. A node's own prompt field can now be driven by the project prompt: the linked- input 'Driven by' list gained Prompt / Negative prompt / Seed, filtered to the widget kinds each can legitimately fill. A combo value whose file isn't on this machine (a saved ArcFace projector with models/loras empty) made ComfyUI reject the WHOLE prompt, so nothing queued even with Best-FaceID switched off. Editor-supplied Studio/Chain-Sampler values are now checked against the live list and fall back to the node's default with a visible note, the same rule slot nodes already had. Co-Authored-By: Claude Opus 5 --- movie_editor/backend/builder.py | 27 ++++++++++- movie_editor/backend/nodes.py | 68 ++++++++++++++++++++++++++++ movie_editor/frontend/inspector.js | 4 +- movie_editor/frontend/models.js | 70 ++++++++++++++++++++++++----- movie_editor/tests/test_builder.py | 72 ++++++++++++++++++++++++++++++ movie_editor/tests/test_nodes.py | 57 +++++++++++++++++++++++ 6 files changed, 285 insertions(+), 13 deletions(-) diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index 8cf55547..af0f7a7a 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -418,6 +418,24 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No graph["keyloader"]["inputs"]["refinement_key"] = "-None-" # 2b. project-level widget overrides for the built-in FunPack nodes. + # A saved combo selection is checked against the LIVE list ComfyUI validates with: + # a projector / LoRA / model file that isn't on THIS machine makes ComfyUI reject + # the whole prompt ("Value not in list"), so nothing queues at all — even when the + # feature reading it is switched off. Fall back to the node's default and say so, + # rather than stalling the run on a setting that isn't in play. + def _live_value(cid: str, name: str, value): + nd_c = object_info.get((graph.get(cid) or {}).get("class_type")) + choices = _widget_choices(nd_c).get(name) + if not choices or isinstance(value, list) or value in choices: + return value + fallback = _widget_defaults(nd_c).get(name) + if fallback not in choices: + fallback = choices[0] + report["unsatisfied"].append( + f"{graph[cid]['class_type']}.{name}: '{value}' is not installed on this " + f"machine — using '{fallback}' instead.") + return fallback + _me_scene_ratings = None for k, v in (params.get("studio_inputs") or {}).items(): if str(k).startswith("_"): @@ -425,7 +443,7 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No _me_scene_ratings = v continue if k not in graph["studio"]["inputs"] or not isinstance(graph["studio"]["inputs"][k], list): - graph["studio"]["inputs"][k] = v + graph["studio"]["inputs"][k] = _live_value("studio", k, v) # split_by_transitions is NOT a top-level Studio input — Studio reads it from # studio_settings.refiner.split_by_transitions (default False = single-scene mode). @@ -465,7 +483,7 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No graph["studio"]["inputs"]["studio_settings"] = json.dumps(_ss) for k, v in (params.get("sampler_inputs") or {}).items(): if k not in graph["sampler"]["inputs"] or not isinstance(graph["sampler"]["inputs"][k], list): - graph["sampler"]["inputs"][k] = v + graph["sampler"]["inputs"][k] = _live_value("sampler", k, v) # The global editor outputs: a VHS_VideoCombine synthesized on demand when a slot output # is wired to global:video / global:audio. Lazily created so it only exists when used. @@ -982,6 +1000,11 @@ def _autowire(graph, slots, slot_node_id, slot_def, object_info, producers, repo continue # inert slot (feeds nothing) — don't auto-wire or block on its inputs nd = slot_def[s["id"]] for ci in connection_inputs(nd or {}): + # Autogrow list sockets (ref_image0, ref_image1, …) are explicit-only: a single + # IMAGE producer would otherwise be auto-wired into EVERY index, silently + # duplicating one reference ten times. An unwired index is simply absent. + if ci.get("autogrow"): + continue targets.append((slot_node_id[s["id"]], ci["name"], ci["type"], ci.get("required", False))) for node_id, inp, t, required in targets: diff --git a/movie_editor/backend/nodes.py b/movie_editor/backend/nodes.py index 54e43057..7fcf5307 100644 --- a/movie_editor/backend/nodes.py +++ b/movie_editor/backend/nodes.py @@ -102,6 +102,59 @@ def _combo_default(opts: dict, choices: list | None = None): return d return choices[0] if choices else "" +def _is_autogrow_type(t) -> bool: + """A V3 autogrow list input (COMFY_AUTOGROW_V3): not a socket itself, but a template + that ComfyUI expands into one real socket per index (ref_image0, ref_image1, …).""" + return isinstance(t, str) and "AUTOGROW" in t.upper() + + +def _autogrow_names(opts: dict) -> list[str]: + """The socket names an autogrow input expands to. Two template flavours: a prefix with + a max count ("ref_image" x 10 -> ref_image0..ref_image9) or an explicit name list.""" + tpl = opts.get("template") + if not isinstance(tpl, dict): + return [] + names = tpl.get("names") + if isinstance(names, list): + return [str(n) for n in names] + prefix, mx = tpl.get("prefix"), tpl.get("max") + if isinstance(prefix, str) and isinstance(mx, int) and mx > 0: + return [f"{prefix}{i}" for i in range(mx)] + return [] + + +def _autogrow_element_type(opts: dict) -> str | None: + """The type of ONE element of an autogrow list — the template's single input, e.g. IMAGE + for ref_images. Widget-typed templates are forced to sockets by ComfyUI, so they count too.""" + tpl = opts.get("template") + if not isinstance(tpl, dict): + return None + inp = tpl.get("input") + if not isinstance(inp, dict): + return None + for group in ("required", "optional"): + for spec in (inp.get(group) or {}).values(): + if isinstance(spec, (list, tuple)) and spec and isinstance(spec[0], str): + return spec[0] + return None + + +def _autogrow_children(name: str, opts: dict) -> list[dict]: + """Expand an autogrow input into its indexed sockets. Empty when the template can't be + read — the caller then falls back to the raw (unwireable) input so nothing regresses.""" + t = _autogrow_element_type(opts) + names = _autogrow_names(opts) + if not t or not names: + return [] + return [ + # Always optional: the API graph carries only the indices that are actually wired, + # and ComfyUI grows the schema to match, so an unwired index must never block. + {"name": nm, "type": _normalize_type(t), "required": False, + "autogrow": {"parent": name, "index": i}} + for i, nm in enumerate(names) + ] + + # ComfyUI V3 dynamic match types that are semantically IMAGE-compatible. _MATCHTYPE_ALIASES: dict[str, str] = {} # populated on first lookup — patterns are prefix-matched @@ -181,6 +234,12 @@ def _all_input_types(node_def: dict) -> list[str]: for spec in (inp.get(group) or {}).values(): t = spec[0] if isinstance(spec, list) and spec else None opts = spec[1] if isinstance(spec, list) and len(spec) > 1 and isinstance(spec[1], dict) else {} + if _is_autogrow_type(t): + # The node consumes the ELEMENT type (IMAGE), not the list wrapper. + el_t = _autogrow_element_type(opts) + if el_t: + types.extend(link_types(el_t)) + continue if isinstance(t, str) and not widget_type_of(t, opts): types.extend(link_types(t)) return types @@ -214,6 +273,15 @@ def connection_inputs(node_def: dict) -> list[dict]: if not isinstance(t, str): continue opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {} + if _is_autogrow_type(t): + # A list input (H3's ref_images/ref_videos/…): the parent name is never wired + # in the API graph — ComfyUI expands it into ref_image0, ref_image1, … and + # rebuilds the dict from whichever indices are present. Offer those sockets + # instead, so they can be sourced like any other input. + children = _autogrow_children(name, opts) + if children: + out.extend(children) + continue wt = widget_type_of(t, opts) if wt: # A widget-typed input is a SOCKET (wireable) only when forceInput is set — diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 97ebb141..476190e7 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -701,7 +701,9 @@ if (!slotItems.length && !linkItems.length) return; const wrap = el("div", "insp-block"); const tag = el("div", "insp-tag"); tag.textContent = "Exposed controls"; wrap.append(tag); - const SRC_LBL = { frame_rate: "Project FPS", num_frames_per_scene: "Project Frames", width: "Project Width", height: "Project Height" }; + const SRC_LBL = { frame_rate: "Project FPS", num_frames_per_scene: "Project Frames", + width: "Project Width", height: "Project Height", prompt: "Project Prompt", + negative_prompt: "Project Negative prompt", seed: "Project Seed" }; linkItems.forEach((l) => { if (l.source === "editor") { const note = el("div", "lib-sub"); note.textContent = `← ${SRC_LBL[l.editor_key] || l.editor_key}`; diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index 5ed6e9a2..51903d87 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -522,9 +522,12 @@ slot.input_sources = slot.input_sources || {}; const sbox = el("div", "wire-box"); sbox.append(el("div", "wire-title", "Input sources")); - cand.connection_inputs.forEach((ci) => { + visibleConnectionInputs(slot, cand.connection_inputs).forEach((ci) => { const row = el("div", "wire-row"); - row.append(el("span", "wire-out", `${ci.name} (${ci.type})`)); + const lbl = el("span", "wire-out", `${ci.name} (${ci.type})`); + if (ci.autogrow) + lbl.title = `Entry ${ci.autogrow.index + 1} of the node's "${ci.autogrow.parent}" list — fill one and the next appears.`; + row.append(lbl); row.append(el("span", "wire-arrow", "←")); const srcs = allowedSources(slot, ci); const sel = el("select", "wire-select"); @@ -563,7 +566,7 @@ ports.filter((p) => typeAccepts(p.type, type)).forEach((p) => out.push({ value: "port:" + p.id, label: p.label })); config.slots.filter((s) => s.id !== slot.id).forEach((s2) => { const c2 = specFor(s2); - (c2?.connection_inputs || []).filter((ci) => typeAccepts(ci.type, type)).forEach((ci) => + visibleConnectionInputs(s2, c2?.connection_inputs).filter((ci) => typeAccepts(ci.type, type)).forEach((ci) => out.push({ value: `node:${s2.id}:${ci.name}`, label: `${slotFullLabel(s2)} · ${ci.name}` })); // Widget inputs can also receive a connection (ComfyUI converts a widget to an // input when wired) — e.g. EmptyLatentVideo.width/height. Offer them as targets. @@ -573,6 +576,30 @@ return out; } + // Whether a connection input already has something feeding it — an explicitly chosen + // source, or an output wired in from another node. + function inputIsFed(slot, name) { + const src = (slot.input_sources || {})[name]; + if (src && src !== "auto") return true; + return (config.slots || []).some((s2) => s2.id !== slot.id && + Object.values(s2.wires || {}).some((tg) => wireTargets(tg).includes(`node:${slot.id}:${name}`))); + } + + // Autogrow list inputs (MiniMax H3's ref_images, ref_videos, …) reach us already expanded + // into one socket per index — ref_image0 … ref_image9. Showing all ten of each list would + // bury the node, so the list renders what's filled plus one empty slot and grows as it is + // used, the way ComfyUI's own canvas grows it. + function visibleConnectionInputs(slot, cis) { + const lastFed = {}; + (cis || []).forEach((ci) => { + const ag = ci.autogrow; + if (ag && inputIsFed(slot, ci.name)) + lastFed[ag.parent] = Math.max(lastFed[ag.parent] == null ? -1 : lastFed[ag.parent], ag.index); + }); + return (cis || []).filter((ci) => !ci.autogrow || + ci.autogrow.index <= (lastFed[ci.autogrow.parent] == null ? -1 : lastFed[ci.autogrow.parent]) + 1); + } + // Available sources for a slot connection input of a given type. // Source IDs: "" = auto, "out::", "core::", "timeline" (IMAGE only). function sources(slot, type) { @@ -800,7 +827,8 @@ function renderDetail() { clear(detail); if (!curCand) return; - const draftSlot = { id: "__draft__", role: roleFinal, wires: {}, input_sources: {} }; + // Shares the draft's input_sources so a filled autogrow entry reveals the next one. + const draftSlot = { id: "__draft__", role: roleFinal, wires: {}, input_sources: draft.input_sources }; if ((curCand.inputs || []).length) { detail.append(el("div", "wire-title", "Values")); @@ -833,7 +861,7 @@ if ((curCand.connection_inputs || []).length) { const sbox = el("div", "wire-box"); sbox.append(el("div", "wire-title", "Input sources")); - curCand.connection_inputs.forEach((ci) => { + visibleConnectionInputs(draftSlot, curCand.connection_inputs).forEach((ci) => { const row = el("div", "wire-row"); row.append(el("span", "wire-out", `${ci.name} (${ci.type})`)); row.append(el("span", "wire-arrow", "←")); @@ -842,7 +870,10 @@ allowedSources(draftSlot, ci).forEach((s) => { const o = el("option", null, s.label); o.value = s.value; if (s.value === cur) o.selected = true; sel.append(o); }); - sel.onchange = () => { if (sel.value) draft.input_sources[ci.name] = sel.value; else delete draft.input_sources[ci.name]; }; + sel.onchange = () => { + if (sel.value) draft.input_sources[ci.name] = sel.value; else delete draft.input_sources[ci.name]; + if (ci.autogrow) renderDetail(); // filling a list entry reveals the next one + }; row.append(sel); sbox.append(row); }); @@ -988,6 +1019,21 @@ } // ── linked-inputs section ────────────────────────────────────────────────────── + // Project values a linked input can be driven by at generation time. `kinds` is the + // widget kinds each one can legitimately fill — the prompt bindings are what let a + // custom node's own text field (e.g. MiniMaxH3ReferenceToVideo.prompt) receive the + // same prompt the built-in encoder gets, instead of being typed twice. + const EDITOR_SOURCES = [ + { key: "", label: "Manual value" }, + { key: "prompt", label: "Project · Prompt (global)", kinds: ["string"] }, + { key: "negative_prompt", label: "Project · Negative prompt", kinds: ["string"] }, + { key: "seed", label: "Project · Seed", kinds: ["int", "float"] }, + { key: "frame_rate", label: "Project · FPS", kinds: ["int", "float"] }, + { key: "num_frames_per_scene", label: "Project · Frames", kinds: ["int", "float"] }, + { key: "width", label: "Project · Width", kinds: ["int", "float"] }, + { key: "height", label: "Project · Height", kinds: ["int", "float"] }, + ]; + function linkExposeBtn(link) { const b = el("button", "eye-btn" + (link.exposed ? " on" : ""), "◉"); b.type = "button"; b.title = link.exposed ? "Hide from main editor window" : "Show in main editor window"; @@ -1012,9 +1058,12 @@ const srcRow = el("label", "field link-source"); srcRow.append(el("span", null, "Driven by")); const srcSel = el("select"); - [["", "Manual value"], ["frame_rate", "Project · FPS"], ["num_frames_per_scene", "Project · Frames"], - ["width", "Project · Width"], ["height", "Project · Height"]].forEach(([v, label]) => { - const o = new Option(label, v); if ((link.source === "editor" ? link.editor_key : "") === v) o.selected = true; srcSel.append(o); + const cur = link.source === "editor" ? link.editor_key : ""; + EDITOR_SOURCES.forEach(({ key, label, kinds }) => { + // Only offer a project value a field of this kind can actually hold — feeding a + // number into a text field (or a prompt into a width) silently breaks generation. + if (key && kinds && link.kind && !kinds.includes(link.kind) && key !== cur) return; + const o = new Option(label, key); if (key === cur) o.selected = true; srcSel.append(o); }); srcSel.onchange = async () => { if (srcSel.value) { link.source = "editor"; link.editor_key = srcSel.value; } @@ -1025,7 +1074,8 @@ card.append(srcRow); if (link.source === "editor") { - card.append(el("div", "link-bound", `Value comes from the project setting at generate.`)); + const srcLbl = (EDITOR_SOURCES.find((s) => s.key === link.editor_key) || {}).label || link.editor_key; + card.append(el("div", "link-bound", `Value comes from ${srcLbl} at generate — the fields below are ignored.`)); } else { const spec = { name: "shared value", kind: link.kind, choices: link.choices, required: false }; const vf = widgetField(spec, link.value, async (v) => { applyLinkValue(link, v); await persist(); }); diff --git a/movie_editor/tests/test_builder.py b/movie_editor/tests/test_builder.py index bdabcec3..919bf58a 100644 --- a/movie_editor/tests/test_builder.py +++ b/movie_editor/tests/test_builder.py @@ -550,3 +550,75 @@ def test_an_unknown_family_falls_back_to_ltxav_rather_than_emitting_nothing(): 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_image0": "out:li:IMAGE", "ref_image1": "timeline"}}, + ]} + graph, report = builder.build(REF_OI, models, PARAMS, media={"filename": "scene.png"}) + ins = graph["slot_r"]["inputs"] + assert ins["ref_image0"] == ["slot_li", 0] + assert ins["ref_image1"] == ["media_load", 0] + assert "ref_images" not in ins # the template name is never sent + # an unwired entry is simply absent — never blocking, never an empty placeholder + assert "ref_image2" not in ins and "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_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"]) diff --git a/movie_editor/tests/test_nodes.py b/movie_editor/tests/test_nodes.py index 999f8157..eab1307d 100644 --- a/movie_editor/tests/test_nodes.py +++ b/movie_editor/tests/test_nodes.py @@ -195,6 +195,63 @@ def test_multitype_socket_stays_a_socket_and_matches_any_member(): 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 + assert [ci[n]["type"] for n in ("ref_image0", "ref_image1", "ref_image2")] == ["IMAGE"] * 3 + assert ci["ref_image0"]["autogrow"] == {"parent": "ref_images", "index": 0} + # explicit-name templates keep their own names, in order + assert ci["ref_audio_a"]["type"] == "AUDIO" + assert ci["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)