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:
Jedrzej Kosinski 2026-06-01 21:10:17 -07:00
parent 188065c011
commit cca4119fdf
5 changed files with 575 additions and 51 deletions

View File

@ -1141,20 +1141,46 @@ class Autogrow(ComfyTypeI):
out_dict["dynamic_paths_default_value"][finalized_prefix] = DynamicPathsDefaultValue.EMPTY_DICT 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) 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") @comfytype(io_type="COMFY_DYNAMICCOMBO_V3")
class DynamicCombo(ComfyTypeI): class DynamicCombo(ComfyTypeI):
Type = dict[str, Any] Type = dict[str, Any]
class Option: 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.key = key
self.inputs = inputs 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): def as_dict(self):
return { return prune_dict({
"key": self.key, "key": self.key,
"inputs": create_input_dict_v1(self.inputs), "inputs": create_input_dict_v1(self.inputs),
} "outputs": _serialize_option_outputs(self.outputs),
})
class Input(DynamicInput): class Input(DynamicInput):
def __init__(self, id: str, options: list[DynamicCombo.Option], 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 list of ComfyType classes (shared branch across multiple types)
* a ``MultiType.Input`` instance (parsed via its ``.io_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.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_types`` is the ordered tuple of io_types (deterministic);
# ``_when_set`` is the same content as a set for fast matching. # ``_when_set`` is the same content as a set for fast matching.
self._when_types = self._normalize_when(when) self._when_types = self._normalize_when(when)
@ -1272,10 +1301,16 @@ class DynamicSlot(ComfyTypeI):
) )
def as_dict(self): 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), "when": None if self._when_types is None else list(self._when_types),
"inputs": create_input_dict_v1(self.inputs), "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): class Input(DynamicInput):
def __init__(self, id: str, options: list[DynamicSlot.Option], def __init__(self, id: str, options: list[DynamicSlot.Option],
@ -1413,19 +1448,22 @@ class FinalizedOutputs:
class DynamicOutputs: class DynamicOutputs:
"""Container namespace for dynamic output group declarations. """Container namespace for dynamic output group declarations.
Place an instance of one of the inner classes (e.g. ``DynamicOutputs.ByKey``) Place an instance of one of the inner classes directly inside
directly inside ``Schema.outputs`` to declare a set of outputs whose shape ``Schema.outputs`` to declare a set of outputs whose shape depends on
depends on prompt data. The active branch is chosen at execution time by prompt data. The active branch is chosen at execution time by
:py:func:`get_finalized_class_outputs`. :py:func:`get_finalized_class_outputs`.
Current limitations (first slice): Forms:
* Only :py:class:`DynamicOutputs.ByKey` is implemented. * :py:class:`DynamicOutputs.ByKey` outputs are declared inline on the
* Selector must be a literal (Combo/string) input on the same node links group and picked by the literal value of a same-node selector input.
are rejected so finalization is a pure function of prompt-finalizable * :py:class:`DynamicOutputs.FromInput` positional placeholder that
data. reuses the per-option ``outputs=[]`` declared on a
* Inactive options do not produce placeholder slots downstream links to :py:class:`DynamicCombo.Input` or :py:class:`DynamicSlot.Input`,
nonexistent finalized slots are rejected by validation. 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: class Option:
@ -1509,6 +1547,56 @@ class DynamicOutputs:
return opt return opt
return None 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]: 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.""" """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) 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( def get_finalized_class_outputs(
schema_outputs: list, schema_outputs: list,
prompt_inputs: dict[str, Any] | None, 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: ) -> FinalizedOutputs:
"""Resolve the active output list for a node by expanding any """Resolve the active output list for a node.
:py:class:`DynamicOutputs` groups against ``prompt_inputs``.
Inactive options contribute no slots downstream links to ranges that Expands :py:class:`DynamicOutputs.ByKey` against ``prompt_inputs`` and
only existed under a different branch are caught by validation rather :py:class:`DynamicOutputs.FromInput` against the matching
than silently filled with ``AnyType`` placeholders. :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 {} inputs = prompt_inputs or {}
outputs: list[Output] = [] outputs: list[Output] = []
@ -1536,27 +1669,27 @@ def get_finalized_class_outputs(
names: list[str] = [] names: list[str] = []
is_list: list[bool] = [] is_list: list[bool] = []
tooltips: list[str | None] = [] 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 []: for entry in schema_outputs or []:
if isinstance(entry, Output): if isinstance(entry, Output):
oid, rt, name, isl, tt = _output_metadata(entry) append_output(entry)
outputs.append(entry)
ids.append(oid)
types.append(rt)
names.append(name)
is_list.append(isl)
tooltips.append(tt)
elif isinstance(entry, DynamicOutputs.ByKey): elif isinstance(entry, DynamicOutputs.ByKey):
selected = entry.select(inputs) selected = entry.select(inputs)
if selected is None: if selected is not None:
continue for o in selected.outputs:
for o in selected.outputs: append_output(o)
oid, rt, name, isl, tt = _output_metadata(o) elif isinstance(entry, DynamicOutputs.FromInput):
outputs.append(o) for o in _select_from_input_outputs(entry, schema_inputs, inputs, live_input_types):
ids.append(oid) append_output(o)
types.append(rt)
names.append(name)
is_list.append(isl)
tooltips.append(tt)
# else: ignore unknown entries (future-proofing for new dynamic kinds) # else: ignore unknown entries (future-proofing for new dynamic kinds)
return FinalizedOutputs( return FinalizedOutputs(
outputs=outputs, outputs=outputs,
@ -1942,15 +2075,25 @@ class Schema:
'''Validate the schema: '''Validate the schema:
- verify ids on inputs and outputs are unique - both internally and in relation to each other - 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 dynamic-output groups reference real inputs and have unique active ids
- verify DynamicOutputs.FromInput placeholders reference real DynamicCombo / DynamicSlot inputs
''' '''
nested_inputs: list[Input] = [] nested_inputs: list[Input] = []
for input in self.inputs: for input in self.inputs:
if not isinstance(input, DynamicInput): if not isinstance(input, DynamicInput):
nested_inputs.extend(input.get_all()) nested_inputs.extend(input.get_all())
input_ids = [i.id for i in nested_inputs] 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_ids`` covers every id that may ever appear in a finalized
# output list — static outputs + every option's outputs across every # 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] = [] output_ids: list[str] = []
for o in self.outputs: for o in self.outputs:
if isinstance(o, Output): if isinstance(o, Output):
@ -1958,7 +2101,12 @@ class Schema:
elif isinstance(o, DynamicOutputs.ByKey): elif isinstance(o, DynamicOutputs.ByKey):
for opt in o.options: for opt in o.options:
output_ids.extend(child.id for child in opt.outputs) 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) output_set = set(output_ids)
issues: list[str] = [] issues: list[str] = []
# verify ids are unique per list # 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.") 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): 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.") 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] = [] group_ids: list[str] = []
for o in self.outputs: for o in self.outputs:
if isinstance(o, DynamicOutputs.ByKey): if isinstance(o, DynamicOutputs.ByKey):
@ -1981,6 +2129,18 @@ class Schema:
f"DynamicOutputs group ids must be unique, but " f"DynamicOutputs group ids must be unique, but "
f"{[i for i, c in Counter(group_ids).items() if c > 1]} are not." 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: if len(issues) > 0:
raise ValueError("\n".join(issues)) raise ValueError("\n".join(issues))
# validate inputs and outputs # validate inputs and outputs
@ -1993,6 +2153,12 @@ class Schema:
for opt in output.options: for opt in output.options:
for child in opt.outputs: for child in opt.outputs:
child.validate() 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: if self.price_badge is not None:
self.price_badge.validate() self.price_badge.validate()
@ -2039,11 +2205,19 @@ class Schema:
output_matchtypes = [] output_matchtypes = []
any_matchtypes = False any_matchtypes = False
dynamic_outputs: list[dict[str, Any]] = [] 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: if self.outputs:
for o in self.outputs: for o in self.outputs:
if isinstance(o, DynamicOutputs.ByKey): if isinstance(o, DynamicOutputs.ByKey):
dynamic_outputs.append(o.as_dict()) dynamic_outputs.append(o.as_dict())
continue 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.append(o.io_type)
output_is_list.append(o.is_output_list) output_is_list.append(o.is_output_list)
output_name.append(o.display_name if o.display_name else o.io_type) output_name.append(o.display_name if o.display_name else o.io_type)

View File

@ -145,7 +145,7 @@ class TypeResolver:
# V3 schemas may declare DynamicOutputs groups whose active slots are # V3 schemas may declare DynamicOutputs groups whose active slots are
# determined by prompt inputs and do not appear in class RETURN_TYPES; # determined by prompt inputs and do not appear in class RETURN_TYPES;
# resolve types against the finalized output list when present. # 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 finalized is not None:
if slot_idx < 0 or slot_idx >= len(finalized): if slot_idx < 0 or slot_idx >= len(finalized):
return ANY_TYPE return ANY_TYPE
@ -232,18 +232,40 @@ class TypeResolver:
f"MatchType template '{template_id}' has no bound concrete upstream input; defaulting to AnyType") f"MatchType template '{template_id}' has no bound concrete upstream input; defaulting to AnyType")
return ANY_TYPE return ANY_TYPE
def _get_finalized_outputs(self, node: dict | None, class_def) -> io.FinalizedOutputs | 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``.""" """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)): if not (isinstance(class_def, type) and issubclass(class_def, _ComfyNodeInternal)):
return None return None
try: try:
schema = class_def.GET_SCHEMA() schema = class_def.GET_SCHEMA()
except Exception: except Exception:
return None 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 return None
prompt_inputs = (node or {}).get("inputs", {}) or {} 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: def finalized_output_count(self, node_id: str) -> int:
"""Number of active output slots on ``node_id``'s schema for the current prompt. """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) node, class_def = self._get_class_def_for_node(node_id)
if class_def is None: if class_def is None:
return 0 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: if finalized is not None:
return len(finalized) return len(finalized)
try: try:
@ -274,7 +296,7 @@ class TypeResolver:
result = False result = False
node, class_def = self._get_class_def_for_node(node_id) node, class_def = self._get_class_def_for_node(node_id)
if class_def is not None: 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 finalized is not None:
if 0 <= slot_idx < len(finalized): if 0 <= slot_idx < len(finalized):
result = bool(finalized.output_is_list[slot_idx]) result = bool(finalized.output_is_list[slot_idx])

