mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-18 20:38:15 +08:00
DynamicOutputs: tighter integration with DynamicCombo / DynamicSlot via FromInput
Lets dynamic-input nodes co-declare per-option outputs so the option list is a single source of truth for both inputs and outputs. * DynamicCombo.Option / DynamicSlot.Option gain an optional outputs=[...] list. * DynamicOutputs.FromInput(input_id) is a positional placeholder in outputs[] that resolves to the referenced input's active-option outputs at finalize time. DynamicCombo selects by literal value; DynamicSlot selects by the upstream slot's resolved type (or when=None when unlinked). * get_finalized_class_outputs gains schema_inputs / live_input_types and the TypeResolver / execute() compute live_input_types only when at least one FromInput → DynamicSlot is present. * Schema.validate(): FromInput must reference an existing DynamicCombo / DynamicSlot input, each input may be referenced at most once, and option output ids stay globally unique. * V1 info synthesizes the dynamic_outputs entry as kind='by_key' for combos and kind='by_slot' for slots, inlining the option outputs. Tests: combo + slot FromInput finalization, FromInput validation (missing / duplicate / id collision), and TypeResolver end-to-end picks for both kinds. Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
parent
188065c011
commit
cca4119fdf
@ -1141,20 +1141,46 @@ class Autogrow(ComfyTypeI):
|
||||
out_dict["dynamic_paths_default_value"][finalized_prefix] = DynamicPathsDefaultValue.EMPTY_DICT
|
||||
parse_class_inputs(out_dict, live_inputs, new_dict, curr_prefix, live_input_types)
|
||||
|
||||
def _validate_option_outputs(label: str, outputs: list[Output] | None) -> list[Output]:
|
||||
"""Validate option outputs once at construction; return [] for missing/empty."""
|
||||
if outputs is None:
|
||||
return []
|
||||
for o in outputs:
|
||||
if not isinstance(o, Output):
|
||||
raise ValueError(f"{label}: outputs must contain Output instances, got {o!r}")
|
||||
if o.id is None:
|
||||
raise ValueError(f"{label}: every output must declare an id")
|
||||
return outputs
|
||||
|
||||
|
||||
def _serialize_option_outputs(outputs: list[Output]) -> list[dict] | None:
|
||||
"""Inline-serialize option outputs for V1 info; None drops the field via prune_dict."""
|
||||
if not outputs:
|
||||
return None
|
||||
return [
|
||||
{"id": o.id, "type": o.get_io_type(), **o.as_dict()}
|
||||
for o in outputs
|
||||
]
|
||||
|
||||
|
||||
@comfytype(io_type="COMFY_DYNAMICCOMBO_V3")
|
||||
class DynamicCombo(ComfyTypeI):
|
||||
Type = dict[str, Any]
|
||||
|
||||
class Option:
|
||||
def __init__(self, key: str, inputs: list[Input]):
|
||||
def __init__(self, key: str, inputs: list[Input], outputs: list[Output] | None = None):
|
||||
self.key = key
|
||||
self.inputs = inputs
|
||||
# When this DynamicCombo input is referenced by DynamicOutputs.FromInput,
|
||||
# the active option's ``outputs`` are spliced into the finalized list.
|
||||
self.outputs = _validate_option_outputs("DynamicCombo.Option", outputs)
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
return prune_dict({
|
||||
"key": self.key,
|
||||
"inputs": create_input_dict_v1(self.inputs),
|
||||
}
|
||||
"outputs": _serialize_option_outputs(self.outputs),
|
||||
})
|
||||
|
||||
class Input(DynamicInput):
|
||||
def __init__(self, id: str, options: list[DynamicCombo.Option],
|
||||
@ -1224,9 +1250,12 @@ class DynamicSlot(ComfyTypeI):
|
||||
* a list of ComfyType classes (shared branch across multiple types)
|
||||
* a ``MultiType.Input`` instance (parsed via its ``.io_types``)
|
||||
"""
|
||||
def __init__(self, when: Any, inputs: list[Input]):
|
||||
def __init__(self, when: Any, inputs: list[Input] = None, outputs: list[Output] | None = None):
|
||||
self.when = when
|
||||
self.inputs = inputs
|
||||
self.inputs = inputs or []
|
||||
# When this DynamicSlot input is referenced by DynamicOutputs.FromInput,
|
||||
# the active option's ``outputs`` are spliced into the finalized list.
|
||||
self.outputs = _validate_option_outputs("DynamicSlot.Option", outputs)
|
||||
# ``_when_types`` is the ordered tuple of io_types (deterministic);
|
||||
# ``_when_set`` is the same content as a set for fast matching.
|
||||
self._when_types = self._normalize_when(when)
|
||||
@ -1272,10 +1301,16 @@ class DynamicSlot(ComfyTypeI):
|
||||
)
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
# ``when`` is preserved even when None (meaningful "unconnected" branch);
|
||||
# ``outputs`` is pruned when absent so existing serializations don't drift.
|
||||
d = {
|
||||
"when": None if self._when_types is None else list(self._when_types),
|
||||
"inputs": create_input_dict_v1(self.inputs),
|
||||
}
|
||||
outs = _serialize_option_outputs(self.outputs)
|
||||
if outs is not None:
|
||||
d["outputs"] = outs
|
||||
return d
|
||||
|
||||
class Input(DynamicInput):
|
||||
def __init__(self, id: str, options: list[DynamicSlot.Option],
|
||||
@ -1413,19 +1448,22 @@ class FinalizedOutputs:
|
||||
class DynamicOutputs:
|
||||
"""Container namespace for dynamic output group declarations.
|
||||
|
||||
Place an instance of one of the inner classes (e.g. ``DynamicOutputs.ByKey``)
|
||||
directly inside ``Schema.outputs`` to declare a set of outputs whose shape
|
||||
depends on prompt data. The active branch is chosen at execution time by
|
||||
Place an instance of one of the inner classes directly inside
|
||||
``Schema.outputs`` to declare a set of outputs whose shape depends on
|
||||
prompt data. The active branch is chosen at execution time by
|
||||
:py:func:`get_finalized_class_outputs`.
|
||||
|
||||
Current limitations (first slice):
|
||||
Forms:
|
||||
|
||||
* Only :py:class:`DynamicOutputs.ByKey` is implemented.
|
||||
* Selector must be a literal (Combo/string) input on the same node — links
|
||||
are rejected so finalization is a pure function of prompt-finalizable
|
||||
data.
|
||||
* Inactive options do not produce placeholder slots — downstream links to
|
||||
nonexistent finalized slots are rejected by validation.
|
||||
* :py:class:`DynamicOutputs.ByKey` — outputs are declared inline on the
|
||||
group and picked by the literal value of a same-node selector input.
|
||||
* :py:class:`DynamicOutputs.FromInput` — positional placeholder that
|
||||
reuses the per-option ``outputs=[…]`` declared on a
|
||||
:py:class:`DynamicCombo.Input` or :py:class:`DynamicSlot.Input`,
|
||||
keeping the option content single-sourced.
|
||||
|
||||
Inactive options do not produce placeholder slots — downstream links to
|
||||
nonexistent finalized slots are rejected by validation.
|
||||
"""
|
||||
|
||||
class Option:
|
||||
@ -1509,6 +1547,56 @@ class DynamicOutputs:
|
||||
return opt
|
||||
return None
|
||||
|
||||
class FromInput:
|
||||
"""Positional placeholder that pulls outputs from a referenced dynamic input.
|
||||
|
||||
Set ``input_id`` to the id of a :py:class:`DynamicCombo.Input` or
|
||||
:py:class:`DynamicSlot.Input` on the same node. At finalization time
|
||||
the active option of that input contributes its ``outputs=[…]`` here.
|
||||
"""
|
||||
|
||||
# ``kind`` is filled at finalize-time from the resolved source input
|
||||
# ("by_key" for DynamicCombo, "by_slot" for DynamicSlot).
|
||||
kind = "from_input"
|
||||
|
||||
def __init__(self, input_id: str):
|
||||
if not isinstance(input_id, str) or not input_id:
|
||||
raise ValueError("DynamicOutputs.FromInput: input_id must be a non-empty string")
|
||||
self.input_id = input_id
|
||||
|
||||
|
||||
def _from_input_as_dict(placeholder: DynamicOutputs.FromInput, source) -> dict[str, Any]:
|
||||
"""Synthesize a ``dynamic_outputs`` entry for a ``FromInput`` placeholder.
|
||||
|
||||
Inlines the outputs of each option so the frontend can render output pins
|
||||
without cross-referencing the input declaration. ``kind`` is ``"by_key"``
|
||||
for DynamicCombo (literal-driven) or ``"by_slot"`` for DynamicSlot
|
||||
(resolved-type-driven).
|
||||
"""
|
||||
if isinstance(source, DynamicCombo.Input):
|
||||
return {
|
||||
"id": placeholder.input_id,
|
||||
"kind": "by_key",
|
||||
"selector": source.id,
|
||||
"options": [
|
||||
{"key": opt.key, "outputs": _serialize_option_outputs(opt.outputs) or []}
|
||||
for opt in source.options
|
||||
],
|
||||
}
|
||||
# DynamicSlot.Input
|
||||
return {
|
||||
"id": placeholder.input_id,
|
||||
"kind": "by_slot",
|
||||
"selector": source.id,
|
||||
"options": [
|
||||
{
|
||||
"when": None if opt._when_types is None else list(opt._when_types),
|
||||
"outputs": _serialize_option_outputs(opt.outputs) or [],
|
||||
}
|
||||
for opt in source.options
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _output_metadata(o: Output) -> tuple[str, str, str, bool, str | None]:
|
||||
"""Return (id, return_type, display_name, is_output_list, tooltip) for an Output."""
|
||||
@ -1517,17 +1605,62 @@ def _output_metadata(o: Output) -> tuple[str, str, str, bool, str | None]:
|
||||
return o.id, rt, name, o.is_output_list, (o.tooltip if o.tooltip else None)
|
||||
|
||||
|
||||
def _select_from_input_outputs(
|
||||
placeholder: DynamicOutputs.FromInput,
|
||||
schema_inputs: list | None,
|
||||
prompt_inputs: dict[str, Any],
|
||||
live_input_types: dict[str, str] | None,
|
||||
) -> list[Output]:
|
||||
"""Return the active option's outputs for a ``FromInput`` placeholder."""
|
||||
if not schema_inputs:
|
||||
return []
|
||||
source = next(
|
||||
(i for i in schema_inputs
|
||||
if isinstance(i, (DynamicCombo.Input, DynamicSlot.Input)) and i.id == placeholder.input_id),
|
||||
None,
|
||||
)
|
||||
if source is None:
|
||||
return []
|
||||
if isinstance(source, DynamicCombo.Input):
|
||||
value = prompt_inputs.get(source.id)
|
||||
if isinstance(value, list): # link, not a literal — no branch finalizable
|
||||
return []
|
||||
for opt in source.options:
|
||||
if opt.key == value:
|
||||
return opt.outputs
|
||||
return []
|
||||
# DynamicSlot: matched by resolved upstream type (or when=None when unlinked).
|
||||
raw = prompt_inputs.get(source.id)
|
||||
has_link = isinstance(raw, list) and raw is not None
|
||||
if not has_link:
|
||||
for opt in source.options:
|
||||
if opt._when_types is None:
|
||||
return opt.outputs
|
||||
return []
|
||||
resolved = (live_input_types or {}).get(source.id, "*")
|
||||
resolved_set = {t.strip() for t in resolved.split(",")}
|
||||
for opt in source.options:
|
||||
if opt._when_types is None:
|
||||
continue
|
||||
if resolved_set & opt._when_set:
|
||||
return opt.outputs
|
||||
return []
|
||||
|
||||
|
||||
def get_finalized_class_outputs(
|
||||
schema_outputs: list,
|
||||
prompt_inputs: dict[str, Any] | None,
|
||||
live_input_types: dict[str, str] | None = None, # noqa: ARG001 — reserved for ByInputType
|
||||
schema_inputs: list | None = None,
|
||||
live_input_types: dict[str, str] | None = None,
|
||||
) -> FinalizedOutputs:
|
||||
"""Resolve the active output list for a node by expanding any
|
||||
:py:class:`DynamicOutputs` groups against ``prompt_inputs``.
|
||||
"""Resolve the active output list for a node.
|
||||
|
||||
Inactive options contribute no slots — downstream links to ranges that
|
||||
only existed under a different branch are caught by validation rather
|
||||
than silently filled with ``AnyType`` placeholders.
|
||||
Expands :py:class:`DynamicOutputs.ByKey` against ``prompt_inputs`` and
|
||||
:py:class:`DynamicOutputs.FromInput` against the matching
|
||||
:py:class:`DynamicCombo.Input` / :py:class:`DynamicSlot.Input` (using
|
||||
``live_input_types`` for the slot case). Inactive options contribute
|
||||
no slots — downstream links to nonexistent finalized slots are caught
|
||||
by validation rather than silently filled with ``AnyType`` placeholders.
|
||||
"""
|
||||
inputs = prompt_inputs or {}
|
||||
outputs: list[Output] = []
|
||||
@ -1536,27 +1669,27 @@ def get_finalized_class_outputs(
|
||||
names: list[str] = []
|
||||
is_list: list[bool] = []
|
||||
tooltips: list[str | None] = []
|
||||
|
||||
def append_output(o: Output):
|
||||
oid, rt, name, isl, tt = _output_metadata(o)
|
||||
outputs.append(o)
|
||||
ids.append(oid)
|
||||
types.append(rt)
|
||||
names.append(name)
|
||||
is_list.append(isl)
|
||||
tooltips.append(tt)
|
||||
|
||||
for entry in schema_outputs or []:
|
||||
if isinstance(entry, Output):
|
||||
oid, rt, name, isl, tt = _output_metadata(entry)
|
||||
outputs.append(entry)
|
||||
ids.append(oid)
|
||||
types.append(rt)
|
||||
names.append(name)
|
||||
is_list.append(isl)
|
||||
tooltips.append(tt)
|
||||
append_output(entry)
|
||||
elif isinstance(entry, DynamicOutputs.ByKey):
|
||||
selected = entry.select(inputs)
|
||||
if selected is None:
|
||||
continue
|
||||
for o in selected.outputs:
|
||||
oid, rt, name, isl, tt = _output_metadata(o)
|
||||
outputs.append(o)
|
||||
ids.append(oid)
|
||||
types.append(rt)
|
||||
names.append(name)
|
||||
is_list.append(isl)
|
||||
tooltips.append(tt)
|
||||
if selected is not None:
|
||||
for o in selected.outputs:
|
||||
append_output(o)
|
||||
elif isinstance(entry, DynamicOutputs.FromInput):
|
||||
for o in _select_from_input_outputs(entry, schema_inputs, inputs, live_input_types):
|
||||
append_output(o)
|
||||
# else: ignore unknown entries (future-proofing for new dynamic kinds)
|
||||
return FinalizedOutputs(
|
||||
outputs=outputs,
|
||||
@ -1942,15 +2075,25 @@ class Schema:
|
||||
'''Validate the schema:
|
||||
- verify ids on inputs and outputs are unique - both internally and in relation to each other
|
||||
- verify dynamic-output groups reference real inputs and have unique active ids
|
||||
- verify DynamicOutputs.FromInput placeholders reference real DynamicCombo / DynamicSlot inputs
|
||||
'''
|
||||
nested_inputs: list[Input] = []
|
||||
for input in self.inputs:
|
||||
if not isinstance(input, DynamicInput):
|
||||
nested_inputs.extend(input.get_all())
|
||||
input_ids = [i.id for i in nested_inputs]
|
||||
input_set = set(input_ids)
|
||||
dynamic_input_ids = {
|
||||
i.id for i in self.inputs if isinstance(i, (DynamicCombo.Input, DynamicSlot.Input))
|
||||
}
|
||||
from_input_refs = [
|
||||
o.input_id for o in self.outputs if isinstance(o, DynamicOutputs.FromInput)
|
||||
]
|
||||
|
||||
# ``output_ids`` covers every id that may ever appear in a finalized
|
||||
# output list — static outputs + every option's outputs across every
|
||||
# dynamic group — so collisions between branches are caught up front.
|
||||
# dynamic group + outputs from any dynamic-input referenced by a
|
||||
# FromInput — so collisions between branches are caught up front.
|
||||
output_ids: list[str] = []
|
||||
for o in self.outputs:
|
||||
if isinstance(o, Output):
|
||||
@ -1958,7 +2101,12 @@ class Schema:
|
||||
elif isinstance(o, DynamicOutputs.ByKey):
|
||||
for opt in o.options:
|
||||
output_ids.extend(child.id for child in opt.outputs)
|
||||
input_set = set(input_ids)
|
||||
referenced = set(from_input_refs)
|
||||
for inp in self.inputs:
|
||||
if isinstance(inp, (DynamicCombo.Input, DynamicSlot.Input)) and inp.id in referenced:
|
||||
for opt in inp.options:
|
||||
output_ids.extend(child.id for child in opt.outputs)
|
||||
|
||||
output_set = set(output_ids)
|
||||
issues: list[str] = []
|
||||
# verify ids are unique per list
|
||||
@ -1966,7 +2114,7 @@ class Schema:
|
||||
issues.append(f"Input ids must be unique, but {[item for item, count in Counter(input_ids).items() if count > 1]} are not.")
|
||||
if len(output_set) != len(output_ids):
|
||||
issues.append(f"Output ids must be unique, but {[item for item, count in Counter(output_ids).items() if count > 1]} are not.")
|
||||
# verify dynamic-output groups: unique group ids + selectors point at real inputs
|
||||
# ByKey: unique group ids + selectors point at real (literal) inputs
|
||||
group_ids: list[str] = []
|
||||
for o in self.outputs:
|
||||
if isinstance(o, DynamicOutputs.ByKey):
|
||||
@ -1981,6 +2129,18 @@ class Schema:
|
||||
f"DynamicOutputs group ids must be unique, but "
|
||||
f"{[i for i, c in Counter(group_ids).items() if c > 1]} are not."
|
||||
)
|
||||
# FromInput: each ref points at a DynamicCombo / DynamicSlot input and is unique
|
||||
for ref in from_input_refs:
|
||||
if ref not in dynamic_input_ids:
|
||||
issues.append(
|
||||
f"DynamicOutputs.FromInput(input_id={ref!r}) must reference a "
|
||||
f"DynamicCombo or DynamicSlot input on the schema."
|
||||
)
|
||||
if len(set(from_input_refs)) != len(from_input_refs):
|
||||
issues.append(
|
||||
f"DynamicOutputs.FromInput: each input may be referenced at most once, but "
|
||||
f"{[r for r, c in Counter(from_input_refs).items() if c > 1]} are referenced more than once."
|
||||
)
|
||||
if len(issues) > 0:
|
||||
raise ValueError("\n".join(issues))
|
||||
# validate inputs and outputs
|
||||
@ -1993,6 +2153,12 @@ class Schema:
|
||||
for opt in output.options:
|
||||
for child in opt.outputs:
|
||||
child.validate()
|
||||
# Validate option-outputs declared on dynamic inputs (whether referenced or not).
|
||||
for inp in self.inputs:
|
||||
if isinstance(inp, (DynamicCombo.Input, DynamicSlot.Input)):
|
||||
for opt in inp.options:
|
||||
for child in opt.outputs:
|
||||
child.validate()
|
||||
if self.price_badge is not None:
|
||||
self.price_badge.validate()
|
||||
|
||||
@ -2039,11 +2205,19 @@ class Schema:
|
||||
output_matchtypes = []
|
||||
any_matchtypes = False
|
||||
dynamic_outputs: list[dict[str, Any]] = []
|
||||
dynamic_inputs_by_id = {
|
||||
i.id: i for i in self.inputs if isinstance(i, (DynamicCombo.Input, DynamicSlot.Input))
|
||||
}
|
||||
if self.outputs:
|
||||
for o in self.outputs:
|
||||
if isinstance(o, DynamicOutputs.ByKey):
|
||||
dynamic_outputs.append(o.as_dict())
|
||||
continue
|
||||
if isinstance(o, DynamicOutputs.FromInput):
|
||||
source = dynamic_inputs_by_id.get(o.input_id)
|
||||
if source is not None:
|
||||
dynamic_outputs.append(_from_input_as_dict(o, source))
|
||||
continue
|
||||
output.append(o.io_type)
|
||||
output_is_list.append(o.is_output_list)
|
||||
output_name.append(o.display_name if o.display_name else o.io_type)
|
||||
|
||||
@ -145,7 +145,7 @@ class TypeResolver:
|
||||
# V3 schemas may declare DynamicOutputs groups whose active slots are
|
||||
# determined by prompt inputs and do not appear in class RETURN_TYPES;
|
||||
# resolve types against the finalized output list when present.
|
||||
finalized = self._get_finalized_outputs(node, class_def)
|
||||
finalized = self._get_finalized_outputs(node_id, node, class_def)
|
||||
if finalized is not None:
|
||||
if slot_idx < 0 or slot_idx >= len(finalized):
|
||||
return ANY_TYPE
|
||||
@ -232,18 +232,40 @@ class TypeResolver:
|
||||
f"MatchType template '{template_id}' has no bound concrete upstream input; defaulting to AnyType")
|
||||
return ANY_TYPE
|
||||
|
||||
def _get_finalized_outputs(self, node: dict | None, class_def) -> io.FinalizedOutputs | None:
|
||||
"""Return ``FinalizedOutputs`` for V3 nodes with DynamicOutputs groups, else ``None``."""
|
||||
def _get_finalized_outputs(self, node_id: str, node: dict | None, class_def) -> io.FinalizedOutputs | None:
|
||||
"""Return ``FinalizedOutputs`` for V3 nodes with DynamicOutputs groups, else ``None``.
|
||||
|
||||
``FromInput`` placeholders for ``DynamicSlot`` inputs also need
|
||||
``live_input_types`` (computed lazily from the resolver itself) so
|
||||
the active option can be picked by resolved upstream type.
|
||||
"""
|
||||
if not (isinstance(class_def, type) and issubclass(class_def, _ComfyNodeInternal)):
|
||||
return None
|
||||
try:
|
||||
schema = class_def.GET_SCHEMA()
|
||||
except Exception:
|
||||
return None
|
||||
if not any(isinstance(o, io.DynamicOutputs.ByKey) for o in schema.outputs):
|
||||
has_dynamic = any(
|
||||
isinstance(o, (io.DynamicOutputs.ByKey, io.DynamicOutputs.FromInput))
|
||||
for o in schema.outputs
|
||||
)
|
||||
if not has_dynamic:
|
||||
return None
|
||||
prompt_inputs = (node or {}).get("inputs", {}) or {}
|
||||
return io.get_finalized_class_outputs(schema.outputs, prompt_inputs)
|
||||
# live_input_types is only needed for FromInput → DynamicSlot; skip the
|
||||
# resolver pass otherwise to keep the hot path cheap.
|
||||
needs_live_types = any(
|
||||
isinstance(o, io.DynamicOutputs.FromInput)
|
||||
and any(isinstance(i, io.DynamicSlot.Input) and i.id == o.input_id for i in schema.inputs)
|
||||
for o in schema.outputs
|
||||
)
|
||||
live_input_types = self.compute_live_input_types(node_id) if needs_live_types else None
|
||||
return io.get_finalized_class_outputs(
|
||||
schema.outputs,
|
||||
prompt_inputs,
|
||||
schema_inputs=schema.inputs,
|
||||
live_input_types=live_input_types,
|
||||
)
|
||||
|
||||
def finalized_output_count(self, node_id: str) -> int:
|
||||
"""Number of active output slots on ``node_id``'s schema for the current prompt.
|
||||
@ -256,7 +278,7 @@ class TypeResolver:
|
||||
node, class_def = self._get_class_def_for_node(node_id)
|
||||
if class_def is None:
|
||||
return 0
|
||||
finalized = self._get_finalized_outputs(node, class_def)
|
||||
finalized = self._get_finalized_outputs(node_id, node, class_def)
|
||||
if finalized is not None:
|
||||
return len(finalized)
|
||||
try:
|
||||
@ -274,7 +296,7 @@ class TypeResolver:
|
||||
result = False
|
||||
node, class_def = self._get_class_def_for_node(node_id)
|
||||
if class_def is not None:
|
||||
finalized = self._get_finalized_outputs(node, class_def)
|
||||
finalized = self._get_finalized_outputs(node_id, node, class_def)
|
||||
if finalized is not None:
|
||||
if 0 <= slot_idx < len(finalized):
|
||||
result = bool(finalized.output_is_list[slot_idx])
|
||||
|
||||
21
execution.py
21
execution.py
@ -503,8 +503,25 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
|
||||
finalized_outputs = None
|
||||
if issubclass(class_def, _ComfyNodeInternal):
|
||||
schema = class_def.GET_SCHEMA()
|
||||
if any(isinstance(o, _io.DynamicOutputs.ByKey) for o in schema.outputs):
|
||||
finalized_outputs = _io.get_finalized_class_outputs(schema.outputs, inputs)
|
||||
has_dynamic = any(
|
||||
isinstance(o, (_io.DynamicOutputs.ByKey, _io.DynamicOutputs.FromInput))
|
||||
for o in schema.outputs
|
||||
)
|
||||
if has_dynamic:
|
||||
# live_input_types is only needed for FromInput → DynamicSlot finalization
|
||||
needs_live_types = any(
|
||||
isinstance(o, _io.DynamicOutputs.FromInput)
|
||||
and any(isinstance(i, _io.DynamicSlot.Input) and i.id == o.input_id for i in schema.inputs)
|
||||
for o in schema.outputs
|
||||
)
|
||||
live_input_types = None
|
||||
if needs_live_types and hasattr(dynprompt, "get_type_resolver"):
|
||||
live_input_types = dynprompt.get_type_resolver().compute_live_input_types(unique_id)
|
||||
finalized_outputs = _io.get_finalized_class_outputs(
|
||||
schema.outputs, inputs,
|
||||
schema_inputs=schema.inputs,
|
||||
live_input_types=live_input_types,
|
||||
)
|
||||
|
||||
input_data_all = None
|
||||
try:
|
||||
|
||||
@ -232,3 +232,212 @@ def test_schema_rejects_duplicate_dynamic_group_ids():
|
||||
|
||||
with pytest.raises(ValueError, match="DynamicOutputs group ids must be unique"):
|
||||
Dup.GET_SCHEMA()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DynamicOutputs.FromInput — DynamicCombo / DynamicSlot integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _combo_options_with_outputs():
|
||||
return [
|
||||
io.DynamicCombo.Option(
|
||||
key="image",
|
||||
inputs=[io.Image.Input("img")],
|
||||
outputs=[io.Image.Output("processed"), io.Mask.Output("alpha")],
|
||||
),
|
||||
io.DynamicCombo.Option(
|
||||
key="latent",
|
||||
inputs=[io.Latent.Input("lat")],
|
||||
outputs=[io.Latent.Output("denoised")],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _slot_options_with_outputs():
|
||||
return [
|
||||
io.DynamicSlot.Option(
|
||||
when=io.Image,
|
||||
outputs=[io.Image.Output("processed"), io.Mask.Output("alpha")],
|
||||
),
|
||||
io.DynamicSlot.Option(
|
||||
when=io.Latent,
|
||||
outputs=[io.Latent.Output("denoised")],
|
||||
),
|
||||
io.DynamicSlot.Option(
|
||||
when=None,
|
||||
inputs=[io.Int.Input("seed")],
|
||||
outputs=[],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_fromInput_finalizes_combo_branch():
|
||||
schema_inputs = [io.DynamicCombo.Input("mode", options=_combo_options_with_outputs())]
|
||||
schema_outputs = [io.String.Output("status"), io.DynamicOutputs.FromInput("mode")]
|
||||
finalized = io.get_finalized_class_outputs(
|
||||
schema_outputs, {"mode": "image"}, schema_inputs=schema_inputs,
|
||||
)
|
||||
assert finalized.output_ids == ["status", "processed", "alpha"]
|
||||
assert finalized.return_types == ["STRING", "IMAGE", "MASK"]
|
||||
|
||||
|
||||
def test_fromInput_unknown_combo_key_yields_only_static():
|
||||
schema_inputs = [io.DynamicCombo.Input("mode", options=_combo_options_with_outputs())]
|
||||
schema_outputs = [io.String.Output("status"), io.DynamicOutputs.FromInput("mode")]
|
||||
finalized = io.get_finalized_class_outputs(
|
||||
schema_outputs, {"mode": "missing"}, schema_inputs=schema_inputs,
|
||||
)
|
||||
assert finalized.output_ids == ["status"]
|
||||
|
||||
|
||||
def test_fromInput_finalizes_slot_by_resolved_type():
|
||||
schema_inputs = [io.DynamicSlot.Input("slot", options=_slot_options_with_outputs())]
|
||||
schema_outputs = [io.DynamicOutputs.FromInput("slot")]
|
||||
# Connected with resolved type IMAGE → first option matches
|
||||
finalized = io.get_finalized_class_outputs(
|
||||
schema_outputs,
|
||||
{"slot": ["upstream", 0]},
|
||||
schema_inputs=schema_inputs,
|
||||
live_input_types={"slot": "IMAGE"},
|
||||
)
|
||||
assert finalized.output_ids == ["processed", "alpha"]
|
||||
# Connected, LATENT branch
|
||||
finalized = io.get_finalized_class_outputs(
|
||||
schema_outputs,
|
||||
{"slot": ["upstream", 0]},
|
||||
schema_inputs=schema_inputs,
|
||||
live_input_types={"slot": "LATENT"},
|
||||
)
|
||||
assert finalized.output_ids == ["denoised"]
|
||||
|
||||
|
||||
def test_fromInput_slot_unconnected_uses_when_none_option():
|
||||
schema_inputs = [io.DynamicSlot.Input("slot", options=_slot_options_with_outputs())]
|
||||
schema_outputs = [io.DynamicOutputs.FromInput("slot")]
|
||||
finalized = io.get_finalized_class_outputs(
|
||||
schema_outputs, {}, schema_inputs=schema_inputs,
|
||||
)
|
||||
# when=None option declares outputs=[] → no active outputs
|
||||
assert finalized.output_ids == []
|
||||
|
||||
|
||||
def test_fromInput_slot_unmatched_type_yields_empty():
|
||||
"""Resolved upstream type with no matching option contributes no slots."""
|
||||
schema_inputs = [io.DynamicSlot.Input("slot", options=_slot_options_with_outputs())]
|
||||
schema_outputs = [io.DynamicOutputs.FromInput("slot")]
|
||||
finalized = io.get_finalized_class_outputs(
|
||||
schema_outputs,
|
||||
{"slot": ["upstream", 0]},
|
||||
schema_inputs=schema_inputs,
|
||||
live_input_types={"slot": "AUDIO"},
|
||||
)
|
||||
assert finalized.output_ids == []
|
||||
|
||||
|
||||
def test_schema_rejects_fromInput_pointing_at_missing_input():
|
||||
class BadRef(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="BadRef",
|
||||
inputs=[io.Combo.Input("mode", options=["a"])],
|
||||
outputs=[io.DynamicOutputs.FromInput("does_not_exist")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, **kwargs):
|
||||
return io.NodeOutput.from_named({})
|
||||
|
||||
with pytest.raises(ValueError, match="must reference a DynamicCombo or DynamicSlot"):
|
||||
BadRef.GET_SCHEMA()
|
||||
|
||||
|
||||
def test_schema_rejects_fromInput_referenced_more_than_once():
|
||||
class DupRef(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="DupRef",
|
||||
inputs=[io.DynamicCombo.Input("mode", options=_combo_options_with_outputs())],
|
||||
outputs=[io.DynamicOutputs.FromInput("mode"), io.DynamicOutputs.FromInput("mode")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, **kwargs):
|
||||
return io.NodeOutput.from_named({})
|
||||
|
||||
with pytest.raises(ValueError, match="referenced more than once"):
|
||||
DupRef.GET_SCHEMA()
|
||||
|
||||
|
||||
def test_schema_rejects_fromInput_output_collision_with_static():
|
||||
class Collision(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="Collision",
|
||||
inputs=[
|
||||
io.DynamicCombo.Input("mode", options=[
|
||||
io.DynamicCombo.Option(
|
||||
key="image", inputs=[io.Image.Input("img")],
|
||||
outputs=[io.Image.Output("processed")],
|
||||
),
|
||||
]),
|
||||
],
|
||||
outputs=[io.Image.Output("processed"), io.DynamicOutputs.FromInput("mode")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, **kwargs):
|
||||
return io.NodeOutput.from_named({"processed": None})
|
||||
|
||||
with pytest.raises(ValueError, match="Output ids must be unique"):
|
||||
Collision.GET_SCHEMA()
|
||||
|
||||
|
||||
def test_v1_info_emits_by_key_for_combo_fromInput():
|
||||
class N(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="ComboFI",
|
||||
inputs=[io.DynamicCombo.Input("mode", options=_combo_options_with_outputs())],
|
||||
outputs=[io.DynamicOutputs.FromInput("mode")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, **kwargs):
|
||||
return io.NodeOutput.from_named({})
|
||||
|
||||
N.GET_SCHEMA()
|
||||
info = N.SCHEMA.get_v1_info(N)
|
||||
assert info.dynamic_outputs is not None and len(info.dynamic_outputs) == 1
|
||||
entry = info.dynamic_outputs[0]
|
||||
assert entry["kind"] == "by_key"
|
||||
assert entry["selector"] == "mode"
|
||||
keys = {opt["key"] for opt in entry["options"]}
|
||||
assert keys == {"image", "latent"}
|
||||
|
||||
|
||||
def test_v1_info_emits_by_slot_for_slot_fromInput():
|
||||
class N(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SlotFI",
|
||||
inputs=[io.DynamicSlot.Input("slot", options=_slot_options_with_outputs())],
|
||||
outputs=[io.DynamicOutputs.FromInput("slot")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, **kwargs):
|
||||
return io.NodeOutput.from_named({})
|
||||
|
||||
N.GET_SCHEMA()
|
||||
info = N.SCHEMA.get_v1_info(N)
|
||||
assert info.dynamic_outputs is not None and len(info.dynamic_outputs) == 1
|
||||
entry = info.dynamic_outputs[0]
|
||||
assert entry["kind"] == "by_slot"
|
||||
assert entry["selector"] == "slot"
|
||||
whens = [opt["when"] for opt in entry["options"]]
|
||||
assert whens == [["IMAGE"], ["LATENT"], None]
|
||||
|
||||
@ -255,6 +255,108 @@ def test_blocker_sized_to_finalized_outputs_for_node_output():
|
||||
assert slot[0].message == "paused"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FromInput via DynamicCombo / DynamicSlot through the TypeResolver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_combo_fi_node():
|
||||
"""V3 node: DynamicCombo input drives output set via FromInput placeholder."""
|
||||
from comfy_api.latest import _io as io
|
||||
|
||||
class ComboFI(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="ComboFI",
|
||||
inputs=[
|
||||
io.DynamicCombo.Input("mode", options=[
|
||||
io.DynamicCombo.Option(
|
||||
key="image",
|
||||
inputs=[io.Image.Input("img")],
|
||||
outputs=[io.Image.Output("processed"), io.Mask.Output("alpha")],
|
||||
),
|
||||
io.DynamicCombo.Option(
|
||||
key="latent",
|
||||
inputs=[io.Latent.Input("lat")],
|
||||
outputs=[io.Latent.Output("denoised")],
|
||||
),
|
||||
]),
|
||||
],
|
||||
outputs=[io.DynamicOutputs.FromInput("mode")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, mode, **kwargs):
|
||||
if mode == "latent":
|
||||
return io.NodeOutput.from_named({"denoised": None})
|
||||
return io.NodeOutput.from_named({"processed": None, "alpha": None})
|
||||
|
||||
ComboFI.GET_SCHEMA()
|
||||
return ComboFI
|
||||
|
||||
|
||||
def _make_slot_fi_node():
|
||||
"""V3 node: DynamicSlot input drives output set via FromInput placeholder."""
|
||||
from comfy_api.latest import _io as io
|
||||
|
||||
class SlotFI(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SlotFI",
|
||||
inputs=[
|
||||
io.DynamicSlot.Input("slot", options=[
|
||||
io.DynamicSlot.Option(when=io.Image,
|
||||
outputs=[io.Image.Output("processed"), io.Mask.Output("alpha")]),
|
||||
io.DynamicSlot.Option(when=io.Latent,
|
||||
outputs=[io.Latent.Output("denoised")]),
|
||||
io.DynamicSlot.Option(when=None, outputs=[]),
|
||||
]),
|
||||
],
|
||||
outputs=[io.DynamicOutputs.FromInput("slot")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, **kwargs):
|
||||
return io.NodeOutput.from_named({})
|
||||
|
||||
SlotFI.GET_SCHEMA()
|
||||
return SlotFI
|
||||
|
||||
|
||||
def test_combo_fromInput_resolver_picks_branch(fake_nodes_module, TypeResolver):
|
||||
fake_nodes_module["ComboFI"] = _make_combo_fi_node()
|
||||
prompt = {
|
||||
"img": {"class_type": "ComboFI", "inputs": {"mode": "image"}},
|
||||
"lat": {"class_type": "ComboFI", "inputs": {"mode": "latent"}},
|
||||
}
|
||||
r = TypeResolver(prompt)
|
||||
assert r.resolve_output_type("img", 0) == "IMAGE"
|
||||
assert r.resolve_output_type("img", 1) == "MASK"
|
||||
assert r.resolve_output_type("lat", 0) == "LATENT"
|
||||
assert r.finalized_output_count("img") == 2
|
||||
assert r.finalized_output_count("lat") == 1
|
||||
|
||||
|
||||
def test_slot_fromInput_resolver_picks_by_resolved_type(fake_nodes_module, TypeResolver):
|
||||
fake_nodes_module["SlotFI"] = _make_slot_fi_node()
|
||||
fake_nodes_module["ImageSrc"] = _v1_node(("IMAGE",))
|
||||
fake_nodes_module["LatentSrc"] = _v1_node(("LATENT",))
|
||||
prompt = {
|
||||
"img_src": {"class_type": "ImageSrc", "inputs": {}},
|
||||
"lat_src": {"class_type": "LatentSrc", "inputs": {}},
|
||||
"image_consumer": {"class_type": "SlotFI", "inputs": {"slot": ["img_src", 0]}},
|
||||
"latent_consumer": {"class_type": "SlotFI", "inputs": {"slot": ["lat_src", 0]}},
|
||||
"unconnected": {"class_type": "SlotFI", "inputs": {}},
|
||||
}
|
||||
r = TypeResolver(prompt)
|
||||
assert r.resolve_output_type("image_consumer", 0) == "IMAGE"
|
||||
assert r.resolve_output_type("image_consumer", 1) == "MASK"
|
||||
assert r.resolve_output_type("latent_consumer", 0) == "LATENT"
|
||||
# Unconnected: when=None option declares outputs=[] → finalized count is 0.
|
||||
assert r.finalized_output_count("unconnected") == 0
|
||||
|
||||
|
||||
def test_bare_execution_blocker_sized_to_finalized_outputs():
|
||||
"""The non-NodeOutput path (bare ``ExecutionBlocker`` from V1-style returns)
|
||||
also sizes against the finalized list."""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user