Models: treat V3 MultiType widgets as widgets, not required sockets

LTXVEmptyLatentAudio.frame_rate arrives from object_info as
("FLOAT,INT", {"widgetType": "INT", ...}) — a V3 MultiType wrapped around an
int widget. The editor split types on exact string match, so the union looked
like an unknown connection type: the field vanished from the node's widgets,
turned into a required input source no node could satisfy, and blocked
generation with 'has no source'.

Classification now goes through nodes.widget_type_of(): an input whose members
are all widget types (or that carries a widgetType hint) is a widget, rendered
and defaulted as its widget member. Genuine unions ("IMAGE,MASK") stay sockets
and match a producer of any member via type_accepts(), used by auto-wire,
bypass passthrough, the source/destination pickers and the image-target list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-07-29 16:25:12 +03:00
co-authored by Claude Opus 5
parent 3222fbcb31
commit 5af0a1b5d3
6 changed files with 169 additions and 30 deletions
+26 -13
View File
@@ -25,7 +25,8 @@ from typing import Any, Optional
from . import config
from . import pipeline_wiring
from .nodes import WIDGET_PRIMITIVES, connection_inputs, node_outputs, _combo_default, _combo_choices
from .nodes import (WIDGET_PRIMITIVES, connection_inputs, node_outputs, type_accepts,
widget_type_of, _combo_default, _combo_choices)
# ── fixed core ────────────────────────────────────────────────────────────────
# logical id -> class_type
@@ -116,7 +117,7 @@ def _ordered_widget_names(node_def: dict) -> list[str]:
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
if opts.get("forceInput"):
continue # forceInput STRING is a socket, never a widget value
if isinstance(t, list) or t in WIDGET_PRIMITIVES:
if isinstance(t, list) or widget_type_of(t, opts) in WIDGET_PRIMITIVES:
names.append(name)
return names
@@ -158,18 +159,19 @@ def _widget_defaults(node_def: Optional[dict]) -> dict:
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
if opts.get("forceInput"):
continue
wt = widget_type_of(t, opts) if isinstance(t, str) else None
if isinstance(t, list):
out[name] = opts.get("default", t[0] if t else None)
elif isinstance(t, str) and "COMBO" in t.upper():
elif wt and "COMBO" in wt.upper():
# "COMBO" or a V3 dynamic combo (COMFY_DYNAMICCOMBO_V3): emit the selected
# key string (options may be {"key": ...} dicts), not the option object.
out[name] = _combo_default(opts)
elif t in WIDGET_PRIMITIVES:
elif wt in WIDGET_PRIMITIVES:
# Always emit a value for every widget — ComfyUI's frontend does, and a
# required widget with no declared default (e.g. ImageTransform's bboxes)
# otherwise goes missing and ComfyUI rejects the prompt. Fall back to a
# type-appropriate empty so generation isn't blocked.
out[name] = opts.get("default", _WIDGET_EMPTY.get(t, ""))
out[name] = opts.get("default", _WIDGET_EMPTY.get(wt, ""))
return out
@@ -192,7 +194,7 @@ def _widget_choices(node_def: Optional[dict]) -> dict:
if isinstance(t, list):
if t:
out[name] = t
elif isinstance(t, str) and "COMBO" in t.upper():
elif isinstance(t, str) and "COMBO" in (widget_type_of(t, opts) or "").upper():
choices = _combo_choices(opts)
if choices:
out[name] = choices
@@ -627,7 +629,7 @@ def core_graph(object_info: dict, models_config: dict | None = None) -> list[dic
def _options(t, builtin_label, self_cid):
opts = [{"value": "", "label": f"built-in: {builtin_label}"}]
for val, lbl in producers.get(t, []):
for val, lbl in _matching_producers(producers, t):
if val.startswith(f"core:{self_cid}:"):
continue
opts.append({"value": val, "label": lbl})
@@ -714,6 +716,18 @@ def _producers(graph, slots, slot_node_id, slot_def, object_info):
return out
def _matching_producers(producers: dict, t: str) -> list:
"""Producers that can feed an input of type `t`. A union type ("IMAGE,MASK") is fed by
any of its members, so collect across them instead of matching the string exactly."""
if t in producers:
return producers[t]
out = []
for pt, lst in producers.items():
if type_accepts(t, pt):
out.extend(lst)
return out
def _reaches_upstream(graph, start, goal):
"""True if `goal` is upstream of `start` — i.e. `start` transitively consumes `goal`'s
output. Used to decide whether wiring `start`'s output into a node would close a loop."""
@@ -838,7 +852,7 @@ def _autowire(graph, slots, slot_node_id, slot_def, object_info, producers, repo
continue
if isinstance(node["inputs"].get(inp), list):
continue # already wired (explicit/core)
raw = [p for p in producers.get(t, []) if p[0] != node_id]
raw = [p for p in _matching_producers(producers, t) if p[0] != node_id]
# Never auto-wire a source that already depends on this node — that closes a loop and
# ComfyUI rejects the whole graph. Drop such candidates so auto-wire stays acyclic.
cands = [p for p in raw if not _reaches_upstream(graph, p[0], node_id)]
@@ -880,14 +894,13 @@ def _apply_bypass(graph, slots, slot_node_id, slot_def, report):
continue
nd = slot_def.get(s["id"]) or {}
outs = node_outputs(nd)
by_type: dict[str, list[str]] = {}
for ci in connection_inputs(nd):
by_type.setdefault(ci["type"], []).append(ci["name"])
cis = connection_inputs(nd)
passthrough = {}
ok = True
for i, o in enumerate(outs):
names = by_type.get(o["type"])
if not names or len(names) != 1:
# A union-typed input ("IMAGE,MASK") can carry any of its members through.
names = [ci["name"] for ci in cis if type_accepts(ci["type"], o["type"])]
if len(names) != 1:
ok = False
break
passthrough[i] = graph[sid]["inputs"].get(names[0])
+53 -6
View File
@@ -36,6 +36,47 @@ def _is_widget_type(t) -> bool:
return isinstance(t, str) and (t in _WIDGET_TYPES or _is_combo_type(t))
def type_parts(t) -> list[str]:
"""Member types of a ComfyUI type string. A V3 MultiType input serializes its members
comma-joined — "FLOAT,INT" (an int widget that also accepts a float link), "IMAGE,MASK"
(a socket taking either) — so a single string can name several types."""
if not isinstance(t, str):
return []
return [p.strip() for p in t.split(",") if p.strip()]
def widget_type_of(t, opts: dict | None = None) -> str | None:
"""The widget type an input renders as, or None if it is a pure graph socket.
A V3 MultiType wrapped around a widget input (io.MultiType.Input(io.Int.Input(...), ...))
is a WIDGET in ComfyUI's frontend, not a socket: it carries "widgetType" in its options
and lists the widget's own type first, e.g. LTXVEmptyLatentAudio.frame_rate arriving as
("FLOAT,INT", {"widgetType": "INT", "default": 25}). Treating it as a required socket
would demand a source for a field the user simply types into."""
opts = opts or {}
wt = opts.get("widgetType")
if _is_widget_type(wt):
return wt
parts = type_parts(t)
if parts and all(_is_widget_type(p) for p in parts):
return parts[0]
return None
def link_types(t) -> list[str]:
"""The graph-connection member types of a type string (widget members dropped)."""
return [_normalize_type(p) for p in type_parts(t) if not _is_widget_type(p)]
def type_accepts(input_type: str, output_type: str) -> bool:
"""Whether an output of `output_type` can feed an input of `input_type`. Either side may
be a union, so they match when any member type is shared."""
a, b = type_parts(input_type), type_parts(output_type)
if not a or not b:
return input_type == output_type
return bool({_normalize_type(x) for x in a} & {_normalize_type(y) for y in b})
def _combo_choices(opts: dict) -> list:
"""Normalise combo options to plain string/int choices. V3 dynamic combos express
options as dicts {"key": <value>, "inputs": [...]}; standard combos are list[str]."""
@@ -120,8 +161,9 @@ def _all_input_types(node_def: dict) -> list[str]:
for group in ("required", "optional"):
for spec in (inp.get(group) or {}).values():
t = spec[0] if isinstance(spec, list) and spec else None
if isinstance(t, str) and t not in _WIDGET_TYPES:
types.append(_normalize_type(t))
opts = spec[1] if isinstance(spec, list) and len(spec) > 1 and isinstance(spec[1], dict) else {}
if isinstance(t, str) and not widget_type_of(t, opts):
types.extend(link_types(t))
return types
@@ -153,15 +195,17 @@ 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_widget_type(t):
wt = widget_type_of(t, opts)
if wt:
# A widget-typed input is a SOCKET (wireable) only when forceInput is set —
# e.g. refinement_key_input is ("STRING", {"forceInput": True}). It always
# has a widget fallback, so it's NEVER auto-required (it must not block
# generation when left unwired). Plain widgets (incl. V3 dynamic combos
# like COMFY_DYNAMICCOMBO_V3) are handled by widget_inputs, not here.
# like COMFY_DYNAMICCOMBO_V3 and MultiType widgets like ("FLOAT,INT", ...))
# are handled by widget_inputs, not here.
if not opts.get("forceInput"):
continue
out.append({"name": name, "type": _normalize_type(t), "required": False})
out.append({"name": name, "type": _normalize_type(wt), "required": False})
continue
# V3 nodes can mark a required-group input as optional via the flag.
is_required = group == "required" and not opts.get("optional", False)
@@ -201,6 +245,9 @@ def widget_inputs(node_def: dict) -> list[dict]:
if opts.get("forceInput"):
continue
field = {"name": name, "required": group == "required", "options": opts}
# A MultiType widget ("FLOAT,INT") renders as its widget member, not the union.
if isinstance(t, str):
t = widget_type_of(t, opts) or t
if isinstance(t, list):
# Old-style combo: t is the list of choices (e.g. folder_paths filenames).
field["kind"] = "combo"
@@ -307,7 +354,7 @@ def ports_from_input_types(label: str, node_key: str, input_types: dict) -> list
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
# Expose typed sockets, plus forceInput widget sockets (e.g. the STRING
# refinement_key_input on Studio/Sampler) so they can be wired manually.
if _is_widget_type(t) and not opts.get("forceInput"):
if widget_type_of(t, opts) and not opts.get("forceInput"):
continue
ports.append({"id": f"{node_key}.{name}", "node": label, "input": name,
"type": t, "label": f"{label} · {name}"})
+17 -9
View File
@@ -134,7 +134,7 @@
function allowedSources(slot, ci) {
const all = sources(slot, ci.type);
if (!pipelineLocked()) return all;
if (slot.role === "image_processing" && ci.name === "image" && ci.type === "IMAGE") {
if (slot.role === "image_processing" && ci.name === "image" && typeAccepts(ci.type, "IMAGE")) {
return all.filter((s) => !s.value || s.value === "timeline" || s.value.startsWith("out:"));
}
return all.filter((s) => !s.value || s.value.startsWith("out:") || s.value === "timeline");
@@ -361,7 +361,7 @@
// that feeds nothing must not be flagged or block generation.
if (slotIsActive(slot)) (spec.connection_inputs || []).forEach((ci) => {
if (!ci.required) return;
if (ci.type === "IMAGE") return; // a scene/timeline image can always feed an IMAGE input
if (typeAccepts(ci.type, "IMAGE")) return; // a scene/timeline image can always feed an IMAGE input
const src = (slot.input_sources || {})[ci.name];
if (src && src !== "auto") return; // explicitly sourced (incl. "timeline")
const incoming = (config.slots || []).some((s2) =>
@@ -546,16 +546,24 @@
}
const _KIND2T = { int: "INT", float: "FLOAT", string: "STRING", boolean: "BOOLEAN" };
// A ComfyUI type string can name several types at once — a V3 MultiType socket serializes
// its members comma-joined ("IMAGE,MASK"). An output feeds an input when they share one.
function typeAccepts(inputType, outputType) {
if (inputType === outputType) return true;
const parts = (t) => String(t || "").split(",").map((p) => p.trim()).filter(Boolean);
const a = parts(inputType), b = parts(outputType);
return a.some((x) => b.includes(x));
}
function destinations(slot, type) {
const out = [{ value: "", label: "— unwired —" }];
// Global editor outputs: wire your final video/audio producer here and the editor shows
// it (works with or without the built-in pipeline). IMAGE -> video, AUDIO -> audio.
if (type === "IMAGE") out.push({ value: "global:video", label: "🌐 Global video output (shown in editor)" });
if (type === "AUDIO") out.push({ value: "global:audio", label: "🌐 Global audio output (shown in editor)" });
ports.filter((p) => p.type === type).forEach((p) => out.push({ value: "port:" + p.id, label: p.label }));
if (typeAccepts(type, "IMAGE")) out.push({ value: "global:video", label: "🌐 Global video output (shown in editor)" });
if (typeAccepts(type, "AUDIO")) out.push({ value: "global:audio", label: "🌐 Global audio output (shown in editor)" });
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) => ci.type === type).forEach((ci) =>
(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.
@@ -569,12 +577,12 @@
// Source IDs: "" = auto, "out:<slotId>:<outName>", "core:<coreId>:<outIdx>", "timeline" (IMAGE only).
function sources(slot, type) {
const out = [{ value: "", label: "(auto-wire)" }];
if (type === "IMAGE") out.push({ value: "timeline", label: "Timeline (scene image)" });
coreProducers.filter((p) => p.type === type).forEach((p) =>
if (typeAccepts(type, "IMAGE")) out.push({ value: "timeline", label: "Timeline (scene image)" });
coreProducers.filter((p) => typeAccepts(type, p.type)).forEach((p) =>
out.push({ value: p.id, label: p.label }));
config.slots.filter((s) => s.id !== slot.id).forEach((s2) => {
const c2 = specFor(s2);
(c2?.outputs || []).filter((o) => o.type === type).forEach((o) =>
(c2?.outputs || []).filter((o) => typeAccepts(type, o.type)).forEach((o) =>
out.push({ value: `out:${s2.id}:${o.name}`, label: `${slotFullLabel(s2)} → ${o.name}` }));
});
return out;
+2 -2
View File
@@ -2264,14 +2264,14 @@ if web is not None and PromptServer is not None:
oi = {}
out = []
for p in nodes.pipeline_ports(oi):
if p.get("type") == "IMAGE":
if nodes.type_accepts(p.get("type") or "", "IMAGE"):
out.append({"value": "port:" + p["id"], "label": p["label"]})
pid = req.query.get("pid")
models_cfg = _project_models(projects.get(pid)) if pid else nodes.load_models()
for slot in models_cfg.get("slots", []):
nd = oi.get(slot.get("node_class")) or {}
for ci in nodes.connection_inputs(nd):
if ci["type"] == "IMAGE":
if nodes.type_accepts(ci["type"], "IMAGE"):
label = (slot.get("label") or slot.get("node_class")) + " · " + ci["name"]
out.append({"value": f"node:{slot['id']}:{ci['name']}", "label": label})
return web.json_response({"targets": out})
+30
View File
@@ -207,6 +207,36 @@ def test_ambiguous_type_is_reported_not_guessed():
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"}},
+41
View File
@@ -154,6 +154,47 @@ def test_v1_combo_and_force_input():
assert ci["blocks"] == "SELECTEDDITBLOCKS"
def test_multitype_widget_is_a_widget_not_a_required_socket():
"""A V3 MultiType built around a widget input serializes comma-joined with a widgetType
hint — e.g. LTXVEmptyLatentAudio.frame_rate as ("FLOAT,INT", {"widgetType": "INT"}).
It is a field the user types into; treating it as a socket demanded a source that no
node produces and blocked generation."""
oi = dict(OI)
oi["LTXVEmptyLatentAudio"] = {
"input": {"required": {
"frames_number": ["INT", {"default": 97}],
"frame_rate": ["FLOAT,INT", {"widgetType": "INT", "default": 25, "min": 1}],
"batch_size": ["INT", {"default": 1}],
"audio_vae": ["VAE"],
}},
"output": ["LATENT"], "output_name": ["Latent"],
"display_name": "LTXV Empty Latent Audio",
}
desc = nodes.describe_node(oi, "LTXVEmptyLatentAudio")
widgets = {w["name"]: w for w in desc["inputs"]}
assert widgets["frame_rate"]["kind"] == "int" # renders as the widget it is
assert widgets["frame_rate"]["default"] == 25
ci = {c["name"]: c["type"] for c in desc["connection_inputs"]}
assert ci == {"audio_vae": "VAE"} # frame_rate is NOT a socket
# ...and it still qualifies for the audio_encoder role (union widget isn't an input type)
assert "LTXVEmptyLatentAudio" in [c["class"] for c in nodes.candidates(oi, "audio_encoder")]
def test_multitype_socket_stays_a_socket_and_matches_any_member():
"""A MultiType with no widget member ("IMAGE,MASK") is a real socket — it must still be
offered as one, and fed by a producer of either member type."""
oi = {"ImageResizeMatch": {"input": {"required": {"image": ["IMAGE"],
"match": ["IMAGE,MASK"]}},
"output": ["IMAGE"], "display_name": "Resize to Match"}}
ci = {c["name"]: (c["type"], c["required"]) for c in nodes.describe_node(oi, "ImageResizeMatch")["connection_inputs"]}
assert ci["match"] == ("IMAGE,MASK", True)
assert nodes.type_accepts("IMAGE,MASK", "MASK")
assert nodes.type_accepts("IMAGE,MASK", "IMAGE")
assert not nodes.type_accepts("IMAGE,MASK", "LATENT")
assert nodes.widget_type_of("IMAGE,MASK", {}) is None
assert nodes.widget_type_of("FLOAT,INT", {}) == "FLOAT" # no hint → first member wins
def test_models_store_roundtrip(tmp_path, monkeypatch):
from movie_editor.backend import config
monkeypatch.setattr(config, "DATA_DIR", tmp_path)