View File

@ -503,8 +503,25 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
finalized_outputs = None finalized_outputs = None
if issubclass(class_def, _ComfyNodeInternal): if issubclass(class_def, _ComfyNodeInternal):
schema = class_def.GET_SCHEMA() schema = class_def.GET_SCHEMA()
if any(isinstance(o, _io.DynamicOutputs.ByKey) for o in schema.outputs): has_dynamic = any(
finalized_outputs = _io.get_finalized_class_outputs(schema.outputs, inputs) 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 input_data_all = None
try: try:

View File

@ -232,3 +232,212 @@ def test_schema_rejects_duplicate_dynamic_group_ids():
with pytest.raises(ValueError, match="DynamicOutputs group ids must be unique"): with pytest.raises(ValueError, match="DynamicOutputs group ids must be unique"):
Dup.GET_SCHEMA() 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]

View File

@ -255,6 +255,108 @@ def test_blocker_sized_to_finalized_outputs_for_node_output():
assert slot[0].message == "paused" 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(): def test_bare_execution_blocker_sized_to_finalized_outputs():
"""The non-NodeOutput path (bare ``ExecutionBlocker`` from V1-style returns) """The non-NodeOutput path (bare ``ExecutionBlocker`` from V1-style returns)
also sizes against the finalized list.""" also sizes against the finalized list."""