fix(import): bypassed/muted subgraphs, primitives through reroutes, one control to every node of a kind, no binding to a lookalike, scoped Set/Get, widget alignment from the export, 400 on bad shape

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 12:35:22 +03:00
co-authored by Claude Sonnet 5.5
parent a8f5163822
commit 972a5e97b4
6 changed files with 149 additions and 43 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ export const models = (app) => function mount() {
let got;
try { got = await api.importWorkflow(JSON.parse(await file.text())); } catch (err) { return say(err instanceof SyntaxError ? "That file is not valid JSON." : err.message); }
const names = { prompt: "Prompt", negative: "Negative prompt", seed: "Seed", width: "Width", height: "Height", frames: "Length", fps: "FPS", image: "Start picture" };
const lines = [`${got.slots.length} nodes.`, ...Object.entries(names).map(([k, label]) => `${label}: ${got.bound[k] ? "→ " + got.bound[k] : "not connected (set on the node itself)"}`), ...got.notes];
const lines = [`${got.slots.length} nodes.`, ...Object.entries(names).map(([k, label]) => `${label}: ${got.bound[k] ? "→ " + got.bound[k].join(", ") : "not connected (set on the node itself)"}`), ...got.notes];
if (!(await confirm("Use this workflow", `${lines.join("\n")}\n\nReplace the whole pipeline with it? Revert puts the old one back until you leave this page.`, "danger"))) return;
const r = await ps.restore({ slots: got.slots, removed: [], unwired: {} });
specs = {}; await describe(); say(r.refused.length ? `Not changed: ${r.refused[0]}` : "");
+3
View File
@@ -426,6 +426,8 @@ class Project:
#: What each scene last rendered ({media, inSec, promptId}) and the removed scenes whose
#: clip still previews: a reload shows the timeline the person left.
scene_renders: dict = field(default_factory=dict)
#: Earlier takes of a scene: {scene id: [{media, promptId, rating}]}, newest last; the one on the clip is scene_renders'.
scene_variants: dict = field(default_factory=dict)
scene_ghosts: list = field(default_factory=list)
#: Cut order (scene ids in TIMELINE order), empty = follow the plan; True once reordered by hand.
timeline_order: list = field(default_factory=list)
@@ -484,6 +486,7 @@ class Project:
continuity_settings=_blob(d.get("continuity_settings"), dict),
generation_meta=_blob(d.get("generation_meta"), dict),
scene_renders=_blob(d.get("scene_renders"), dict),
scene_variants=_blob(d.get("scene_variants"), dict),
scene_ghosts=_dicts(d.get("scene_ghosts")),
timeline_order=[i for i in _blob(d.get("timeline_order"), list) if is_token(i)],
timeline_manually_ordered=_bool(d.get("timeline_manually_ordered")),
+2 -2
View File
@@ -254,8 +254,8 @@ def register(routes, prefix=None):
except ValueError:
return web.json_response({"why": "the request was not JSON"}, status=400)
try:
return web.json_response(workflow_import.convert(body.get("workflow"), graph_mod.from_comfyui()))
except ValueError as exc:
return web.json_response(workflow_import.convert(body.get("workflow") if isinstance(body, dict) else None, graph_mod.from_comfyui()))
except (ValueError, TypeError, KeyError, AttributeError, IndexError) as exc: # an export that is not shaped like one
return web.json_response({"why": str(exc)}, status=400)
@routes.post(P + "/api/settings-card")
+1
View File
@@ -528,6 +528,7 @@ def test_a_comfyui_workflow_imports_into_slots_that_build_a_real_graph(server, r
status, body = _request(server, "POST", "/funpack/api/pipeline/import", {"workflow": wf})
assert status == 200
assert {"prompt", "negative", "seed", "width", "height"} <= set(body["bound"])
assert _request(server, "POST", "/funpack/api/pipeline/import", [1, 2])[0] == 400
prompt, problems = graph.build(body["slots"])
assert set(prompt) >= {"w1", "w2", "w3", "w4"}
assert not [p for p in problems if "w4" in p and "positive" in p]
+74 -3
View File
@@ -45,8 +45,8 @@ def test_ui_export_becomes_slots_with_values_and_links_stepping_over_reroutes_an
def test_the_apps_controls_find_their_nodes():
out = wi.convert(ui(), S)
assert out["bound"] == {"prompt": "CLIPTextEncode #1.text", "negative": "CLIPTextEncode #2.text", "seed": "KSampler #5.seed",
"width": "EmptyLatentImage #4.width", "height": "EmptyLatentImage #4.height"}
assert out["bound"] == {"prompt": ["CLIPTextEncode #1.text"], "negative": ["CLIPTextEncode #2.text"], "seed": ["KSampler #5.seed"],
"width": ["EmptyLatentImage #4.width"], "height": ["EmptyLatentImage #4.height"]}
roles = {s["id"]: s.get("roles") for s in out["slots"]}
assert roles["w1"] == [{"at": "generation.prompt", "label": "Prompt", "input": "text"}]
assert roles["w2"][0]["at"] == "project.negative"
@@ -59,7 +59,7 @@ def test_api_export_works_and_a_load_image_becomes_the_start_picture():
by = {s["id"]: s for s in out["slots"]}
assert by["w1"]["node"] == "FunPackLoadMedia" and by["w1"]["roles"][0]["at"] == "assets.source_image"
assert by["w2"]["inputs"]["image"] == ["w1", 0]
assert out["bound"]["image"] == "FunPackLoadMedia #1.media_id"
assert out["bound"]["image"] == ["FunPackLoadMedia #1.media_id"] and any("start picture" in n for n in out["notes"])
def test_a_load_image_whose_mask_is_used_is_left_alone():
@@ -119,3 +119,74 @@ def test_not_installed_nodes_are_kept_and_named_and_a_non_workflow_is_refused():
wi.convert({"hello": 1}, S)
with pytest.raises(ValueError):
wi.convert({"nodes": [node(1, "Note")], "links": []}, S)
NODES["Conditioning2"] = {"inputs": {"positive": "CONDITIONING", "negative": "CONDITIONING"}, "outputs": ["CONDITIONING", "CONDITIONING"]}
NODES["Int"] = {"inputs": {"value": "INT"}, "outputs": ["INT"]}
def test_a_bypassed_or_muted_subgraph_instance_does_not_run():
sub = {"id": "sg-1", "name": "Upscale", "nodes": [node(7, "ImageScale", [("image", None)], [64])],
"inputs": [{"name": "img", "type": "IMAGE"}], "outputs": [{"name": "out", "type": "IMAGE"}],
"links": [{"id": 1, "origin_id": wi.SG_IN, "origin_slot": 0, "target_id": 7, "target_slot": 0, "type": "IMAGE"},
{"id": 2, "origin_id": 7, "origin_slot": 0, "target_id": wi.SG_OUT, "target_slot": 0, "type": "IMAGE"}]}
for mode in (4, 2):
w = {"definitions": {"subgraphs": [sub]},
"nodes": [node(1, "LoadImage", [], ["a.png"]),
{"id": 2, "type": "sg-1", "mode": mode, "inputs": [{"name": "img", "type": "IMAGE", "link": 50}], "outputs": [{"type": "IMAGE"}], "widgets_values": []},
node(3, "VAEDecode", [("samples", 51)])],
"links": [link(50, 1, 0, 2, 0, "IMAGE"), link(51, 2, 0, 3, 0, "IMAGE")]}
got = {s["id"]: s for s in wi.convert(w, S)["slots"]}
assert not any(i.startswith("wsg") for i in got), "the disabled stage is not queued"
assert (got["w3"]["inputs"].get("samples") == ["w1", 0]) == (mode == 4) # bypass passes the picture on, mute leaves it unwired
def test_a_primitive_seed_reaches_its_node_through_a_reroute():
w = {"nodes": [node(1, "KSampler", [("seed", 60)], [0, "fixed", 5, 1.0, "euler"]), node(2, "Reroute", [("", 61)]), node(3, "PrimitiveNode", [], [1234])],
"links": [link(60, 2, 0, 1, 0), link(61, 3, 0, 2, 0)]}
assert {s["id"]: s for s in wi.convert(w, S)["slots"]}["w1"]["inputs"]["seed"] == 1234
def test_an_input_the_obvious_node_takes_from_a_wire_is_not_given_to_another_node_and_the_note_says_so():
api = {"1": {"class_type": "Int", "inputs": {"value": 512}},
"2": {"class_type": "EmptyLatentImage", "inputs": {"width": ["1", 0], "height": 512, "batch_size": 1}},
"3": {"class_type": "ImageScale", "inputs": {"image": ["9", 0], "width": 100}}}
out = wi.convert(api, S)
assert "width" not in out["bound"] and "height" in out["bound"]
assert any("Width" in n and "EmptyLatentImage" in n for n in out["notes"])
def test_one_control_drives_every_node_of_the_kind_and_each_sampler_gets_its_own_prompt():
api = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "hi", "clip": ["9", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "lo", "clip": ["9", 0]}},
"3": {"class_type": "KSampler", "inputs": {"positive": ["1", 0], "seed": 1, "steps": 1}},
"4": {"class_type": "KSampler", "inputs": {"positive": ["2", 0], "seed": 1, "steps": 1}}}
out = wi.convert(api, S)
assert out["bound"]["seed"] == ["KSampler #3.seed", "KSampler #4.seed"]
assert out["bound"]["prompt"] == ["CLIPTextEncode #1.text", "CLIPTextEncode #2.text"]
def test_positive_and_negative_are_not_swapped_when_one_node_carries_both():
api = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "neg", "clip": ["9", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos", "clip": ["9", 0]}},
"3": {"class_type": "Conditioning2", "inputs": {"negative": ["1", 0], "positive": ["2", 0]}},
"4": {"class_type": "KSampler", "inputs": {"positive": ["3", 0], "negative": ["3", 1], "seed": 1}}}
out = wi.convert(api, S)
assert out["bound"]["prompt"] == ["CLIPTextEncode #2.text"] and out["bound"]["negative"] == ["CLIPTextEncode #1.text"]
def test_widget_values_follow_the_sockets_that_are_widgets_not_the_schema_guess():
NODES["Odd"] = {"inputs": {"text": "STRING", "n": "INT"}, "outputs": []} # the schema says both are widgets...
n = {"id": 1, "type": "Odd", "inputs": [{"name": "text", "link": None}, {"name": "n", "link": None, "widget": {"name": "n"}}], "widgets_values": [4]}
assert wi.convert({"nodes": [n], "links": []}, S)["slots"][0]["inputs"] == {"n": 4} # ...the export says only n is
NODES["Plain"] = {"inputs": {"label": "STRING", "mode": "COMBO"}, "outputs": []}
p = {"id": 1, "type": "Plain", "inputs": [], "widgets_values": ["fixed", "x"]} # a string that merely looks like a seed control
assert wi.convert({"nodes": [p], "links": []}, S)["slots"][0]["inputs"] == {"label": "fixed", "mode": "x"}
def test_a_set_inside_one_subgraph_copy_is_not_read_by_the_other_copy():
w = {"nodes": [node("sg1_1", "EmptyLatentImage", [], [1, 1, 1]), node("sg1_2", "SetNode", [("L", 70)], ["lat"]), node("sg1_3", "GetNode", [], ["lat"]), node("sg1_4", "KSampler", [("latent_image", 71)], [1, "fixed", 1, 1.0, "euler"]),
node("sg2_1", "EmptyLatentImage", [], [2, 2, 1]), node("sg2_2", "SetNode", [("L", 72)], ["lat"]), node("sg2_3", "GetNode", [], ["lat"]), node("sg2_4", "KSampler", [("latent_image", 73)], [1, "fixed", 1, 1.0, "euler"])],
"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]
+68 -37
View File
@@ -91,7 +91,7 @@ def flatten_subgraphs(wf: dict, schemas) -> dict:
out_nodes, out_rows, again = [], list(rows), False
for inst in nodes:
defn = defs.get(str(inst.get("type")))
if not defn:
if not defn or inst.get("mode") in (2, 4): # a bypassed or muted instance stays one node: origin() passes it through or drops it
out_nodes.append(inst)
continue
again = True
@@ -151,8 +151,20 @@ def flatten_subgraphs(wf: dict, schemas) -> dict:
return wf
def _widget_names(schemas, cls):
return [n for n, t in schemas.inputs(cls).items() if t in comfy_types.PRIMITIVE]
def _scope(node_id):
"""The subgraph instance a flattened node came from ("sg2_7" -> "sg2"), "" for the top level."""
text = str(node_id)
return text.rsplit("_", 1)[0] if text.startswith("sg") and "_" in text else ""
def _widgets(schemas, node):
"""(name, type) of the node's widgets, in the order widgets_values lists them. The export's own sockets say
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:
return [(n, types.get(n)) for n in own]
return [(n, t) for n, t in types.items() if t in comfy_types.PRIMITIVE]
def _values(schemas, node):
@@ -162,11 +174,11 @@ def _values(schemas, node):
out = {k: v for k, v in raw.items()}
elif isinstance(raw, list):
out, i = {}, 0
for name in _widget_names(schemas, node.get("type")):
for name, kind in _widgets(schemas, node):
if i >= len(raw):
break
out[name] = raw[i]; i += 1
if i < len(raw) and isinstance(out[name], (int, float)) and not isinstance(out[name], bool) and raw[i] in CONTROL:
if i < len(raw) and kind in ("INT", "FLOAT") and raw[i] in CONTROL:
i += 1 # control_after_generate rides after a number
else:
out = {}
@@ -193,6 +205,8 @@ def _from_ui(wf, schemas, notes):
if n is None or (nid, slot) in seen:
return None
kind, mode = n.get("type"), n.get("mode", 0)
if mode == 2:
return None # muted: nothing comes out of it
if kind in PASS or kind == "SetNode" or (mode == 4 and kind not in VIRTUAL):
socks = n.get("inputs") or []
pick = 0
@@ -209,7 +223,9 @@ def _from_ui(wf, schemas, notes):
nid, slot = row[1], row[2]
elif kind == "GetNode":
name = (n.get("widgets_values") or [""])[0]
setter = next((m for m in nodes.values() if m.get("type") == "SetNode" and (m.get("widgets_values") or [""])[0] == name), None)
scope = _scope(nid) # a Set inside the same subgraph instance wins over one elsewhere
setters = [m for m in nodes.values() if m.get("type") == "SetNode" and m.get("mode", 0) != 2 and (m.get("widgets_values") or [""])[0] == name]
setter = next((m for m in setters if _scope(m.get("id")) == scope), setters[0] if setters else None)
if setter is None:
return None
row = next((r for r in rows if r[3] == setter.get("id") and r[4] == 0), None)
@@ -238,13 +254,13 @@ def _from_ui(wf, schemas, notes):
row = into.get((node.get("id"), i))
if row is None:
continue
src = nodes.get(row[1])
if src is not None and src.get("type") == "PrimitiveNode": # a Primitive hands over its value
hit = origin(row[1], row[2])
src = nodes.get(hit[0]) if hit else None
if src is not None and src.get("type") == "PrimitiveNode": # a Primitive hands over its value, even through a Reroute or Set/Get
vals = src.get("widgets_values") or []
if vals:
s["inputs"][name] = vals[0]
continue
hit = origin(row[1], row[2])
if hit and sid(hit[0]) in keep:
s["inputs"][name] = [sid(hit[0]), hit[1]]
else:
@@ -265,33 +281,41 @@ def _from_api(wf, notes):
# ── giving the app's controls a home ─────────────────────────────────────────
def _upstream(slots_by_id, start):
"""Slots feeding `start`, nearest first."""
def _upstream(slots_by_id, start, prefer=None):
"""Slots feeding `start`, nearest first. Through a node that takes both a positive and a negative, only
the side named `prefer` is followed, so one prompt is never read off the other's encoder."""
seen, order, queue = {start}, [], [start]
while queue:
cur = queue.pop(0)
for v in (slots_by_id[cur]["inputs"] or {}).values():
ins = slots_by_id[cur]["inputs"] or {}
if prefer and "positive" in ins and "negative" in ins and prefer in ins:
ins = {prefer: ins[prefer]}
for v in ins.values():
if is_link(v) and v[0] in slots_by_id and v[0] not in seen:
seen.add(v[0]); order.append(v[0]); queue.append(v[0])
return order
def bind(slots: List[dict], schemas) -> Tuple[List[dict], Dict[str, str]]:
"""(slots with roles added, what was bound: control -> 'node #id.input')."""
def bind(slots: List[dict], schemas, notes: List[str] = None) -> Tuple[List[dict], Dict[str, List[str]]]:
"""(slots with roles added, what was bound: control -> ['node #id.input', ...]).
A control goes to EVERY node of the kind that first takes it (two samplers both get the seed), but only when
that kind's input is typed in: if the obvious node takes it from another node, the control is left unbound and
a note says so, rather than landing on some other node that happens to share the input name."""
notes = notes if notes is not None else []
by_id = {s["id"]: s for s in slots}
bound: Dict[str, str] = {}
bound: Dict[str, List[str]] = {}
def give(control, slot, name, role):
slot.setdefault("roles", []).append(dict(role, input=name))
bound[control] = f"{slot['node']} #{slot['id'][1:]}.{name}"
bound.setdefault(control, []).append(f"{slot['node']} #{slot['id'][1:]}.{name}")
def text_home(slot_id):
"""The first unlinked text input on the node a sampler's conditioning comes from, or upstream of it."""
taken = {b.split("#")[-1] for b in bound.values()}
for sid_ in [slot_id, *_upstream(by_id, slot_id)]:
def text_home(slot_id, prefer):
taken = {x for v in bound.values() for x in v}
for sid_ in [slot_id, *_upstream(by_id, slot_id, prefer)]:
s = by_id[sid_]
for name in TEXT_NAMES:
if schemas.inputs(s["node"]).get(name) == "STRING" and not is_link(s["inputs"].get(name)) and f"{sid_[1:]}.{name}" not in taken:
if schemas.inputs(s["node"]).get(name) == "STRING" and not is_link(s["inputs"].get(name)) and f"{s['node']} #{sid_[1:]}.{name}" not in taken:
return s, name
return None
@@ -299,26 +323,32 @@ def bind(slots: List[dict], schemas) -> Tuple[List[dict], Dict[str, str]]:
("negative", "negative", {"at": "project.negative", "label": "Negative prompt"})):
for s in slots:
link = (s["inputs"] or {}).get(want)
found = text_home(link[0]) if is_link(link) and link[0] in by_id else None
found = text_home(link[0], want) if is_link(link) and link[0] in by_id else None
if found:
give(key, found[0], found[1], role)
break
seeds = [(s, n) for s in slots for n in s["inputs"] if "seed" in n.lower() and schemas.inputs(s["node"]).get(n) == "INT" and not is_link(s["inputs"][n])]
if seeds:
give("seed", seeds[0][0], seeds[0][1], {"at": "generation.seed", "label": "Seed"})
def numeric(control, label, names, kinds, role, match):
"""Bind `control` to every unlinked input of the first matching node class; say when that class takes it from a wire."""
first = next(((s, n) for s in slots for n in s["inputs"] if match(n, names) and schemas.inputs(s["node"]).get(n) in kinds), None)
if first is None:
return
cls = first[0]["node"]
mine = [(s, n) for s in slots if s["node"] == cls for n in s["inputs"] if match(n, names) and schemas.inputs(cls).get(n) in kinds]
free = [(s, n) for s, n in mine if not is_link(s["inputs"][n])]
for s, n in free:
give(control, s, n, dict(role, **({"drives": control} if control in ("frames", "fps") else {})))
if not free:
notes.append(f"{label}: the nodes that take it ({cls}) get it from other nodes, so the app's {label.lower()} control is not connected. Set it where it comes from.")
numeric("seed", "Seed", None, ("INT",), {"at": "generation.seed", "label": "Seed"}, lambda n, _: "seed" in n.lower())
for control, names in SIZE_NAMES.items():
for s in slots:
name = next((n for n in names if schemas.inputs(s["node"]).get(n) in ("INT", "FLOAT") and not is_link(s["inputs"].get(n))), None)
if name:
role = {"at": "project.video", "label": {"width": "Width", "height": "Height", "frames": "Length", "fps": "FPS"}[control]}
if control in ("frames", "fps"):
role["drives"] = control
give(control, s, name, role)
break
label = {"width": "Width", "height": "Height", "frames": "Length", "fps": "FPS"}[control]
numeric(control, label, names, ("INT", "FLOAT"), {"at": "project.video", "label": label}, lambda n, ns: n in ns)
for s in slots: # a LoadImage becomes the scene's start picture
if s["node"] == "LoadImage" and "image" not in bound:
if any(is_link(v) and v == [s["id"], 1] for o in slots for v in o["inputs"].values()):
continue # its mask is used: leave it as it is
notes.append(f"LoadImage #{s['id'][1:]} now takes the scene's start picture; the file it named is not kept. With no picture on the scene it cannot run.")
s["node"], s["inputs"] = "FunPackLoadMedia", {"media_id": ""}
give("image", s, "media_id", {"at": "assets.source_image"})
return slots, bound
@@ -327,7 +357,8 @@ def bind(slots: List[dict], schemas) -> Tuple[List[dict], Dict[str, str]]:
def convert(workflow: Any, schemas) -> dict:
"""{"slots", "bound", "notes"} for a workflow in either export format."""
notes: List[str] = []
if is_api(workflow):
api = is_api(workflow)
if api:
slots = _from_api(workflow, notes)
elif isinstance(workflow, dict) and isinstance(workflow.get("nodes"), list):
slots = _from_ui(workflow, schemas, notes)
@@ -337,6 +368,6 @@ def convert(workflow: Any, schemas) -> dict:
raise ValueError("the workflow has no nodes that run")
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.")
slots, bound = bind(slots, schemas)
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") + ".")
slots, bound = bind(slots, schemas, notes)
return {"slots": slots, "bound": bound, "notes": notes}