fix: wire H3 reference lists, bind node prompts to the project prompt, survive a missing projector file

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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-03 19:56:32 +03:00
co-authored by Claude Opus 5
parent ab85ef4ca9
commit e1ae4acea9
6 changed files with 285 additions and 13 deletions
+25 -2
View File
@@ -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:
+68
View File
@@ -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 —
+3 -1
View File
@@ -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}`;
+60 -10
View File
@@ -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:<slotId>:<outName>", "core:<coreId>:<outIdx>", "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(); });
+72
View File
@@ -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"])
+57
View File
@@ -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)