DynamicOutputs: replace FromInput with BySlot; outputs always declared in Schema.outputs

ByKey already covered literal-driven dispatch (Combo/DynamicCombo/String);
add BySlot as the symmetric resolved-type-driven form (mirrors DynamicSlot).

Inputs no longer carry output declarations. DynamicCombo.Option / DynamicSlot.Option
go back to {key|when, inputs} only — outputs always live on the corresponding
DynamicOutputs entry in Schema.outputs. Validation enforces that ByKey option keys
align with the referenced DynamicCombo's keys and BySlot option 'when' types are a
subset of the referenced DynamicSlot's accepted types (including when=None).

Removes FromInput/_select_from_input_outputs/_from_input_as_dict and the
option-level output serialization helpers.

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:
Jedrzej Kosinski
2026-06-01 22:39:16 -07:00
parent cca4119fdf
commit 22d467dc84
5 changed files with 357 additions and 393 deletions

View File

@@ -1141,46 +1141,20 @@ 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], outputs: list[Output] | None = None):
def __init__(self, key: str, inputs: list[Input]):
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 prune_dict({
return {
"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],
@@ -1250,12 +1224,9 @@ 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] = None, outputs: list[Output] | None = None):
def __init__(self, when: Any, inputs: list[Input] = None):
self.when = when
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)
@@ -1301,16 +1272,10 @@ class DynamicSlot(ComfyTypeI):
)
def as_dict(self):
# ``when`` is preserved even when None (meaningful "unconnected" branch);
# ``outputs`` is pruned when absent so existing serializations don't drift.
d = {
return {
"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],
@@ -1455,48 +1420,56 @@ class DynamicOutputs:
Forms:
* :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.
* :py:class:`DynamicOutputs.ByKey` — selector is a literal input
(``Combo`` / ``DynamicCombo`` / ``String`` / etc.) and the active
option is chosen by direct value comparison against ``Option.key``.
* :py:class:`DynamicOutputs.BySlot` — selector is a
:py:class:`DynamicSlot.Input` and the active option is chosen by
the slot's resolved upstream type, matching ``Option.when``.
Inactive options do not produce placeholder slots — downstream links to
nonexistent finalized slots are rejected by validation.
"""
class Option:
"""One branch of outputs revealed when the selector matches ``key``."""
"""A branch of outputs for :py:class:`DynamicOutputs.ByKey`."""
def __init__(self, key: str, outputs: list[Output]):
if not isinstance(key, str) or not key:
raise ValueError("DynamicOutputs.Option: key must be a non-empty string")
for o in outputs:
if not isinstance(o, Output):
raise ValueError(
f"DynamicOutputs.Option: outputs must contain Output instances, got {o!r}"
)
if o.id is None:
raise ValueError("DynamicOutputs.Option: every output must declare an id")
_validate_outputs("DynamicOutputs.Option", outputs)
self.key = key
self.outputs = outputs
def as_dict(self):
return {"key": self.key, "outputs": _serialize_outputs(self.outputs)}
class SlotOption:
"""A branch of outputs for :py:class:`DynamicOutputs.BySlot`.
``when`` accepts the same shapes as :py:class:`DynamicSlot.Option.when`
(``None``, ``io.AnyType``, a single ComfyType class, a list of classes,
or a ``MultiType.Input``); validation requires it to be a subset of the
target ``DynamicSlot.Input``'s declared types.
"""
def __init__(self, when: Any, outputs: list[Output]):
_validate_outputs("DynamicOutputs.SlotOption", outputs)
self.when = when
self.outputs = outputs
self._when_types = DynamicSlot.Option._normalize_when(when)
self._when_set: frozenset[str] | None = (
None if self._when_types is None else frozenset(self._when_types)
)
def as_dict(self):
return {
"key": self.key,
"outputs": [
{
"id": o.id,
"type": o.get_io_type(),
**o.as_dict(),
}
for o in self.outputs
],
"when": None if self._when_types is None else list(self._when_types),
"outputs": _serialize_outputs(self.outputs),
}
class ByKey:
"""Active outputs are picked by the literal value of one of the node's inputs."""
"""Active outputs picked by the literal value of a same-node input."""
kind = "by_key"
@@ -1539,7 +1512,11 @@ class DynamicOutputs:
def select(self, prompt_inputs: dict[str, Any]) -> DynamicOutputs.Option | None:
"""Pick the matching ``Option`` for the prompt's selector value, or ``None``."""
value = prompt_inputs.get(self.selector)
# Links are ``[node_id, slot_idx]`` lists; for this slice we only accept literals.
# DynamicCombo wraps the literal in a nested dict {selector_id: {selector_id: key, ...}};
# unwrap so authors don't have to special-case it.
if isinstance(value, dict):
value = value.get(self.selector)
# Links are ``[node_id, slot_idx]`` lists; only literals finalize.
if isinstance(value, list):
return None
for opt in self.options:
@@ -1547,55 +1524,91 @@ class DynamicOutputs:
return opt
return None
class FromInput:
"""Positional placeholder that pulls outputs from a referenced dynamic input.
class BySlot:
"""Active outputs picked by the resolved upstream type of a DynamicSlot 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 = "by_slot"
# ``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, id: str, selector: str, options: list[DynamicOutputs.SlotOption]):
if not isinstance(id, str) or not id:
raise ValueError("DynamicOutputs.BySlot: id must be a non-empty string")
if not isinstance(selector, str) or not selector:
raise ValueError("DynamicOutputs.BySlot: selector must be a non-empty string input id")
if not options:
raise ValueError("DynamicOutputs.BySlot: at least one Option is required")
seen_types: set[str] = set()
seen_none = False
seen_ids: set[str] = set()
for opt in options:
if not isinstance(opt, DynamicOutputs.SlotOption):
raise ValueError(
f"DynamicOutputs.BySlot: options must be DynamicOutputs.SlotOption, got {opt!r}"
)
if opt._when_types is None:
if seen_none:
raise ValueError("DynamicOutputs.BySlot: only one option may declare when=None")
seen_none = True
else:
for t in opt._when_types:
if t in seen_types:
raise ValueError(
f"DynamicOutputs.BySlot: type {t!r} appears in more than one option; "
"each type must be claimed by exactly one option"
)
seen_types.add(t)
for o in opt.outputs:
if o.id in seen_ids:
raise ValueError(
f"DynamicOutputs.BySlot: output id {o.id!r} appears in more than one option; "
"each output id must be unique within the group"
)
seen_ids.add(o.id)
self.id = id
self.selector = selector
self.options = options
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 [],
def as_dict(self):
return {
"id": self.id,
"kind": self.kind,
"selector": self.selector,
"options": [opt.as_dict() for opt in self.options],
}
for opt in source.options
],
}
def select(self, prompt_inputs: dict[str, Any],
live_input_types: dict[str, str] | None) -> DynamicOutputs.SlotOption | None:
"""Pick the matching ``SlotOption`` for the slot's resolved type."""
raw = prompt_inputs.get(self.selector)
has_link = isinstance(raw, list) and raw is not None
if not has_link:
for opt in self.options:
if opt._when_types is None:
return opt
return None
resolved = (live_input_types or {}).get(self.selector, "*")
resolved_set = {t.strip() for t in resolved.split(",")}
for opt in self.options:
if opt._when_types is None:
continue
if resolved_set & opt._when_set:
return opt
return None
def _validate_outputs(label: str, outputs: list[Output]) -> None:
"""Sanity-check a list of outputs at option construction."""
if not outputs:
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")
def _serialize_outputs(outputs: list[Output]) -> list[dict]:
"""Inline-serialize outputs for V1 ``dynamic_outputs`` entries."""
return [{"id": o.id, "type": o.get_io_type(), **o.as_dict()} for o in outputs]
def _output_metadata(o: Output) -> tuple[str, str, str, bool, str | None]:
@@ -1605,62 +1618,18 @@ 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,
schema_inputs: list | None = None,
live_input_types: dict[str, str] | None = None,
) -> FinalizedOutputs:
"""Resolve the active output list for a node.
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.
:py:class:`DynamicOutputs.BySlot` against ``live_input_types``. 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.
"""
inputs = prompt_inputs or {}
outputs: list[Output] = []
@@ -1687,9 +1656,11 @@ def get_finalized_class_outputs(
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)
elif isinstance(entry, DynamicOutputs.BySlot):
selected = entry.select(inputs, live_input_types)
if selected is not None:
for o in selected.outputs:
append_output(o)
# else: ignore unknown entries (future-proofing for new dynamic kinds)
return FinalizedOutputs(
outputs=outputs,
@@ -2073,9 +2044,12 @@ class Schema:
def validate(self):
'''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
- input/output ids are unique within and across each other
- DynamicOutputs.ByKey / BySlot have unique group ids
- ByKey selectors reference a real input (literal alignment validated when
the selector is a DynamicCombo)
- BySlot selectors reference a real DynamicSlot input and every option's
``when`` is accepted by that slot
'''
nested_inputs: list[Input] = []
for input in self.inputs:
@@ -2083,82 +2057,96 @@ class Schema:
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)
]
combo_inputs = {i.id: i for i in self.inputs if isinstance(i, DynamicCombo.Input)}
slot_inputs = {i.id: i for i in self.inputs if isinstance(i, DynamicSlot.Input)}
# selector lookup covers both static (nested) inputs and DynamicCombo/Slot
# top-level inputs, since DynamicInput instances aren't included in nested_inputs.
all_selectable_ids = input_set | set(combo_inputs) | set(slot_inputs)
# ``output_ids`` covers every id that may ever appear in a finalized
# output list — static outputs + every option's outputs across every
# dynamic group + outputs from any dynamic-input referenced by a
# FromInput — so collisions between branches are caught up front.
# dynamic group — so collisions between branches are caught up front.
output_ids: list[str] = []
for o in self.outputs:
if isinstance(o, Output):
output_ids.append(o.id)
elif isinstance(o, DynamicOutputs.ByKey):
elif isinstance(o, (DynamicOutputs.ByKey, DynamicOutputs.BySlot)):
for opt in o.options:
output_ids.extend(child.id for child in opt.outputs)
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
if len(input_set) != len(input_ids):
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.")
# ByKey: unique group ids + selectors point at real (literal) inputs
group_ids: list[str] = []
for o in self.outputs:
if isinstance(o, DynamicOutputs.ByKey):
group_ids.append(o.id)
if o.selector not in input_set:
if o.selector not in all_selectable_ids:
issues.append(
f"DynamicOutputs.ByKey(id={o.id!r}) selector input {o.selector!r} "
f"does not exist on the schema."
)
# If the selector is a DynamicCombo, every option key must match one of its keys.
if o.selector in combo_inputs:
valid_keys = {opt.key for opt in combo_inputs[o.selector].options}
stray = [opt.key for opt in o.options if opt.key not in valid_keys]
if stray:
issues.append(
f"DynamicOutputs.ByKey(id={o.id!r}) option key(s) {stray!r} are not "
f"declared on DynamicCombo input {o.selector!r} (valid: {sorted(valid_keys)!r})."
)
elif isinstance(o, DynamicOutputs.BySlot):
group_ids.append(o.id)
if o.selector not in slot_inputs:
issues.append(
f"DynamicOutputs.BySlot(id={o.id!r}) selector {o.selector!r} must reference "
f"a DynamicSlot input on the schema."
)
else:
slot = slot_inputs[o.selector]
slot_accepted = set()
slot_has_none = False
for sopt in slot.options:
if sopt._when_types is None:
slot_has_none = True
else:
slot_accepted.update(sopt._when_types)
for opt in o.options:
if opt._when_types is None:
if not slot_has_none:
issues.append(
f"DynamicOutputs.BySlot(id={o.id!r}) option(when=None) requires "
f"DynamicSlot {o.selector!r} to declare a when=None option."
)
else:
stray_types = [t for t in opt._when_types if t not in slot_accepted]
if stray_types:
issues.append(
f"DynamicOutputs.BySlot(id={o.id!r}) option type(s) {stray_types!r} "
f"are not accepted by DynamicSlot {o.selector!r} "
f"(accepted: {sorted(slot_accepted)!r})."
)
if len(set(group_ids)) != len(group_ids):
issues.append(
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
# per-element validation
for input in self.inputs:
input.validate()
for output in self.outputs:
if isinstance(output, Output):
output.validate()
elif isinstance(output, DynamicOutputs.ByKey):
elif isinstance(output, (DynamicOutputs.ByKey, DynamicOutputs.BySlot)):
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()
@@ -2205,19 +2193,11 @@ 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):
if isinstance(o, (DynamicOutputs.ByKey, DynamicOutputs.BySlot)):
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)

