Files
olivv-cs-ComfyUI-FunPack/core/graph.py
T

691 lines
32 KiB
Python

"""The pipeline, as slots rather than as a core.
The pipeline is DATA: a list of slots, each naming a node and what feeds it.
Nothing here is a fixed chain, and that is the whole point.
v4 could not remove a built-in node from the pipeline, and the reason was
structural rather than an oversight: the wiring lived in code as a `CORE` dict
the user could not reach, so there was no entry to delete. Anything that only
exists as code cannot be replaced by something that is not that code.
So three operations, and each has to hold for a foundation nobody can break:
* **build** -- turn slots into the API prompt ComfyUI queues.
* **replace** -- point a slot at a different node. Allowed when the replacement
still produces what its consumers read; refused, with the reason, when it does
not. Anything producing the same result is as good as the built-in one.
* **remove** -- take a slot out and rewire what it fed to what fed it. Allowed
when the removed node has exactly one input matching each output somebody
consumes; refused otherwise, naming the ambiguity.
Refused and not silently skipped. v4 shipped a bypass that reported a problem
into a field nothing rendered, so toggling it did nothing and said nothing --
the user's word for it was "terrifying".
"""
import re
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
from . import comfy_types
Link = list # [slot_id, output_index]
def is_link(value: Any) -> bool:
"""A reference to another slot's output, as ComfyUI spells it."""
return (isinstance(value, (list, tuple)) and len(value) == 2
and isinstance(value[0], str) and isinstance(value[1], int))
class Schemas:
"""What core is allowed to know about a node: its input and output types.
Injected rather than imported so the whole of this file is testable without
ComfyUI, and so nothing here depends on how node classes happen to be built.
"""
def __init__(self, lookup: Callable[[str], Optional[dict]]):
self._lookup = lookup
def of(self, class_type: str) -> Optional[dict]:
return self._lookup(class_type)
def outputs(self, class_type: str) -> List[str]:
found = self.of(class_type)
return list(found["outputs"]) if found else []
def inputs(self, class_type: str) -> Dict[str, str]:
found = self.of(class_type)
return dict(found["inputs"]) if found else {}
def required(self, class_type: str) -> List[str]:
"""Inputs that must be filled. Absent from a schema means none are."""
found = self.of(class_type)
return list(found.get("required", [])) 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.
Absent from a schema means unknown, which is checked as no limit --
a schema that does not say is not a schema that says no.
"""
found = self.of(class_type)
return dict(found.get("limits", {})) if found else {}
def from_comfyui() -> Schemas:
"""Schemas read from whatever ComfyUI has registered, FunPack's and everyone
else's alike -- a slot may point at any node, not only ours."""
def lookup(class_type: str) -> Optional[dict]:
import nodes as comfy_nodes
node = comfy_nodes.NODE_CLASS_MAPPINGS.get(class_type)
if node is None:
return None
spec = node.INPUT_TYPES()
inputs, required, limits = {}, [], {}
for section in ("required", "optional"):
for name, declared in (spec.get(section) or {}).items():
kind, options = comfy_types.declared(declared)
# An Autogrow is not one input -- "ref_images" is really
# "ref_image_0".."ref_image_9", each a real socket, none of
# them named "ref_images" itself. Expanded here (see
# comfy_types' own docstring) so a wire naming one is checked
# against its own declared type, rather than refused as an
# input the node does not have.
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
continue
# A MatchType input's own type name says nothing about what it
# actually accepts -- resolve it to the real union (see
# comfy_types' own docstring) before anything below judges a
# wire against it, or a completely legal one is refused.
if kind == comfy_types.MATCH_TYPE:
resolved = comfy_types.match_type_union(
(options.get("template") or {}).get("allowed_types"))
if resolved:
kind = resolved
# The type as ComfyUI would WIRE it. A list of choices is a
# combo; so is the string "COMBO" and every V3 dynamic combo,
# 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 section == "required":
# Kept, because flattening the two sections loses the only
# thing that says a slot is incomplete -- and a slot missing
# a required input built clean and failed once queued.
required.append(name)
bounds = {}
if inputs[name] == comfy_types.COMBO:
bounds["choices"] = (list(kind) if isinstance(kind, (list, tuple))
else comfy_types.choices(options))
for edge in ("min", "max"):
if edge in options:
bounds[edge] = options[edge]
if bounds:
limits[name] = bounds
outputs = list(node.RETURN_TYPES)
# Same resolution, the output side: RETURN_TYPES carries no template of
# its own (it is the V1-compat tuple, plain strings only), so a real
# union has to come from the V3 schema's own output list -- read only
# when a MatchType actually shows up here, so a node with none of these
# never pays for a schema object it does not need.
if comfy_types.MATCH_TYPE in outputs and hasattr(node, "GET_SCHEMA"):
schema_outputs = node.GET_SCHEMA().outputs
for i, kind in enumerate(outputs):
if kind != comfy_types.MATCH_TYPE or i >= len(schema_outputs):
continue
template = getattr(schema_outputs[i], "template", None)
resolved = comfy_types.match_type_union(
getattr(template, "allowed_types", None))
if resolved:
outputs[i] = resolved
return {"inputs": inputs, "outputs": outputs,
"required": required, "limits": limits}
return Schemas(lookup)
def shape_problems(slots: Any) -> List[str]:
"""Whether this is even a pipeline, before anything indexes into it.
A payload arrives from HTTP, so "it parsed as JSON" is all that has been
established. Reading slot["id"] off a string raised a TypeError and the
route answered 500 in plain text -- which the app cannot turn into a reason
to show, and a refusal nobody can read is the failure this file exists to
avoid.
Separate from build() so a caller can refuse a malformed REQUEST before
reporting on an incomplete PIPELINE: those are different things and an app
showing them together says the wrong thing about both.
"""
if not isinstance(slots, (list, tuple)):
return [f"a pipeline is a list of slots, not {type(slots).__name__}"]
problems: List[str] = []
for index, slot in enumerate(slots):
where = f"slot {index}"
if not isinstance(slot, dict):
problems.append(f"{where} is {type(slot).__name__}, not an object")
continue
for key in ("id", "node"):
value = slot.get(key)
if not isinstance(value, str) or not value.strip():
problems.append(f"{where} has no {key}")
inputs = slot.get("inputs")
if inputs is not None and not isinstance(inputs, dict):
problems.append(f"{where}'s inputs must be an object, not {type(inputs).__name__}")
# Core does not know what a group MEANS -- it is the pipeline's own way
# of arranging itself, and any name is a valid one. It does know a name
# is text: a number here becomes a card titled `5`, and two slots
# grouped under `5` and `"5"` land in two cards reading the same.
group = slot.get("group")
if group is not None:
if not isinstance(group, str):
problems.append(f"{where}'s group must be a name, not {type(group).__name__}")
elif not group.strip():
problems.append(f"{where}'s group is blank; leave it out to leave the "
f"slot ungrouped")
problems.extend(_role_problems(slot.get("roles"), where))
return problems
def _role_problems(roles: Any, where: str) -> List[str]:
"""A role says one of this slot's inputs belongs on the app's surface.
Core knows nothing about what any of these NAMES mean -- which places exist
is the app's business, and an input's meaning is the node's. It knows the
shape: a place to put it and an input to put there, both named in text. A
role naming a place nothing offers is simply not shown, the same as a module
asking for a mount point nobody has.
"""
if roles is None:
return []
if not isinstance(roles, list):
return [f"{where}'s roles must be a list, not {type(roles).__name__}"]
problems = []
for index, role in enumerate(roles):
at = f"{where} role {index}"
if not isinstance(role, dict):
problems.append(f"{at} is {type(role).__name__}, not an object")
continue
for key in ("at", "input"):
value = role.get(key)
if not isinstance(value, str) or not value.strip():
problems.append(f"{at} has no {key}")
label = role.get("label")
if label is not None and not isinstance(label, str):
problems.append(f"{at}'s label must be text, not {type(label).__name__}")
return problems
def build(slots: Sequence[dict], schemas: Optional[Schemas] = None) -> Tuple[dict, List[str]]:
"""(prompt, problems) -- the graph ComfyUI queues, and why it should not.
Nothing is queued while `problems` is non-empty, so a wrong graph is refused
here rather than surfacing as a traceback from inside sampling.
"""
schemas = schemas or from_comfyui()
problems: List[str] = []
prompt: Dict[str, dict] = {}
# The shape first, because everything below indexes into a slot.
problems = shape_problems(slots)
if problems:
return {}, problems
# A set of ids cannot see a duplicate, and writing the prompt by id would
# then silently overwrite one slot with the other -- losing a node, or
# leaving one reading its own output. Refused, because a graph quietly
# missing a node is the shape of fault this whole file exists to prevent.
seen = set()
for slot in slots:
if slot["id"] in seen:
problems.append(f"{slot['id']!r} is used by more than one slot; ids are how "
f"a link names what feeds it, so they have to be unique")
seen.add(slot["id"])
if problems:
return {}, problems
known = seen
# Built once. Rebuilding it per link made validating a chain of N slots do N
# dict rebuilds, and this runs on the event loop: the cost of checking a
# pipeline is paid by every other request the server is holding.
by_id = slots_by_id(slots)
for slot in slots:
slot_id, class_type = slot["id"], slot["node"]
if schemas.of(class_type) is None:
problems.append(f"{slot_id}: there is no node called {class_type!r} installed")
continue
declared = schemas.inputs(class_type)
limits = schemas.limits(class_type)
inputs, refused_here = {}, set()
for name, value in (slot.get("inputs") or {}).items():
if name not in declared:
problems.append(f"{slot_id}: {class_type} has no input {name!r}")
continue
if is_link(value):
source, index = value
if source not in known:
problems.append(f"{slot_id}.{name} is fed by {source!r}, which is not in the pipeline")
continue
produced = schemas.outputs(by_id[source]["node"])
if index >= len(produced):
problems.append(f"{slot_id}.{name} reads output {index} of {source!r}, "
f"which has {len(produced)}")
continue
# A union on either side, not an exact string. `"IMAGE,MASK"`
# 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} "
f"gives {produced[index]}")
continue
inputs[name] = [source, index]
else:
value = as_choice(value, limits.get(name))
problem = unacceptable(name, value, limits.get(name))
if problem:
# An input the app writes under a name the person sees (Length, Width) is refused by that name.
label = next((r.get("label") for r in slot.get("roles") or []
if isinstance(r, dict) and r.get("input") == name and r.get("label")), None)
problems.append(unacceptable(label, value, limits.get(name)) if label else f"{slot_id}.{problem}")
refused_here.add(name)
continue
inputs[name] = value
for name in schemas.required(class_type):
if name not in inputs and name not in refused_here: # a refused value was just said; "nothing fills it" would be false
problems.append(f"{slot_id}: {class_type} needs {name!r} and nothing "
f"fills it")
prompt[slot_id] = {"class_type": class_type, "inputs": inputs}
problems.extend(cycles(prompt))
return prompt, problems
def as_choice(value: Any, bounds: Optional[dict]) -> Any:
"""A combo choice saved as text ("8") back to the choice it names (8). A web page's select hands back
text whatever it was given, and ComfyUI refuses "8" from a list holding the number 8."""
choices = (bounds or {}).get("choices")
if isinstance(value, str) and choices and value not in choices:
return next((c for c in choices if not isinstance(c, str) and str(c) == value), value)
return value
def unacceptable(name: str, value: Any, bounds: Optional[dict]) -> Optional[str]:
"""Why this literal will not do, or None.
ComfyUI checks these too, at /prompt -- and it refuses the WHOLE graph when
one value is wrong, which v4 hit with a LoRA file that had been deleted: a
feature that was switched off still stopped every generation. Refusing here
means the reason arrives naming the slot and the input, before anything is
queued, instead of as "Prompt outputs failed validation".
No bounds means unknown, and unknown is not a refusal.
"""
if not bounds:
return None
choices = bounds.get("choices")
if choices is not None:
# An empty list is a machine with no files of that kind, not a value
# that was checked and rejected -- the slot being unfilled is what gets
# reported, and saying "x is not one of: " helps nobody.
if choices and value not in choices:
offered = ", ".join(repr(c) for c in choices[:6])
more = "" if len(choices) <= 6 else f", and {len(choices) - 6} more"
return (f"{name} is {value!r}, which is not one of: {offered}{more}")
return None
# Only a number has bounds, and only a number is compared to them. A bool is
# an int in Python and would sail through a naive comparison as 0 or 1.
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
if "min" in bounds and value < bounds["min"]:
return f"{name} is {value}, below the smallest {bounds['min']} it takes"
if "max" in bounds and value > bounds["max"]:
return f"{name} is {value}, above the largest {bounds['max']} it takes"
return None
def place(slots: Sequence[dict], value: Any,
sinks: Sequence[dict]) -> Tuple[List[dict], int]:
"""Write one value into every slot a sink names. Returns (slots, how many).
This is how what the UI holds reaches the graph, and it is deliberately
ignorant of what the value MEANS: a module says "a node of this class takes
it, under this input name" and core does exactly that. Core naming the node
itself would make one module's node privileged, which is the thing the whole
announcement contract exists to prevent.
The count is returned rather than a message, because only the caller knows
what the value was -- and a value with nowhere to go has to be SAID. Writing
it nowhere and reporting success is how a settings panel becomes decoration.
"""
out = [dict(slot, inputs=dict(slot.get("inputs") or {})) for slot in slots]
wanted = [(sink.get("node"), sink.get("input")) for sink in sinks
if isinstance(sink, dict) and sink.get("node") and sink.get("input")]
placed = 0
for slot in out:
for class_type, name in wanted:
if slot.get("node") == class_type:
slot["inputs"][name] = value
placed += 1
return out, placed
def override(slots: Sequence[dict], edits: Any) -> Tuple[List[dict], List[str]]:
"""Values the app is holding for named inputs: {slot_id: {input: value}}.
Addressed rather than merged: the app sends what it is showing, not a whole
pipeline, so a control on the main window and the pipeline window cannot
disagree about the slots between them -- one of them owns the structure and
the other owns a value in it.
An address that is not there is REFUSED, never written and hoped for. A
prompt typed into a box for a slot somebody removed has gone nowhere, and a
run that starts anyway is a run without the prompt in it.
"""
if not isinstance(edits, dict):
return list(slots), [f"overrides are an object keyed by slot id, "
f"not a {type(edits).__name__}"]
out = [dict(slot, inputs=dict(slot.get("inputs") or {})) for slot in slots]
by_id = {slot.get("id"): slot for slot in out}
problems: List[str] = []
for slot_id, values in edits.items():
slot = by_id.get(slot_id)
if slot is None:
problems.append(f"{slot_id}: there is no such slot to put a value in")
continue
if not isinstance(values, dict):
problems.append(f"{slot_id}: its values are an object keyed by input name, "
f"not a {type(values).__name__}")
continue
for name, value in values.items():
# A link is the graph's structure, and a value written over one
# silently unwires a node. Whatever meant to do that has to say so
# somewhere it can be seen.
if is_link(slot["inputs"].get(name)):
problems.append(f"{slot_id}.{name} is fed by another node, so a value "
f"cannot be put in it")
continue
slot["inputs"][name] = value
return out, problems
def wire(slots: Sequence[dict], to_slot_id: str, to_input: str,
from_slot_id: str, from_output: int,
schemas: Optional[Schemas] = None) -> Tuple[List[dict], List[str]]:
"""Feed one slot's input from another slot's output.
Not restricted to socket-typed inputs: ComfyUI's own Primitive nodes
(PrimitiveInt, PrimitiveFloat...) exist to feed a widget input from a wire,
which is how several nodes end up sharing one value edited in one place --
v4 called this a "linked input" and froze the set once made. Nothing here
freezes: a link is an ordinary entry in a slot's `inputs`, so adding a
fourth consumer to an existing Primitive later is just wiring one more
slot to it, not editing a group that has to already know about it.
Refused, not silently accepted, for the same reasons build() would refuse
the resulting graph -- a slot that does not exist, an input the node does
not declare, a type mismatch, or a link that would close a cycle. Checked
here so a bad wire is never written into the pipeline at all.
"""
schemas = schemas or from_comfyui()
by_id = slots_by_id(slots)
if to_slot_id not in by_id:
return list(slots), [f"there is no slot called {to_slot_id!r}"]
if from_slot_id not in by_id:
return list(slots), [f"there is no slot called {from_slot_id!r}"]
to_node = by_id[to_slot_id]["node"]
declared = schemas.inputs(to_node)
if to_input not in declared:
return list(slots), [f"{to_node} has no input {to_input!r}"]
produced = schemas.outputs(by_id[from_slot_id]["node"])
if from_output < 0 or from_output >= len(produced):
return list(slots), [f"{by_id[from_slot_id]['node']} has {len(produced)} "
f"output(s); {from_output} does not exist"]
wanted, given = declared[to_input], produced[from_output]
if not comfy_types.accepts(wanted, given):
return list(slots), [f"{to_slot_id}.{to_input} wants {wanted} but "
f"{from_slot_id!r} gives {given}"]
out = [dict(slot, inputs=dict(slot.get("inputs") or {})) for slot in slots]
slots_by_id(out)[to_slot_id]["inputs"][to_input] = [from_slot_id, from_output]
# The same refusal build() would make at prompt time, made here instead --
# a wire that closes a loop is wrong the moment it is drawn, not once
# queued.
prompt = {s["id"]: {"inputs": s.get("inputs") or {}} for s in out}
problems = cycles(prompt)
if problems:
return list(slots), problems
return out, []
def unwire(slots: Sequence[dict], to_slot_id: str, to_input: str) -> Tuple[List[dict], List[str]]:
"""Remove whatever feeds one input, leaving it unset.
Not a value written over the link -- override() already refuses that, on
purpose, because a value silently unwiring a node is a structural change
nobody asked for. This is that same structural change, asked for.
"""
by_id = slots_by_id(slots)
if to_slot_id not in by_id:
return list(slots), [f"there is no slot called {to_slot_id!r}"]
out = [dict(slot, inputs=dict(slot.get("inputs") or {})) for slot in slots]
slots_by_id(out)[to_slot_id]["inputs"].pop(to_input, None)
return out, []
def cycles(prompt: Dict[str, dict]) -> List[str]:
"""Slots that feed each other, named in the order the loop runs.
Every other refusal here is local -- a type, a name, an index -- and a cycle
is the one wrong graph that is correct at every link and impossible as a
whole. ComfyUI does catch it, but at execution: the run is queued, accepted,
and dies inside the executor with a message the app has no reason to be
reading, which is the "refused somewhere nobody looks" shape this file
exists to prevent.
Walked with an explicit stack rather than recursion. Slots arrive from a
request, so how many there are is the caller's choice, and a long enough
chain would turn a refusal into a RecursionError -- a 500 instead of a
reason.
"""
feeds = {slot_id: [value[0] for value in node["inputs"].values() if is_link(value)]
for slot_id, node in prompt.items()}
cleared: set = set()
reported: set = set()
found: List[str] = []
for root in prompt:
if root in cleared:
continue
stack: List[List] = [[root, 0]] # [slot, index of its next feed]
path: List[str] = [root] # what we are currently inside
on_path = {root}
while stack:
slot_id, i = stack[-1]
sources = feeds.get(slot_id, ())
if i >= len(sources):
cleared.add(slot_id)
on_path.discard(slot_id)
path.pop()
stack.pop()
continue
stack[-1][1] = i + 1
source = sources[i]
if source in on_path:
loop = path[path.index(source):]
# One loop, reported once, however many links close it.
key = frozenset(loop)
if key not in reported:
reported.add(key)
found.append(f"{' feeds '.join(reversed(loop))} feeds {loop[-1]}: "
f"a slot cannot end up feeding itself")
elif source not in cleared:
stack.append([source, 0])
path.append(source)
on_path.add(source)
return found
def slots_by_id(slots: Sequence[dict]) -> Dict[str, dict]:
return {slot["id"]: slot for slot in slots}
def consumers(slots: Sequence[dict], slot_id: str) -> List[Tuple[str, str, int]]:
"""Every (consumer_id, input_name, output_index) reading this slot."""
found = []
for slot in slots:
for name, value in (slot.get("inputs") or {}).items():
if is_link(value) and value[0] == slot_id:
found.append((slot["id"], name, value[1]))
return found
def add(slots: Sequence[dict], class_type: str, group: Optional[str] = None,
schemas: Optional[Schemas] = None) -> Tuple[List[dict], List[str]]:
"""Put a new slot at the end, holding `class_type`, with nothing set and nothing wired.
The id is made from the node's name and is never one already taken, so adding the same
node twice gives two slots, not a collision. Wiring it in is a separate, checked step.
"""
schemas = schemas or from_comfyui()
if schemas.of(class_type) is None:
return list(slots), [f"there is no node called {class_type!r} installed"]
if group is not None and (not isinstance(group, str) or not group.strip()):
return list(slots), ["a group is a name; leave it out to leave the slot ungrouped"]
taken = {slot["id"] for slot in slots}
stem = re.sub(r"[^a-z0-9]+", "_", class_type.lower()).strip("_") or "node"
n = 1
new_id = stem
while new_id in taken:
n += 1
new_id = f"{stem}_{n}"
slot = {"id": new_id, "node": class_type, "inputs": {}}
if group:
slot["group"] = group.strip()
return [dict(s) for s in slots] + [slot], []
def replace(slots: Sequence[dict], slot_id: str, class_type: str,
schemas: Optional[Schemas] = None) -> Tuple[List[dict], List[str]]:
"""Point a slot at a different node.
Allowed when the replacement still produces what its consumers read. The
built-in nodes have no special standing: anything producing the same result
is as good, which is what makes them replaceable rather than merely present.
"""
schemas = schemas or from_comfyui()
by_id = slots_by_id(slots)
if slot_id not in by_id:
return list(slots), [f"there is no slot called {slot_id!r}"]
if schemas.of(class_type) is None:
return list(slots), [f"there is no node called {class_type!r} installed"]
was = schemas.outputs(by_id[slot_id]["node"])
now = schemas.outputs(class_type)
problems = []
for consumer, name, index in consumers(slots, slot_id):
wanted = was[index] if index < len(was) else None
if index >= len(now):
problems.append(f"{consumer}.{name} reads output {index}, which {class_type} "
f"does not have")
elif wanted is not None and not comfy_types.accepts(wanted, now[index]):
problems.append(f"{consumer}.{name} needs {wanted} but {class_type} gives "
f"{now[index]} there")
if problems:
return list(slots), problems
# Inputs are NOT carried across: a different node has different inputs, and
# silently keeping the ones whose names happen to match is how a value ends
# up meaning something else.
# The slot's roles say which of its inputs the app writes to (the prompt box, the source image,
# the length). Only the ones the new node actually has can still be written: a role left
# pointing at an input that is gone would make every run refuse.
have = schemas.inputs(class_type)
def fits(slot):
roles = [r for r in (slot.get("roles") or []) if isinstance(r, dict) and r.get("input") in have]
out = dict(slot, node=class_type, inputs={})
out.pop("roles", None)
if roles:
out["roles"] = roles
return out
changed = [fits(slot) if slot["id"] == slot_id else dict(slot) for slot in slots]
return changed, []
def remove(slots: Sequence[dict], slot_id: str,
schemas: Optional[Schemas] = None) -> Tuple[List[dict], List[str]]:
"""Take a slot out, and rewire what it fed to what fed it.
The passthrough rule is ComfyUI's own for a bypassed node: for each output
somebody actually consumes, the removed node must have exactly one input of
that type to inherit from. Only CONSUMED outputs are checked -- v4 required
one for every output a class could theoretically produce, and a node with a
spare FLOAT output nobody wired became unremovable.
"""
schemas = schemas or from_comfyui()
by_id = slots_by_id(slots)
if slot_id not in by_id:
return list(slots), [f"there is no slot called {slot_id!r}"]
going = by_id[slot_id]
produced = schemas.outputs(going["node"])
declared = schemas.inputs(going["node"])
wired = going.get("inputs") or {}
replacement: Dict[int, Any] = {}
problems: List[str] = []
for index in sorted({index for _c, _n, index in consumers(slots, slot_id)}):
if index >= len(produced):
problems.append(f"something reads output {index} of {slot_id!r}, which does not exist")
continue
kind = produced[index]
matching = [name for name, wants in declared.items()
if comfy_types.accepts(wants, kind)
and name in wired and is_link(wired[name])]
if len(matching) == 1:
replacement[index] = wired[matching[0]]
elif not matching:
problems.append(f"removing {slot_id!r} would leave its {kind} output unfed: it has "
f"no wired {kind} input for whatever reads it to inherit")
else:
problems.append(f"removing {slot_id!r} is ambiguous: {kind} could come from "
f"{', '.join(sorted(matching))}")
if problems:
return list(slots), problems
changed = []
for slot in slots:
if slot["id"] == slot_id:
continue
inputs = dict(slot.get("inputs") or {})
for name, value in list(inputs.items()):
if is_link(value) and value[0] == slot_id:
inputs[name] = replacement[value[1]]
changed.append(dict(slot, inputs=inputs))
return changed, []