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:
+14
-7
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user