View File

@@ -235,9 +235,8 @@ class TypeResolver:
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.
``BySlot`` groups need ``live_input_types`` (computed lazily from the
resolver itself) so the active option can be picked by resolved type.
"""
if not (isinstance(class_def, type) and issubclass(class_def, _ComfyNodeInternal)):
return None
@@ -246,25 +245,18 @@ class TypeResolver:
except Exception:
return None
has_dynamic = any(
isinstance(o, (io.DynamicOutputs.ByKey, io.DynamicOutputs.FromInput))
isinstance(o, (io.DynamicOutputs.ByKey, io.DynamicOutputs.BySlot))
for o in schema.outputs
)
if not has_dynamic:
return None
prompt_inputs = (node or {}).get("inputs", {}) or {}
# 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 is only needed for BySlot skip the resolver pass
# otherwise to keep the hot path cheap.
needs_live_types = any(isinstance(o, io.DynamicOutputs.BySlot) 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,
schema.outputs, prompt_inputs, live_input_types=live_input_types,
)
def finalized_output_count(self, node_id: str) -> int:

View File

@@ -504,23 +504,17 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
if issubclass(class_def, _ComfyNodeInternal):
schema = class_def.GET_SCHEMA()
has_dynamic = any(
isinstance(o, (_io.DynamicOutputs.ByKey, _io.DynamicOutputs.FromInput))
isinstance(o, (_io.DynamicOutputs.ByKey, _io.DynamicOutputs.BySlot))
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 is only needed for BySlot finalization.
needs_live_types = any(isinstance(o, _io.DynamicOutputs.BySlot) 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,
schema.outputs, inputs, live_input_types=live_input_types,
)
input_data_all = None

View File

@@ -235,198 +235,197 @@ def test_schema_rejects_duplicate_dynamic_group_ids():
# ---------------------------------------------------------------------------
# DynamicOutputs.FromInput — DynamicCombo / DynamicSlot integration
# DynamicOutputs.ByKey with a DynamicCombo selector
# ---------------------------------------------------------------------------
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 _combo_input():
return io.DynamicCombo.Input("mode", options=[
io.DynamicCombo.Option(key="image", inputs=[io.Image.Input("img")]),
io.DynamicCombo.Option(key="latent", inputs=[io.Latent.Input("lat")]),
])
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 _bykey_outputs():
return io.DynamicOutputs.ByKey(id="result", selector="mode", options=[
io.DynamicOutputs.Option(key="image", outputs=[io.Image.Output("processed"), io.Mask.Output("alpha")]),
io.DynamicOutputs.Option(key="latent", outputs=[io.Latent.Output("denoised")]),
])
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")]
def test_bykey_with_dynamic_combo_finalizes_branch():
finalized = io.get_finalized_class_outputs(
schema_outputs, {"mode": "image"}, schema_inputs=schema_inputs,
[io.String.Output("status"), _bykey_outputs()],
{"mode": {"mode": "image", "img": None}}, # DynamicCombo dispatch shape
)
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")]
def test_bykey_with_dynamic_combo_other_branch():
finalized = io.get_finalized_class_outputs(
schema_outputs, {"mode": "missing"}, schema_inputs=schema_inputs,
[_bykey_outputs()],
{"mode": {"mode": "latent", "lat": None}},
)
assert finalized.output_ids == ["status"]
assert finalized.output_ids == ["denoised"]
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
def test_schema_rejects_bykey_key_not_on_dynamic_combo():
class StrayKey(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="StrayKey",
inputs=[_combo_input()],
outputs=[io.DynamicOutputs.ByKey(id="r", selector="mode", options=[
io.DynamicOutputs.Option(key="image", outputs=[io.Image.Output("a")]),
io.DynamicOutputs.Option(key="audio", outputs=[io.String.Output("b")]),
])],
)
@classmethod
def execute(cls, **kwargs):
return io.NodeOutput.from_named({})
with pytest.raises(ValueError, match=r"option key\(s\) \['audio'\] are not declared"):
StrayKey.GET_SCHEMA()
# ---------------------------------------------------------------------------
# DynamicOutputs.BySlot
# ---------------------------------------------------------------------------
def _slot_input():
return io.DynamicSlot.Input("slot", options=[
io.DynamicSlot.Option(when=io.Image),
io.DynamicSlot.Option(when=io.Latent),
io.DynamicSlot.Option(when=None, inputs=[io.Int.Input("seed")]),
])
def _byslot_outputs():
return io.DynamicOutputs.BySlot(id="slot_out", selector="slot", options=[
io.DynamicOutputs.SlotOption(when=io.Image, outputs=[io.Image.Output("processed"), io.Mask.Output("alpha")]),
io.DynamicOutputs.SlotOption(when=io.Latent, outputs=[io.Latent.Output("denoised")]),
io.DynamicOutputs.SlotOption(when=None, outputs=[]),
])
def test_byslot_finalizes_by_resolved_type():
finalized = io.get_finalized_class_outputs(
schema_outputs,
[_byslot_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,
[_byslot_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,
)
def test_byslot_unconnected_uses_when_none():
finalized = io.get_finalized_class_outputs([_byslot_outputs()], {})
# 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")]
def test_byslot_unmatched_type_yields_empty():
finalized = io.get_finalized_class_outputs(
schema_outputs,
[_byslot_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):
def test_byslot_rejects_duplicate_when_types():
with pytest.raises(ValueError, match="appears in more than one option"):
io.DynamicOutputs.BySlot(id="r", selector="slot", options=[
io.DynamicOutputs.SlotOption(when=io.Image, outputs=[io.Image.Output("a")]),
io.DynamicOutputs.SlotOption(when=io.Image, outputs=[io.Mask.Output("b")]),
])
def test_byslot_rejects_duplicate_when_none():
with pytest.raises(ValueError, match="only one option may declare when=None"):
io.DynamicOutputs.BySlot(id="r", selector="slot", options=[
io.DynamicOutputs.SlotOption(when=None, outputs=[]),
io.DynamicOutputs.SlotOption(when=None, outputs=[io.Image.Output("x")]),
])
def test_schema_rejects_byslot_selector_not_a_dynamic_slot():
class WrongSel(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")],
node_id="WrongSel",
inputs=[io.Combo.Input("not_a_slot", options=["a"])],
outputs=[io.DynamicOutputs.BySlot(id="r", selector="not_a_slot", options=[
io.DynamicOutputs.SlotOption(when=io.Image, outputs=[io.Image.Output("x")]),
])],
)
@classmethod
def execute(cls, **kwargs):
return io.NodeOutput.from_named({})
with pytest.raises(ValueError, match="must reference a DynamicCombo or DynamicSlot"):
BadRef.GET_SCHEMA()
with pytest.raises(ValueError, match="must reference a DynamicSlot input"):
WrongSel.GET_SCHEMA()
def test_schema_rejects_fromInput_referenced_more_than_once():
class DupRef(io.ComfyNode):
def test_schema_rejects_byslot_when_type_not_on_slot():
class StrayWhen(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")],
node_id="StrayWhen",
inputs=[_slot_input()],
outputs=[io.DynamicOutputs.BySlot(id="r", selector="slot", options=[
io.DynamicOutputs.SlotOption(when=io.Audio, outputs=[io.Audio.Output("x")]),
])],
)
@classmethod
def execute(cls, **kwargs):
return io.NodeOutput.from_named({})
with pytest.raises(ValueError, match="referenced more than once"):
DupRef.GET_SCHEMA()
with pytest.raises(ValueError, match=r"type\(s\) \['AUDIO'\] are not accepted"):
StrayWhen.GET_SCHEMA()
def test_schema_rejects_fromInput_output_collision_with_static():
class Collision(io.ComfyNode):
def test_schema_rejects_byslot_when_none_without_slot_when_none():
class NoNone(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")],
node_id="NoNone",
inputs=[io.DynamicSlot.Input("slot", optional=False, options=[
io.DynamicSlot.Option(when=io.Image),
])],
outputs=[io.DynamicOutputs.BySlot(id="r", selector="slot", options=[
io.DynamicOutputs.SlotOption(when=None, outputs=[]),
])],
)
@classmethod
def execute(cls, **kwargs):
return io.NodeOutput.from_named({"processed": None})
return io.NodeOutput.from_named({})
with pytest.raises(ValueError, match="Output ids must be unique"):
Collision.GET_SCHEMA()
with pytest.raises(ValueError, match="requires DynamicSlot 'slot' to declare a when=None"):
NoNone.GET_SCHEMA()
def test_v1_info_emits_by_key_for_combo_fromInput():
def test_v1_info_emits_byslot_entry():
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")],
node_id="SlotV1",
inputs=[_slot_input()],
outputs=[_byslot_outputs()],
)
@classmethod

View File

@@ -256,79 +256,78 @@ def test_blocker_sized_to_finalized_outputs_for_node_output():
# ---------------------------------------------------------------------------
# FromInput via DynamicCombo / DynamicSlot through the TypeResolver
# DynamicOutputs.ByKey driven by a DynamicCombo selector (end-to-end resolver)
# ---------------------------------------------------------------------------
def _make_combo_fi_node():
"""V3 node: DynamicCombo input drives output set via FromInput placeholder."""
def _make_combo_bykey_node():
from comfy_api.latest import _io as io
class ComboFI(io.ComfyNode):
class ComboBK(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ComboFI",
node_id="ComboBK",
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")],
),
io.DynamicCombo.Option(key="image", inputs=[io.Image.Input("img")]),
io.DynamicCombo.Option(key="latent", inputs=[io.Latent.Input("lat")]),
]),
],
outputs=[io.DynamicOutputs.FromInput("mode")],
outputs=[io.DynamicOutputs.ByKey(id="result", selector="mode", options=[
io.DynamicOutputs.Option(key="image",
outputs=[io.Image.Output("processed"), io.Mask.Output("alpha")]),
io.DynamicOutputs.Option(key="latent",
outputs=[io.Latent.Output("denoised")]),
])],
)
@classmethod
def execute(cls, mode, **kwargs):
if mode == "latent":
if mode["mode"] == "latent":
return io.NodeOutput.from_named({"denoised": None})
return io.NodeOutput.from_named({"processed": None, "alpha": None})
ComboFI.GET_SCHEMA()
return ComboFI
ComboBK.GET_SCHEMA()
return ComboBK
def _make_slot_fi_node():
"""V3 node: DynamicSlot input drives output set via FromInput placeholder."""
def _make_slot_byslot_node():
from comfy_api.latest import _io as io
class SlotFI(io.ComfyNode):
class SlotBS(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="SlotFI",
node_id="SlotBS",
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=[]),
io.DynamicSlot.Option(when=io.Image),
io.DynamicSlot.Option(when=io.Latent),
io.DynamicSlot.Option(when=None),
]),
],
outputs=[io.DynamicOutputs.FromInput("slot")],
outputs=[io.DynamicOutputs.BySlot(id="slot_out", selector="slot", options=[
io.DynamicOutputs.SlotOption(when=io.Image,
outputs=[io.Image.Output("processed"), io.Mask.Output("alpha")]),
io.DynamicOutputs.SlotOption(when=io.Latent,
outputs=[io.Latent.Output("denoised")]),
io.DynamicOutputs.SlotOption(when=None, outputs=[]),
])],
)
@classmethod
def execute(cls, **kwargs):
return io.NodeOutput.from_named({})
SlotFI.GET_SCHEMA()
return SlotFI
SlotBS.GET_SCHEMA()
return SlotBS
def test_combo_fromInput_resolver_picks_branch(fake_nodes_module, TypeResolver):
fake_nodes_module["ComboFI"] = _make_combo_fi_node()
def test_combo_bykey_resolver_picks_branch(fake_nodes_module, TypeResolver):
fake_nodes_module["ComboBK"] = _make_combo_bykey_node()
prompt = {
"img": {"class_type": "ComboFI", "inputs": {"mode": "image"}},
"lat": {"class_type": "ComboFI", "inputs": {"mode": "latent"}},
"img": {"class_type": "ComboBK", "inputs": {"mode": {"mode": "image", "img": None}}},
"lat": {"class_type": "ComboBK", "inputs": {"mode": {"mode": "latent", "lat": None}}},
}
r = TypeResolver(prompt)
assert r.resolve_output_type("img", 0) == "IMAGE"
@@ -338,22 +337,22 @@ def test_combo_fromInput_resolver_picks_branch(fake_nodes_module, TypeResolver):
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()
def test_slot_byslot_resolver_picks_by_resolved_type(fake_nodes_module, TypeResolver):
fake_nodes_module["SlotBS"] = _make_slot_byslot_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": {}},
"image_consumer": {"class_type": "SlotBS", "inputs": {"slot": ["img_src", 0]}},
"latent_consumer": {"class_type": "SlotBS", "inputs": {"slot": ["lat_src", 0]}},
"unconnected": {"class_type": "SlotBS", "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.
# Unconnected when=None branch declares outputs=[]
assert r.finalized_output_count("unconnected") == 0