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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WP6HMLLhRYcghAEXK4KLwK
This commit is contained in:
Claude
2026-10-21 04:10:12 +00:00
parent 24f946f43c
commit 98a1504a29
5 changed files with 343 additions and 28 deletions
+14 -7
View File
@@ -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]:
+47 -6
View File
@@ -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]
+91
View File
@@ -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
@@ -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")
+97 -15
View File
@@ -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]