From 5af0a1b5d3edde73fb1ca1426de0fc9ccbae17b2 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:25:12 +0300 Subject: [PATCH] Models: treat V3 MultiType widgets as widgets, not required sockets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- movie_editor/backend/builder.py | 39 +++++++++++++------- movie_editor/backend/nodes.py | 59 +++++++++++++++++++++++++++--- movie_editor/frontend/models.js | 26 ++++++++----- movie_editor/server.py | 4 +- movie_editor/tests/test_builder.py | 30 +++++++++++++++ movie_editor/tests/test_nodes.py | 41 +++++++++++++++++++++ 6 files changed, 169 insertions(+), 30 deletions(-) diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index f6b5d9d4..ad97932b 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -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]) diff --git a/movie_editor/backend/nodes.py b/movie_editor/backend/nodes.py index a2b8b840..4af32be4 100644 --- a/movie_editor/backend/nodes.py +++ b/movie_editor/backend/nodes.py @@ -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": , "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}"}) diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index 9c5b14df..6584ea7d 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -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::", "core::", "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; diff --git a/movie_editor/server.py b/movie_editor/server.py index d605b257..cd2c9ae0 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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}) diff --git a/movie_editor/tests/test_builder.py b/movie_editor/tests/test_builder.py index 6d22ff65..9c6154b6 100644 --- a/movie_editor/tests/test_builder.py +++ b/movie_editor/tests/test_builder.py @@ -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"}}, diff --git a/movie_editor/tests/test_nodes.py b/movie_editor/tests/test_nodes.py index 7de08512..999f8157 100644 --- a/movie_editor/tests/test_nodes.py +++ b/movie_editor/tests/test_nodes.py @@ -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)