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
456 lines
24 KiB
Python
456 lines
24 KiB
Python
"""A ComfyUI workflow file, as pipeline slots.
|
|
|
|
Reads both exports: the API format ({id: {class_type, inputs}}) and the UI format (nodes + links). The UI
|
|
format is first made plain: subgraphs are opened, Set/Get pairs and Reroutes are stepped over, Primitive nodes
|
|
hand their value to what they feed, bypassed nodes pass their input through, muted ones are dropped.
|
|
|
|
Then `bind` gives the app's own controls (prompt, seed, size, length, FPS, start picture) a home on the nodes
|
|
that look like they take them. It guesses from names and wiring; what it did is returned so the person can see
|
|
it, and anything it could not place is simply not bound -- never bound to a wrong node quietly.
|
|
|
|
Pure data in, data out. `schemas` is core.graph.Schemas (what each node's inputs and outputs are).
|
|
"""
|
|
|
|
import copy
|
|
from typing import Any, Dict, List, Tuple
|
|
|
|
from . import comfy_types
|
|
from .graph import is_link
|
|
|
|
SKIP = {"Note", "MarkdownNote"}
|
|
VIRTUAL = {"SetNode", "GetNode"}
|
|
PASS = {"Reroute"}
|
|
CONTROL = {"fixed", "increment", "decrement", "randomize"} # the value the UI stores after a seed widget
|
|
SG_IN, SG_OUT = -10, -20 # a subgraph definition's own boundary nodes
|
|
MAX_DEPTH = 8
|
|
|
|
TEXT_NAMES = ("text", "prompt", "text_g", "string", "value")
|
|
SIZE_NAMES = {"width": ("width",), "height": ("height",),
|
|
"frames": ("length", "num_frames", "frame_count", "frames", "video_frames"),
|
|
"fps": ("frame_rate", "fps")}
|
|
|
|
|
|
def is_api(wf: Any) -> bool:
|
|
return isinstance(wf, dict) and not isinstance(wf.get("nodes"), list) and any(
|
|
isinstance(v, dict) and "class_type" in v for v in wf.values())
|
|
|
|
|
|
def sid(node_id: Any) -> str:
|
|
return f"w{node_id}"
|
|
|
|
|
|
# ── UI format, made plain ────────────────────────────────────────────────────
|
|
|
|
def _rows(wf):
|
|
return [r for r in (wf.get("links") or []) if isinstance(r, list) and len(r) >= 6]
|
|
|
|
|
|
def _group_of(node, groups):
|
|
"""Title of the smallest group whose box holds the node's centre, or ""."""
|
|
pos, size = node.get("pos") or [], node.get("size") or []
|
|
if len(pos) < 2:
|
|
return ""
|
|
try:
|
|
cx = float(pos[0]) + float(size[0] if size else 0) / 2
|
|
cy = float(pos[1]) + float(size[1] if len(size) > 1 else 0) / 2
|
|
best = None
|
|
for g in groups or []:
|
|
x, y, w, h = (float(v) for v in (g.get("bounding") or [])[:4])
|
|
if x <= cx <= x + w and y <= cy <= y + h and (best is None or w * h < best[0]):
|
|
best = (w * h, str(g.get("title") or ""))
|
|
return best[1] if best else ""
|
|
except (TypeError, ValueError):
|
|
return ""
|
|
|
|
|
|
def flatten_subgraphs(wf: dict, schemas) -> dict:
|
|
"""Every subgraph instance replaced by the nodes inside it, rewired; same shape back."""
|
|
defs = {str(d.get("id")): d for d in ((wf.get("definitions") or {}).get("subgraphs") or []) if isinstance(d, dict) and d.get("id")}
|
|
if not defs:
|
|
return wf
|
|
wf = copy.deepcopy(wf)
|
|
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. 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(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 []
|
|
ts = link.get("target_slot")
|
|
if ts is not None and ts < len(sockets) and sockets[ts].get("widget"):
|
|
found.append(i)
|
|
break
|
|
return found
|
|
|
|
def expand(nodes, rows, depth):
|
|
if depth > MAX_DEPTH:
|
|
return nodes, rows
|
|
out_nodes, out_rows, again = [], list(rows), False
|
|
for inst in nodes:
|
|
defn = defs.get(str(inst.get("type")))
|
|
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
|
|
pfx = f"sg{inst.get('id')}_"
|
|
inner = copy.deepcopy(defn.get("nodes") or [])
|
|
by_old = {n.get("id"): n for n in inner}
|
|
title = str(inst.get("title") or defn.get("name") or "Subgraph")
|
|
for n in inner:
|
|
n["id"] = f"{pfx}{n.get('id')}"
|
|
n.setdefault("_group", _group_of(n, defn.get("groups")) or title)
|
|
# the instance's own widget values beat the inner node's stale copy
|
|
values = inst.get("widgets_values")
|
|
if isinstance(values, list):
|
|
for idx, val in zip(promoted(defn), values):
|
|
for link in defn.get("links") or []:
|
|
if link.get("origin_id") == SG_IN and link.get("origin_slot") == idx:
|
|
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") 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 []}
|
|
by_row = {int(r[0]): r for r in out_rows}
|
|
names = [i.get("name") for i in defn.get("inputs") or []]
|
|
remap, out_src = {}, {}
|
|
for link in defn.get("links") or []:
|
|
src, ss, dst, ds = link.get("origin_id"), link.get("origin_slot"), link.get("target_id"), link.get("target_slot")
|
|
if dst == SG_OUT:
|
|
out_src[int(ds or 0)] = (f"{pfx}{src}", int(ss or 0))
|
|
remap[link.get("id")] = None
|
|
continue
|
|
if src == SG_IN:
|
|
feed = by_row.get(int(feeds.get(names[ss] if ss is not None and ss < len(names) else None) or -1))
|
|
if not feed:
|
|
remap[link.get("id")] = None
|
|
continue
|
|
src, ss = feed[1], feed[2]
|
|
else:
|
|
src = f"{pfx}{src}"
|
|
n = counter[0]; counter[0] += 1
|
|
remap[link.get("id")] = n
|
|
out_rows.append([n, src, int(ss or 0), f"{pfx}{dst}", int(ds or 0), link.get("type")])
|
|
for n in inner:
|
|
for sock in n.get("inputs") or []:
|
|
if sock.get("link") is not None:
|
|
sock["link"] = remap.get(sock["link"])
|
|
for row in out_rows:
|
|
if row[1] == inst.get("id") and int(row[2] or 0) in out_src:
|
|
row[1], row[2] = out_src[int(row[2] or 0)]
|
|
out_nodes.extend(inner)
|
|
return expand(out_nodes, out_rows, depth + 1) if again else (out_nodes, out_rows)
|
|
|
|
wf["nodes"], wf["links"] = expand(wf.get("nodes") or [], _rows(wf), 0)
|
|
live = {n.get("id") for n in wf["nodes"]}
|
|
wf["links"] = [r for r in wf["links"] if r[1] in live and r[3] in live]
|
|
return wf
|
|
|
|
|
|
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")]
|
|
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 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")
|
|
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, 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):
|
|
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 {})
|
|
return out
|
|
|
|
|
|
def _from_ui(wf, schemas, notes):
|
|
wf = flatten_subgraphs(wf, schemas)
|
|
nodes = {n.get("id"): n for n in wf.get("nodes") or []}
|
|
rows = _rows(wf)
|
|
into = {(r[3], r[4]): r for r in rows} # (node, input index) -> link row
|
|
by_in_name = {}
|
|
for r in rows:
|
|
node = nodes.get(r[3]) or {}
|
|
socks = node.get("inputs") or []
|
|
if 0 <= r[4] < len(socks):
|
|
by_in_name[(r[3], socks[r[4]].get("name"))] = r
|
|
|
|
def origin(nid, slot, seen=()):
|
|
"""Follow Reroutes, Set/Get pairs and bypassed nodes to the node that really makes this output."""
|
|
for _ in range(64):
|
|
n = nodes.get(nid)
|
|
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
|
|
if mode == 4 and kind not in PASS: # bypass: the input of the same type as this output
|
|
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)
|
|
if row is None:
|
|
return None
|
|
seen += ((nid, slot),)
|
|
nid, slot = row[1], row[2]
|
|
elif kind == "GetNode":
|
|
name = (n.get("widgets_values") or [""])[0]
|
|
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)
|
|
if row is None:
|
|
return None
|
|
seen += ((nid, slot),)
|
|
nid, slot = row[1], row[2]
|
|
else:
|
|
return (nid, slot) if mode != 2 else None
|
|
return None
|
|
|
|
groups = wf.get("groups") or []
|
|
slots, node_of = [], {}
|
|
for nid, n in nodes.items():
|
|
cls = n.get("type")
|
|
if cls in SKIP or cls in PASS or cls in VIRTUAL or cls == "PrimitiveNode" or n.get("mode") in (2, 4):
|
|
continue
|
|
inputs = _values(schemas, n)
|
|
slots.append({"id": sid(nid), "group": n.get("_group") or _group_of(n, groups) or "Workflow", "node": cls, "inputs": inputs})
|
|
node_of[sid(nid)] = n
|
|
keep = {s["id"]: s for s in slots}
|
|
for s in slots: # links
|
|
node = node_of[s["id"]]
|
|
for i, sock in enumerate(node.get("inputs") or []):
|
|
name = sock.get("name")
|
|
row = into.get((node.get("id"), i))
|
|
if row is None:
|
|
continue
|
|
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
|
|
if hit and sid(hit[0]) in keep:
|
|
s["inputs"][name] = [sid(hit[0]), hit[1]]
|
|
else:
|
|
s["inputs"].pop(name, None)
|
|
notes.append(f"{s['node']} #{node.get('id')}: its “{name}” input comes from a node that is muted, missing or unresolved, so it is left unconnected.")
|
|
return slots
|
|
|
|
|
|
def _from_api(wf, notes):
|
|
slots = []
|
|
for nid, n in wf.items():
|
|
if not isinstance(n, dict) or "class_type" not in n:
|
|
continue
|
|
inputs = {k: ([sid(v[0]), v[1]] if is_link(v) else v) for k, v in (n.get("inputs") or {}).items()}
|
|
slots.append({"id": sid(nid), "group": "Workflow", "node": n["class_type"], "inputs": inputs})
|
|
return slots
|
|
|
|
|
|
# ── giving the app's controls a home ─────────────────────────────────────────
|
|
|
|
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)
|
|
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, 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, List[str]] = {}
|
|
|
|
def give(control, slot, name, role):
|
|
slot.setdefault("roles", []).append(dict(role, input=name))
|
|
bound.setdefault(control, []).append(f"{slot['node']} #{slot['id'][1:]}.{name}")
|
|
|
|
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"{s['node']} #{sid_[1:]}.{name}" not in taken:
|
|
return s, name
|
|
return None
|
|
|
|
# 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], prefer) if is_link(link) and link[0] in by_id else None
|
|
if found:
|
|
give(key, found[0], found[1], role)
|
|
|
|
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():
|
|
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
|
|
|
|
|
|
def convert(workflow: Any, schemas) -> dict:
|
|
"""{"slots", "bound", "notes"} for a workflow in either export format."""
|
|
notes: List[str] = []
|
|
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)
|
|
else:
|
|
raise ValueError("this is not a ComfyUI workflow: expected the UI export (nodes and links) or the API export")
|
|
if not slots:
|
|
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" + ("" 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]
|