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:
co-authored by
Claude Opus 5
parent
ab85ef4ca9
commit
e1ae4acea9
@@ -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:
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user