From 98a1504a29ec09c9c7af4cb28fdeb558b73348ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:10:12 +0000 Subject: [PATCH] fix: workflow import reads the official ComfyUI templates: lost widget values, dynamic inputs, autogrow names, prompt through guiders Audit against all 634 workflows ComfyUI ships (comfyui-workflow-templates). Found and fixed: a crash on list-valued widgets; widgets of nodes inside subgraphs losing seed/denoise; dynamic combos (resize, save format, sparse attention) shifting every later value; instance values mapped by the wrong inputs; Math expression / Batch images / Reference multi latent inputs refused; INT wires into FLOAT-or-INT widgets refused; the prompt not found when conditioning goes through BasicGuider (official H3 workflows); widgets an older export never had now take the node default; nodes no save or preview uses are left out as ComfyUI does. Autogrow sockets are now numbered as ComfyUI numbers them (R2V has 9/3/3/3, FunPack offered one extra of each). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WP6HMLLhRYcghAEXK4KLwK --- core/comfy_types.py | 21 ++-- core/graph.py | 53 ++++++++-- core/tests/test_workflow_import.py | 91 +++++++++++++++++ core/tests/test_workflow_import_samples.py | 94 +++++++++++++++++ core/workflow_import.py | 112 ++++++++++++++++++--- 5 files changed, 343 insertions(+), 28 deletions(-) create mode 100644 core/tests/test_workflow_import_samples.py diff --git a/core/comfy_types.py b/core/comfy_types.py index 7f5192f0..225602c6 100644 --- a/core/comfy_types.py +++ b/core/comfy_types.py @@ -167,7 +167,7 @@ def autogrow_instances(options: Optional[dict]) -> List[Tuple[str, Any, Dict[str `template` allows -- "ref_images" carrying `{"template": {"input": {"required": {"ref_image": ("IMAGE", {...})}}, "prefix": "ref_image_", "min": 0, "max": 9}}` becomes ("ref_image_0", "IMAGE", {...}) through - ("ref_image_9", "IMAGE", {...}). + ("ref_image_8", "IMAGE", {...}): nine sockets, as the node itself builds them. Every instance is wireable regardless of `min`: an autogrow's own `min` is how many the ORIGINAL node would insist on before it runs, which is not @@ -186,17 +186,24 @@ def autogrow_instances(options: Optional[dict]) -> List[Tuple[str, Any, Dict[str template = (options or {}).get("template") if not isinstance(template, dict): return [] - wrapped = (template.get("input") or {}).get("required") or {} + inner = template.get("input") or {} + wrapped = inner.get("required") or inner.get("optional") or {} # the wrapped input may be an optional one if len(wrapped) != 1: return [] (inner_name, inner_decl), = wrapped.items() - prefix = template.get("prefix") - lo, hi = template.get("min"), template.get("max") - if (not isinstance(prefix, str) or not isinstance(lo, int) or not isinstance(hi, int) - or isinstance(lo, bool) or isinstance(hi, bool) or lo > hi): + # ComfyUI's own naming (comfy_api _io.Autogrow): a prefix template has `max` instances numbered from 0 + # (prefix0 .. prefix{max-1}); a names template has exactly the names it lists. `min` is only how many a run + # insists on, so it never changes which names exist. + prefix, names = template.get("prefix"), template.get("names") + hi = template.get("max") + if isinstance(names, list) and names and all(isinstance(n, str) and n for n in names): + found = list(names) + elif isinstance(prefix, str) and isinstance(hi, int) and not isinstance(hi, bool) and hi >= 1: + found = [f"{prefix}{i}" for i in range(hi)] + else: return [] kind, inner_options = declared(inner_decl) - return [(f"{prefix}{i}", kind, inner_options) for i in range(lo, hi + 1)] + return [(name, kind, inner_options) for name in found] def match_type_union(allowed: Any) -> Optional[str]: diff --git a/core/graph.py b/core/graph.py index 39a60d92..13d1c60b 100644 --- a/core/graph.py +++ b/core/graph.py @@ -69,6 +69,31 @@ class Schemas: name for name, (parent, keys) in (found.get("required_when") or {}).items() if picked.get(parent) in keys] + def wire_type(self, class_type: str, name: str) -> str: + """What a wire into `name` may carry: the whole union of a widget that also takes a wire, else its type.""" + found = self.of(class_type) + return ((found.get("wires") or {}).get(name) or found["inputs"].get(name)) if found else None + + def default(self, class_type: str, name: str): + """(has one, value): what a widget holds when nothing was typed into it.""" + found = self.of(class_type) + table = (found or {}).get("defaults") or {} + return (name in table, table.get(name)) + + def frontend_only(self, class_type: str, name: str) -> bool: + found = self.of(class_type) + return name in ((found or {}).get("frontend") or ()) + + def is_output(self, class_type: str) -> bool: + """Whether a run of this node is wanted for itself (a save, a preview): ComfyUI executes what these need.""" + found = self.of(class_type) + return bool(found and found.get("output")) + + def shown_by(self, class_type: str) -> Dict[str, Tuple[str, list]]: + """{field: (combo, choices)} for every field a dynamic combo's choice brings, in declaration order.""" + found = self.of(class_type) + return dict(found.get("shown_by") or {}) if found else {} + def limits(self, class_type: str) -> Dict[str, dict]: """What each input will ACCEPT: a combo's choices, a number's bounds. @@ -88,7 +113,8 @@ def from_comfyui() -> Schemas: if node is None: return None spec = node.INPUT_TYPES() - inputs, required, limits, when = {}, [], {}, {} + inputs, required, limits, when, shown, wires, defaults = {}, [], {}, {}, {}, {}, {} + frontend = set() for section in ("required", "optional"): for name, declared in (spec.get(section) or {}).items(): kind, options = comfy_types.declared(declared) @@ -101,8 +127,10 @@ def from_comfyui() -> Schemas: if kind == comfy_types.AUTOGROW: for instance_name, instance_kind, instance_options in ( comfy_types.autogrow_instances(options)): - edited = comfy_types.widget_type(instance_kind, instance_options) - inputs[f"{name}.{instance_name}"] = edited if edited else instance_kind + # Every instance is a socket (the frontend draws one plug per name), so a union of + # plain types such as ComfyMathExpression's "FLOAT,INT,BOOLEAN" stays a union: reading + # it as a FLOAT widget refused the INT that feeds it. + inputs[f"{name}.{instance_name}"] = instance_kind continue # A MatchType input's own type name says nothing about what it # actually accepts -- resolve it to the real union (see @@ -118,11 +146,14 @@ def from_comfyui() -> Schemas: # and none of them can be fed by a wire. edited = comfy_types.widget_type(kind, options) inputs[name] = edited if edited else kind + if edited and isinstance(kind, str) and "," in kind and edited != comfy_types.COMBO: + wires[name] = kind # "FLOAT,INT" typed in as a FLOAT, but a wire of either type may feed it if comfy_types.is_combo(kind) and comfy_types.reveals(options): for dotted, decl, req, parent, keys in comfy_types.revealed(name, options): sub_kind, sub_options = comfy_types.declared(decl) sub_edited = comfy_types.widget_type(sub_kind, sub_options) inputs[dotted] = sub_edited if sub_edited else sub_kind + shown[dotted] = (parent, keys) if req: when[dotted] = (parent, keys) sub_bounds = {} @@ -144,6 +175,14 @@ def from_comfyui() -> Schemas: if inputs[name] == comfy_types.COMBO: bounds["choices"] = (list(kind) if isinstance(kind, (list, tuple)) else comfy_types.choices(options)) + if options.get("socketless"): + frontend.add(name) # a widget only the ComfyUI page draws (a colour, a viewer): nothing to wire + if inputs[name] in comfy_types.PRIMITIVE or options.get("socketless"): + # What ComfyUI's own frontend puts in a widget a saved workflow has no value for. + if "default" in options: + defaults[name] = comfy_types._plain(options["default"]) + elif bounds.get("choices"): + defaults[name] = bounds["choices"][0] for edge in ("min", "max"): if edge in options: bounds[edge] = options[edge] @@ -168,7 +207,8 @@ def from_comfyui() -> Schemas: outputs[i] = resolved return {"inputs": inputs, "outputs": outputs, - "required": required, "limits": limits, "required_when": when} + "required": required, "limits": limits, "required_when": when, "shown_by": shown, "wires": wires, "defaults": defaults, "frontend": frontend, + "output": bool(getattr(node, "OUTPUT_NODE", False))} return Schemas(lookup) @@ -403,8 +443,9 @@ def build(slots: Sequence[dict], schemas: Optional[Schemas] = None, # takes either, and comparing the whole string refused a MASK # feeding it -- a legal wire refused, which is as bad a failure # as an illegal one accepted, and harder to argue with. - if not comfy_types.accepts(declared[name], produced[index]): - problems.append(f"{slot_id}.{name} wants {declared[name]} but {source!r} " + wanted = schemas.wire_type(class_type, name) + if not comfy_types.accepts(wanted, produced[index]): + problems.append(f"{slot_id}.{name} wants {wanted} but {source!r} " f"gives {produced[index]}") continue inputs[name] = [source, index] diff --git a/core/tests/test_workflow_import.py b/core/tests/test_workflow_import.py index a20ab60f..0b30068d 100644 --- a/core/tests/test_workflow_import.py +++ b/core/tests/test_workflow_import.py @@ -190,3 +190,94 @@ def test_a_set_inside_one_subgraph_copy_is_not_read_by_the_other_copy(): "links": [link(70, "sg1_1", 0, "sg1_2", 0), link(71, "sg1_3", 0, "sg1_4", 0), link(72, "sg2_1", 0, "sg2_2", 0), link(73, "sg2_3", 0, "sg2_4", 0)]} got = {s["id"]: s for s in wi.convert(w, S)["slots"]} assert got["wsg2_4"]["inputs"]["latent_image"] == ["wsg2_1", 0] and got["wsg1_4"]["inputs"]["latent_image"] == ["wsg1_1", 0] + + +# ── what the official ComfyUI workflow templates showed (audit, 2026-10-21) ────────────────────────────── + +NODES["Resize"] = {"inputs": {"image": "IMAGE", "kind": "COMBO", "kind.width": "INT", "kind.mult": "FLOAT", "method": "COMBO"}, "outputs": ["IMAGE"], + "shown_by": {"kind.width": ("kind", ["exact"]), "kind.mult": ("kind", ["scale"])}} +NODES["Sampler2"] = {"inputs": {"seed": "INT", "steps": "INT", "denoise": "FLOAT", "model": "MODEL"}, "outputs": ["LATENT"]} +NODES["Guider"] = {"inputs": {"model": "MODEL", "conditioning": "CONDITIONING"}, "outputs": ["GUIDER"]} +NODES["Save"] = {"inputs": {"samples": "LATENT", "quality": "INT"}, "outputs": [], "required": ["samples", "quality"], "output": True, + "defaults": {"quality": 90}} +NODES["Viewer"] = {"inputs": {"images": "IMAGE", "view": "IMAGECOMPARE"}, "outputs": [], "required": ["view"], "output": True, "frontend": {"view"}} + + +def test_a_widget_value_that_is_a_list_does_not_crash_the_reading_of_the_next_one(): + # a bounding-box editor stores a list where a number's control word would be + n = {"id": 1, "type": "Sampler2", "inputs": [], "widgets_values": [1, [1, 2], 5]} + assert wi.convert({"nodes": [n], "links": []}, S)["slots"][0]["inputs"]["seed"] == 1 + + +def test_a_dynamic_combo_reads_its_chosen_widgets_right_after_it_and_leaves_the_next_widget_alone(): + for values, want in ((["exact", 1536, "lanczos"], {"kind": "exact", "kind.width": 1536, "method": "lanczos"}), + (["scale", 2.0, "area"], {"kind": "scale", "kind.mult": 2.0, "method": "area"})): + n = {"id": 1, "type": "Resize", "inputs": [{"name": "image", "link": None}], "widgets_values": values} + assert wi.convert({"nodes": [n], "links": []}, S)["slots"][0]["inputs"] == want + + +def test_named_values_win_over_counting_and_control_words_are_not_inputs(): + n = {"id": 1, "type": "Sampler2", "inputs": [], "widgets_values": [9, "fixed", 4, 1.0], + "widgets_values_named": {"seed": 9, "control_after_generate": "fixed", "steps": 4, "denoise": 1.0}} + assert wi.convert({"nodes": [n], "links": []}, S)["slots"][0]["inputs"] == {"seed": 9, "steps": 4, "denoise": 1.0} + + +def test_a_node_inside_a_subgraph_keeps_every_widget_though_its_sockets_list_only_some(): + # the top-level rule (only sockets marked as widgets are widgets) must not apply to widgets the definition never lists as sockets + n = {"id": 1, "type": "Sampler2", "inputs": [{"name": "steps", "link": None, "widget": {"name": "steps"}}], "widgets_values": [7, "fixed", 4, 0.5]} + assert wi.convert({"nodes": [n], "links": []}, S)["slots"][0]["inputs"] == {"seed": 7, "steps": 4, "denoise": 0.5} + + +def test_an_instance_of_a_subgraph_gets_one_value_per_typed_in_input_in_the_definitions_order(): + sub = {"id": "sg-9", "name": "Run", "nodes": [node(7, "Sampler2", [("steps", None), ("seed", None)], [0, "fixed", 1, 1.0])], + "inputs": [{"name": "model", "type": "MODEL"}, {"name": "seed", "type": "INT"}, {"name": "steps", "type": "INT"}], + "outputs": [{"name": "out", "type": "LATENT"}], + "links": [{"id": 1, "origin_id": wi.SG_IN, "origin_slot": 1, "target_id": 7, "target_slot": 1, "type": "INT"}, + {"id": 2, "origin_id": wi.SG_IN, "origin_slot": 2, "target_id": 7, "target_slot": 0, "type": "INT"}, + {"id": 3, "origin_id": 7, "origin_slot": 0, "target_id": wi.SG_OUT, "target_slot": 0, "type": "LATENT"}]} + sub["nodes"][0]["inputs"][0]["widget"] = {"name": "steps"} + w = {"definitions": {"subgraphs": [sub]}, "nodes": [{"id": 2, "type": "sg-9", "inputs": [{"name": "model", "link": None}], "widgets_values": [123, 30]}, + node(3, "VAEDecode", [("samples", 41)])], + "links": [link(41, 2, 0, 3, 0)]} + got = {s["id"]: s for s in wi.convert(w, S)["slots"]}["wsg2_7"]["inputs"] + assert got["seed"] == 123 and got["steps"] == 30 + + +def test_the_prompt_is_found_through_a_guiders_conditioning_input(): + w = {"nodes": [node(1, "CLIPTextEncode", [("clip", None)], ["a fox"]), node(2, "Guider", [("conditioning", 10)]), + node(3, "Save", [("samples", 11)], [80])], + "links": [link(10, 1, 0, 2, 0)]} + out = wi.convert(w, S) + assert out["bound"]["prompt"] == ["CLIPTextEncode #1.text"] + + +def test_a_widget_an_older_export_never_had_gets_the_nodes_default_and_a_dangling_node_is_left_out(): + w = {"nodes": [node(1, "Sampler2", [], [1, "fixed", 4, 1.0]), node(2, "Save", [("samples", 10)], []), + node(3, "Resize", [("image", None)], ["exact", 64, "area"])], # feeds nothing, takes nothing + "links": [link(10, 1, 0, 2, 0)]} + out = wi.convert(w, S) + assert {s["id"] for s in out["slots"]} == {"w1", "w2"} + assert {s["id"]: s for s in out["slots"]}["w2"]["inputs"]["quality"] == 90 + assert any("Left out" in n and "Resize #3" in n for n in out["notes"]) + + +def test_a_viewer_only_the_comfyui_page_can_draw_is_not_an_end_and_does_not_block_the_run(): + w = {"nodes": [node(1, "Sampler2", [], [1, "fixed", 4, 1.0]), node(2, "Save", [("samples", 10)], [80]), node(3, "Viewer", [("images", 11)])], + "links": [link(10, 1, 0, 2, 0)]} + out = wi.convert(w, S) + assert {s["id"] for s in out["slots"]} == {"w1", "w2"} + + +def test_an_end_that_lost_its_feed_stops_the_pruning_instead_of_hiding_the_whole_graph(): + w = {"nodes": [node(1, "Sampler2", [], [1, "fixed", 4, 1.0]), node(2, "Save", [], [80])], "links": []} + assert {s["id"] for s in wi.convert(w, S)["slots"]} == {"w1", "w2"} + + +def test_a_bypassed_node_with_an_any_socket_passes_that_input_through(): + NODES["Peek"] = {"inputs": {"source": "*"}, "outputs": ["STRING"]} + w = {"nodes": [node(1, "CLIPTextEncode", [("clip", None)], ["fox"]), + node(2, "Peek", [("source", 20, "*")], mode=4, outputs=[{"type": "STRING"}]), + node(3, "CLIPTextEncode", [("text", 21, "STRING")], [""])], + "links": [link(20, 3, 0, 2, 0, "*"), link(21, 2, 0, 1, 0, "STRING")]} + got = {s["id"]: s for s in wi.convert(w, S)["slots"]} + assert "w2" not in got diff --git a/core/tests/test_workflow_import_samples.py b/core/tests/test_workflow_import_samples.py new file mode 100644 index 00000000..49cbd3ec --- /dev/null +++ b/core/tests/test_workflow_import_samples.py @@ -0,0 +1,94 @@ +"""Workflow import against ComfyUI's own sample workflows and its real nodes. + +The unit tests in test_workflow_import.py use stand-in nodes. This file reads the workflows ComfyUI ships as +templates (the `comfyui-workflow-templates` package, part of ComfyUI's own requirements) and converts each with the +real node schemas, so a node, a dynamic input or a widget layout the stand-ins do not imagine is met here. + +What it checks is structure: every node is known, every wire fits, every typed-in value is in range. A model or +other file the template names is not installed on a test machine, so a "which is not one of" about a file is the +one thing allowed through. +""" + +import json +import re +from pathlib import Path + +import pytest + +pytest.importorskip("comfyui_workflow_templates_json") + +from core import graph, workflow_import # noqa: E402 + +FILE_WORDS = re.compile(r"\.(safetensors|png|jpg|jpeg|mp4|webp|ckpt|pt|gguf|wav|mp3|flac|mov|glb|bin|pth|json)'|pixel_space|kitchen") +CLOUD = re.compile(r"Gemini|Grok|ByteDance|Kling|OpenAI|Eleven|Seedream") # partner nodes: dynamic forms of their own + +VIDEO = re.compile(r"^(video_|ltx|template_ltx|text_to_video|image_to_video|hunyuan_video|wan|template_image_speech)") + + +@pytest.fixture(scope="module") +def schemas(comfyui): + import asyncio + import nodes as comfy_nodes + + async def load(): + await comfy_nodes.init_extra_nodes(init_custom_nodes=False) + await comfy_nodes.load_custom_node(str(Path(__file__).resolve().parents[2]), module_parent="custom_nodes") + + asyncio.run(load()) + return graph.from_comfyui() + + +def _templates(): + import comfyui_workflow_templates_json as pack + folder = Path(pack.__file__).parent / "templates" + return sorted(p for p in folder.glob("*.json") if VIDEO.match(p.stem)) + + +def _convert(path, schemas): + wf = json.loads(path.read_text(encoding="utf-8")) + out = workflow_import.convert(wf, schemas) + return out, graph.build(out["slots"], schemas)[1] + + +def test_there_are_video_templates_to_test(): + assert len(_templates()) > 30 + + +@pytest.mark.parametrize("path", _templates(), ids=lambda p: p.stem) +def test_a_video_template_imports_and_every_node_wire_and_value_fits(path, schemas): + out, problems = _convert(path, schemas) + assert out["slots"], "nothing imported" + left = [p for p in problems if not FILE_WORDS.search(p) and not CLOUD.search(p)] + # video_ltx2_i2v_lora ships with a subgraph output whose link is missing from the file itself + if path.stem == "video_ltx2_i2v_lora": + left = [p for p in left if "SaveVideo needs 'video'" not in p] + assert not left, left[:5] + + +@pytest.mark.parametrize("name", ["video_minimax_h3_t2v", "video_minimax_h3_i2v", "video_minimax_h3_r2v", "video_ltx2_5_t2v", + "video_ltx2_3_i2v", "video_wan2_2_14B_t2v", "text_to_video_wan"]) +def test_the_prompt_and_seed_of_the_main_video_templates_get_a_control(name, schemas): + folder = _templates()[0].parent + out, _ = _convert(folder / f"{name}.json", schemas) + assert "prompt" in out["bound"] and "seed" in out["bound"], out["bound"] + + +def test_r2v_offers_exactly_the_inputs_the_node_builds(schemas): + got = set(schemas.inputs("MiniMaxH3ReferenceToVideo")) + names = lambda prefix, n: {f"{prefix}{i}" for i in range(n)} # noqa: E731 + assert {f"ref_images.ref_image_{i}" for i in range(9)} <= got and "ref_images.ref_image_9" not in got + assert {f"ref_videos.ref_video_{i}" for i in range(3)} <= got and "ref_videos.ref_video_3" not in got + assert {f"ref_video_audios.ref_video_audio_{i}" for i in range(3)} <= got and "ref_video_audios.ref_video_audio_3" not in got + assert {f"ref_audios.ref_audio_{i}" for i in range(3)} <= got and "ref_audios.ref_audio_3" not in got + assert names + + +def test_autogrow_inputs_of_other_nodes_are_named_as_comfyui_names_them(schemas): + assert "images.image0" in schemas.inputs("BatchImagesNode") # numbered from 0, whatever the minimum + assert "values.a" in schemas.inputs("ComfyMathExpression") and "values.z" in schemas.inputs("ComfyMathExpression") + assert schemas.wire_type("ComfyMathExpression", "values.a") == "FLOAT,INT,BOOLEAN" # an INT may feed it + assert "images.image0" in schemas.inputs("ReferenceMultiLatent") # the wrapped input is an optional one there + + +def test_a_widget_that_also_takes_a_wire_accepts_either_type(schemas): + assert "INT" in schemas.wire_type("LTXVEmptyLatentAudio", "frame_rate") diff --git a/core/workflow_import.py b/core/workflow_import.py index 99541fd5..df802a3a 100644 --- a/core/workflow_import.py +++ b/core/workflow_import.py @@ -72,10 +72,20 @@ def flatten_subgraphs(wf: dict, schemas) -> dict: counter = [max([int(r[0]) for r in _rows(wf)] or [0]) + 1] def promoted(defn): - """Which of the definition's inputs stand for an inner widget (what an instance's widgets_values lists).""" + """Which of the definition's inputs stand for an inner widget. An instance's widgets_values lists one value + for each input that is a typed-in kind (a number, text, a switch, a dropdown), in the definition's order. + A definition that does not type its inputs is read off the inner nodes' widget sockets instead.""" + entries = defn.get("inputs") or [] + if any(isinstance(e, dict) and e.get("type") for e in entries): + found = [] + for i, entry in enumerate(entries): + parts = comfy_types.members(entry.get("type")) if isinstance(entry, dict) else [] + if parts and all(part in comfy_types.PRIMITIVE for part in parts): + found.append(i) + return found by_id = {n.get("id"): n for n in defn.get("nodes") or []} found = [] - for i in range(len(defn.get("inputs") or [])): + for i in range(len(entries)): for link in defn.get("links") or []: if link.get("origin_id") == SG_IN and link.get("origin_slot") == i: sockets = (by_id.get(link.get("target_id")) or {}).get("inputs") or [] @@ -111,7 +121,7 @@ def flatten_subgraphs(wf: dict, schemas) -> dict: tgt = by_old.get(link.get("target_id")) sockets = (tgt or {}).get("inputs") or [] ts = link.get("target_slot") - name = ((sockets[ts].get("widget") or {}).get("name")) if tgt and ts is not None and ts < len(sockets) else None + name = ((sockets[ts].get("widget") or {}).get("name") or sockets[ts].get("name")) if tgt and ts is not None and ts < len(sockets) else None if name: tgt.setdefault("_values", {})[name] = val feeds = {i.get("name"): i.get("link") for i in inst.get("inputs") or []} @@ -162,24 +172,55 @@ def _widgets(schemas, node): which inputs are widgets (they carry a "widget" key); the node's schema is the fallback for older exports.""" types = schemas.inputs(node.get("type")) own = [i.get("name") for i in node.get("inputs") or [] if isinstance(i, dict) and i.get("widget")] - if own: + declared = [(n, t) for n, t in types.items() if t in comfy_types.PRIMITIVE] + # An export lists a widget's socket (with a "widget" key) when the node sits at the top level, but a node inside a + # subgraph definition keeps most of its widgets out of its sockets, and widgets_values still holds every widget in + # the node's declared order. So: a declared widget is read unless the export shows it as a plain socket (listed + # without a "widget" key); a node with dynamic inputs the schema cannot name keeps the export's own list. + sockets = {i.get("name") for i in node.get("inputs") or [] if isinstance(i, dict)} + if own and (not declared or any(n not in types for n in own)): return [(n, types.get(n)) for n in own] - return [(n, t) for n, t in types.items() if t in comfy_types.PRIMITIVE] + return [(n, t) for n, t in declared if n in own or n not in sockets] def _values(schemas, node): """The node's typed-in values by input name.""" raw = node.get("widgets_values") - if isinstance(raw, dict): + named = node.get("widgets_values_named") + if isinstance(named, dict) and named: # newer exports say which value is whose: no counting needed + known, shown = schemas.inputs(node.get("type")), schemas.shown_by(node.get("type")) + out = {k: v for k, v in named.items() if k in known} if known else dict(named) # not control_after_generate and the like + # a field a choice brings is named by its short name past the first level ("codec" for "format.codec") + for dotted, (parent, keys) in shown.items(): + short = dotted.rsplit(".", 1)[-1] + if dotted not in out and short in named and out.get(parent, named.get(parent)) in keys: + out[dotted] = named[short] + elif isinstance(raw, dict): out = {k: v for k, v in raw.items()} elif isinstance(raw, list): - out, i = {}, 0 - for name, kind in _widgets(schemas, node): + out, pos = {}, [0] + cls = node.get("type") + types, shown = schemas.inputs(cls), schemas.shown_by(cls) + + def eat(name, kind): + """One widget's value, its control_after_generate if a number has one, and (for a dynamic combo) + the widgets of the choice made, which sit right after it and nowhere else.""" + i = pos[0] if i >= len(raw): - break - out[name] = raw[i]; i += 1 - if i < len(raw) and kind in ("INT", "FLOAT") and raw[i] in CONTROL: + return + out[name] = value = raw[i] + i += 1 + if i < len(raw) and kind in ("INT", "FLOAT") and isinstance(raw[i], str) and raw[i] in CONTROL: i += 1 # control_after_generate rides after a number + pos[0] = i + if kind == comfy_types.COMBO: + for sub, (parent, keys) in shown.items(): + if parent == name and value in keys and types.get(sub) in comfy_types.PRIMITIVE: + eat(sub, types[sub]) + + for name, kind in _widgets(schemas, node): + if name not in shown: # a revealed field is read with the combo that reveals it + eat(name, kind) else: out = {} out.update(node.get("_values") or {}) @@ -214,6 +255,8 @@ def _from_ui(wf, schemas, notes): outs = n.get("outputs") or [] want = (outs[slot] or {}).get("type") if slot < len(outs) else None pick = next((i for i, s in enumerate(socks) if s.get("type") == want and s.get("link") is not None), None) + if pick is None: # an "any" socket takes whatever type the output has + pick = next((i for i, s in enumerate(socks) if s.get("type") == "*" and s.get("link") is not None), None) if pick is None: return None row = next((r for r in rows if r[3] == nid and r[4] == pick), None) @@ -319,11 +362,12 @@ def bind(slots: List[dict], schemas, notes: List[str] = None) -> Tuple[List[dict return s, name return None - for want, key, role in (("positive", "prompt", {"at": "generation.prompt", "label": "Prompt"}), - ("negative", "negative", {"at": "project.negative", "label": "Negative prompt"})): - for s in slots: + # A guider takes its one prompt as `conditioning` (BasicGuider); a sampler takes `positive` and `negative`. + for wants, prefer, key, role in ((("positive", "conditioning"), "positive", "prompt", {"at": "generation.prompt", "label": "Prompt"}), + (("negative",), "negative", "negative", {"at": "project.negative", "label": "Negative prompt"})): + for s, want in ((s, w) for s in slots for w in wants): link = (s["inputs"] or {}).get(want) - found = text_home(link[0], want) if is_link(link) and link[0] in by_id else None + found = text_home(link[0], prefer) if is_link(link) and link[0] in by_id else None if found: give(key, found[0], found[1], role) @@ -369,5 +413,43 @@ def convert(workflow: Any, schemas) -> dict: missing = sorted({s["node"] for s in slots if not schemas.of(s["node"])}) if missing: notes.append("Not installed here: " + ", ".join(missing) + ". Those nodes are kept but cannot run until their pack is installed" + ("" if api else "; their typed-in values could not be read from this export") + ".") + stuck = _fill_unset(slots, schemas) if not api else set() + slots = _prune_unused(slots, schemas, notes, stuck) slots, bound = bind(slots, schemas, notes) return {"slots": slots, "bound": bound, "notes": notes} + + +def _fill_unset(slots, schemas): + """A saved workflow has no value for a widget its node gained since (an older export): ComfyUI's frontend + shows the node's default there, so the run uses it too. Returns the slots left with a required widget that + only the ComfyUI page can fill (a viewer, a bounding-box editor): those cannot run from here.""" + stuck = set() + for s in slots: + for name in schemas.required(s["node"], s["inputs"]): + if name not in s["inputs"]: + has, value = schemas.default(s["node"], name) + if has: + s["inputs"][name] = value + elif schemas.frontend_only(s["node"], name): + stuck.add(s["id"]) + return stuck + + +def _prune_unused(slots, schemas, notes, stuck=()): + """Only what a save or preview needs, as ComfyUI itself runs: a node nothing downstream uses is left out, so + a dangling half-wired node cannot refuse the whole run.""" + by_id = {s["id"]: s for s in slots} + ends = [s["id"] for s in slots if schemas.is_output(s["node"]) and s["id"] not in stuck] # a viewer only the page can draw is no end + if not ends: + return slots + if any(n not in by_id[e]["inputs"] for e in ends for n in schemas.required(by_id[e]["node"], by_id[e]["inputs"])): + return slots # an end with nothing feeding it means a link was lost: pruning would only hide what is wrong + live, queue = set(ends), list(ends) + while queue: + for v in by_id[queue.pop()]["inputs"].values(): + if is_link(v) and v[0] in by_id and v[0] not in live: + live.add(v[0]); queue.append(v[0]) + dropped = [s for s in slots if s["id"] not in live] + if dropped: + notes.append("Left out because nothing that saves or shows a result uses them: " + ", ".join(sorted({f"{s['node']} #{s['id'][1:]}" for s in dropped})) + ".") + return [s for s in slots if s["id"] in live]