DynamicOutputs: replace FromInput with BySlot; outputs always declared in Schema.outputs
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run

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 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], outputs: list[Output] | None = None): def __init__(self, key: str, inputs: list[Input]):
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 prune_dict({ return {
"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],
@ -1250,12 +1224,9 @@ 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] = None, outputs: list[Output] | None = None): def __init__(self, when: Any, inputs: list[Input] = None):
self.when = when self.when = when
self.inputs = inputs or [] 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)
@ -1301,16 +1272,10 @@ class DynamicSlot(ComfyTypeI):
) )
def as_dict(self): def as_dict(self):
# ``when`` is preserved even when None (meaningful "unconnected" branch); return {
# ``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],
@ -1455,48 +1420,56 @@ class DynamicOutputs:
Forms: Forms:
* :py:class:`DynamicOutputs.ByKey` outputs are declared inline on the * :py:class:`DynamicOutputs.ByKey` selector is a literal input
group and picked by the literal value of a same-node selector input. (``Combo`` / ``DynamicCombo`` / ``String`` / etc.) and the active
* :py:class:`DynamicOutputs.FromInput` positional placeholder that option is chosen by direct value comparison against ``Option.key``.
reuses the per-option ``outputs=[]`` declared on a * :py:class:`DynamicOutputs.BySlot` selector is a
:py:class:`DynamicCombo.Input` or :py:class:`DynamicSlot.Input`, :py:class:`DynamicSlot.Input` and the active option is chosen by
keeping the option content single-sourced. the slot's resolved upstream type, matching ``Option.when``.
Inactive options do not produce placeholder slots downstream links to Inactive options do not produce placeholder slots downstream links to
nonexistent finalized slots are rejected by validation. nonexistent finalized slots are rejected by validation.
""" """
class Option: 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]): def __init__(self, key: str, outputs: list[Output]):
if not isinstance(key, str) or not key: if not isinstance(key, str) or not key:
raise ValueError("DynamicOutputs.Option: key must be a non-empty string") raise ValueError("DynamicOutputs.Option: key must be a non-empty string")
for o in outputs: _validate_outputs("DynamicOutputs.Option", 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")
self.key = key self.key = key
self.outputs = outputs 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): def as_dict(self):
return { return {
"key": self.key, "when": None if self._when_types is None else list(self._when_types),
"outputs": [ "outputs": _serialize_outputs(self.outputs),
{
"id": o.id,
"type": o.get_io_type(),
**o.as_dict(),
}
for o in self.outputs
],
} }
class ByKey: 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" kind = "by_key"
@ -1539,7 +1512,11 @@ class DynamicOutputs:
def select(self, prompt_inputs: dict[str, Any]) -> DynamicOutputs.Option | None: def select(self, prompt_inputs: dict[str, Any]) -> DynamicOutputs.Option | None:
"""Pick the matching ``Option`` for the prompt's selector value, or ``None``.""" """Pick the matching ``Option`` for the prompt's selector value, or ``None``."""
value = prompt_inputs.get(self.selector) 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): if isinstance(value, list):
return None return None
for opt in self.options: for opt in self.options:
@ -1547,55 +1524,91 @@ class DynamicOutputs:
return opt return opt
return None return None
class FromInput: class BySlot:
"""Positional placeholder that pulls outputs from a referenced dynamic input. """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 kind = "by_slot"
: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 def __init__(self, id: str, selector: str, options: list[DynamicOutputs.SlotOption]):
# ("by_key" for DynamicCombo, "by_slot" for DynamicSlot). if not isinstance(id, str) or not id:
kind = "from_input" 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): def as_dict(self):
if not isinstance(input_id, str) or not input_id: return {
raise ValueError("DynamicOutputs.FromInput: input_id must be a non-empty string") "id": self.id,
self.input_id = input_id "kind": self.kind,
"selector": self.selector,
"options": [opt.as_dict() for opt in self.options],
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 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]: 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) 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,
schema_inputs: list | None = None,
live_input_types: dict[str, str] | None = None, live_input_types: dict[str, str] | None = None,
) -> FinalizedOutputs: ) -> FinalizedOutputs:
"""Resolve the active output list for a node. """Resolve the active output list for a node.
Expands :py:class:`DynamicOutputs.ByKey` against ``prompt_inputs`` and Expands :py:class:`DynamicOutputs.ByKey` against ``prompt_inputs`` and
:py:class:`DynamicOutputs.FromInput` against the matching :py:class:`DynamicOutputs.BySlot` against ``live_input_types``. Inactive
:py:class:`DynamicCombo.Input` / :py:class:`DynamicSlot.Input` (using options contribute no slots downstream links to ranges that only existed
``live_input_types`` for the slot case). Inactive options contribute under a different branch are caught by validation rather than silently
no slots downstream links to nonexistent finalized slots are caught filled with ``AnyType`` placeholders.
by validation rather than silently filled with ``AnyType`` placeholders.
""" """
inputs = prompt_inputs or {} inputs = prompt_inputs or {}
outputs: list[Output] = [] outputs: list[Output] = []
@ -1687,9 +1656,11 @@ def get_finalized_class_outputs(
if selected is not None: if selected is not None:
for o in selected.outputs: for o in selected.outputs:
append_output(o) append_output(o)
elif isinstance(entry, DynamicOutputs.FromInput): elif isinstance(entry, DynamicOutputs.BySlot):
for o in _select_from_input_outputs(entry, schema_inputs, inputs, live_input_types): selected = entry.select(inputs, live_input_types)
append_output(o) if selected is not None:
for o in selected.outputs:
append_output(o)
# 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,
@ -2073,9 +2044,12 @@ class Schema:
def validate(self): def validate(self):
'''Validate the schema: '''Validate the schema:
- verify ids on inputs and outputs are unique - both internally and in relation to each other - input/output ids are unique within and across each other
- verify dynamic-output groups reference real inputs and have unique active ids - DynamicOutputs.ByKey / BySlot have unique group ids
- verify DynamicOutputs.FromInput placeholders reference real DynamicCombo / DynamicSlot inputs - 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] = [] nested_inputs: list[Input] = []
for input in self.inputs: for input in self.inputs:
@ -2083,82 +2057,96 @@ class Schema:
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) input_set = set(input_ids)
dynamic_input_ids = { combo_inputs = {i.id: i for i in self.inputs if isinstance(i, DynamicCombo.Input)}
i.id for i in self.inputs if isinstance(i, (DynamicCombo.Input, DynamicSlot.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
from_input_refs = [ # top-level inputs, since DynamicInput instances aren't included in nested_inputs.
o.input_id for o in self.outputs if isinstance(o, DynamicOutputs.FromInput) all_selectable_ids = input_set | set(combo_inputs) | set(slot_inputs)
]
# ``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 + outputs from any dynamic-input referenced by a # dynamic group — so collisions between branches are caught up front.
# 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):
output_ids.append(o.id) output_ids.append(o.id)
elif isinstance(o, DynamicOutputs.ByKey): elif isinstance(o, (DynamicOutputs.ByKey, DynamicOutputs.BySlot)):
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)
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
if len(input_set) != len(input_ids): 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.") 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.")
# 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):
group_ids.append(o.id) group_ids.append(o.id)
if o.selector not in input_set: if o.selector not in all_selectable_ids:
issues.append( issues.append(
f"DynamicOutputs.ByKey(id={o.id!r}) selector input {o.selector!r} " f"DynamicOutputs.ByKey(id={o.id!r}) selector input {o.selector!r} "
f"does not exist on the schema." 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): if len(set(group_ids)) != len(group_ids):
issues.append( issues.append(
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 # per-element validation
for input in self.inputs: for input in self.inputs:
input.validate() input.validate()
for output in self.outputs: for output in self.outputs:
if isinstance(output, Output): if isinstance(output, Output):
output.validate() output.validate()
elif isinstance(output, DynamicOutputs.ByKey): elif isinstance(output, (DynamicOutputs.ByKey, DynamicOutputs.BySlot)):
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()
@ -2205,19 +2193,11 @@ 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, DynamicOutputs.BySlot)):
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

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

View File

@ -504,23 +504,17 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
if issubclass(class_def, _ComfyNodeInternal): if issubclass(class_def, _ComfyNodeInternal):
schema = class_def.GET_SCHEMA() schema = class_def.GET_SCHEMA()
has_dynamic = any( has_dynamic = any(
isinstance(o, (_io.DynamicOutputs.ByKey, _io.DynamicOutputs.FromInput)) isinstance(o, (_io.DynamicOutputs.ByKey, _io.DynamicOutputs.BySlot))
for o in schema.outputs for o in schema.outputs
) )
if has_dynamic: if has_dynamic:
# live_input_types is only needed for FromInput → DynamicSlot finalization # live_input_types is only needed for BySlot finalization.
needs_live_types = any( needs_live_types = any(isinstance(o, _io.DynamicOutputs.BySlot) for o in schema.outputs)
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 live_input_types = None
if needs_live_types and hasattr(dynprompt, "get_type_resolver"): if needs_live_types and hasattr(dynprompt, "get_type_resolver"):
live_input_types = dynprompt.get_type_resolver().compute_live_input_types(unique_id) live_input_types = dynprompt.get_type_resolver().compute_live_input_types(unique_id)
finalized_outputs = _io.get_finalized_class_outputs( finalized_outputs = _io.get_finalized_class_outputs(
schema.outputs, inputs, schema.outputs, inputs, live_input_types=live_input_types,
schema_inputs=schema.inputs,
live_input_types=live_input_types,
) )
input_data_all = None 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(): def _combo_input():
return [ return io.DynamicCombo.Input("mode", options=[
io.DynamicCombo.Option( io.DynamicCombo.Option(key="image", inputs=[io.Image.Input("img")]),
key="image", io.DynamicCombo.Option(key="latent", inputs=[io.Latent.Input("lat")]),
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(): def _bykey_outputs():
return [ return io.DynamicOutputs.ByKey(id="result", selector="mode", options=[
io.DynamicSlot.Option( io.DynamicOutputs.Option(key="image", outputs=[io.Image.Output("processed"), io.Mask.Output("alpha")]),
when=io.Image, io.DynamicOutputs.Option(key="latent", outputs=[io.Latent.Output("denoised")]),
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(): def test_bykey_with_dynamic_combo_finalizes_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( 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.output_ids == ["status", "processed", "alpha"]
assert finalized.return_types == ["STRING", "IMAGE", "MASK"] assert finalized.return_types == ["STRING", "IMAGE", "MASK"]
def test_fromInput_unknown_combo_key_yields_only_static(): def test_bykey_with_dynamic_combo_other_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( 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(): def test_schema_rejects_bykey_key_not_on_dynamic_combo():
schema_inputs = [io.DynamicSlot.Input("slot", options=_slot_options_with_outputs())] class StrayKey(io.ComfyNode):
schema_outputs = [io.DynamicOutputs.FromInput("slot")] @classmethod
# Connected with resolved type IMAGE → first option matches 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( finalized = io.get_finalized_class_outputs(
schema_outputs, [_byslot_outputs()],
{"slot": ["upstream", 0]}, {"slot": ["upstream", 0]},
schema_inputs=schema_inputs,
live_input_types={"slot": "IMAGE"}, live_input_types={"slot": "IMAGE"},
) )
assert finalized.output_ids == ["processed", "alpha"] assert finalized.output_ids == ["processed", "alpha"]
# Connected, LATENT branch
finalized = io.get_finalized_class_outputs( finalized = io.get_finalized_class_outputs(
schema_outputs, [_byslot_outputs()],
{"slot": ["upstream", 0]}, {"slot": ["upstream", 0]},
schema_inputs=schema_inputs,
live_input_types={"slot": "LATENT"}, live_input_types={"slot": "LATENT"},
) )
assert finalized.output_ids == ["denoised"] assert finalized.output_ids == ["denoised"]
def test_fromInput_slot_unconnected_uses_when_none_option(): def test_byslot_unconnected_uses_when_none():
schema_inputs = [io.DynamicSlot.Input("slot", options=_slot_options_with_outputs())] finalized = io.get_finalized_class_outputs([_byslot_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 # when=None option declares outputs=[] → no active outputs
assert finalized.output_ids == [] assert finalized.output_ids == []
def test_fromInput_slot_unmatched_type_yields_empty(): def test_byslot_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( finalized = io.get_finalized_class_outputs(
schema_outputs, [_byslot_outputs()],
{"slot": ["upstream", 0]}, {"slot": ["upstream", 0]},
schema_inputs=schema_inputs,
live_input_types={"slot": "AUDIO"}, live_input_types={"slot": "AUDIO"},
) )
assert finalized.output_ids == [] assert finalized.output_ids == []
def test_schema_rejects_fromInput_pointing_at_missing_input(): def test_byslot_rejects_duplicate_when_types():
class BadRef(io.ComfyNode): 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 @classmethod
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="BadRef", node_id="WrongSel",
inputs=[io.Combo.Input("mode", options=["a"])], inputs=[io.Combo.Input("not_a_slot", options=["a"])],
outputs=[io.DynamicOutputs.FromInput("does_not_exist")], outputs=[io.DynamicOutputs.BySlot(id="r", selector="not_a_slot", options=[
io.DynamicOutputs.SlotOption(when=io.Image, outputs=[io.Image.Output("x")]),
])],
) )
@classmethod @classmethod
def execute(cls, **kwargs): def execute(cls, **kwargs):
return io.NodeOutput.from_named({}) return io.NodeOutput.from_named({})
with pytest.raises(ValueError, match="must reference a DynamicCombo or DynamicSlot"): with pytest.raises(ValueError, match="must reference a DynamicSlot input"):
BadRef.GET_SCHEMA() WrongSel.GET_SCHEMA()
def test_schema_rejects_fromInput_referenced_more_than_once(): def test_schema_rejects_byslot_when_type_not_on_slot():
class DupRef(io.ComfyNode): class StrayWhen(io.ComfyNode):
@classmethod @classmethod
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="DupRef", node_id="StrayWhen",
inputs=[io.DynamicCombo.Input("mode", options=_combo_options_with_outputs())], inputs=[_slot_input()],
outputs=[io.DynamicOutputs.FromInput("mode"), io.DynamicOutputs.FromInput("mode")], outputs=[io.DynamicOutputs.BySlot(id="r", selector="slot", options=[
io.DynamicOutputs.SlotOption(when=io.Audio, outputs=[io.Audio.Output("x")]),
])],
) )
@classmethod @classmethod
def execute(cls, **kwargs): def execute(cls, **kwargs):
return io.NodeOutput.from_named({}) return io.NodeOutput.from_named({})
with pytest.raises(ValueError, match="referenced more than once"): with pytest.raises(ValueError, match=r"type\(s\) \['AUDIO'\] are not accepted"):
DupRef.GET_SCHEMA() StrayWhen.GET_SCHEMA()
def test_schema_rejects_fromInput_output_collision_with_static(): def test_schema_rejects_byslot_when_none_without_slot_when_none():
class Collision(io.ComfyNode): class NoNone(io.ComfyNode):
@classmethod @classmethod
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="Collision", node_id="NoNone",
inputs=[ inputs=[io.DynamicSlot.Input("slot", optional=False, options=[
io.DynamicCombo.Input("mode", options=[ io.DynamicSlot.Option(when=io.Image),
io.DynamicCombo.Option( ])],
key="image", inputs=[io.Image.Input("img")], outputs=[io.DynamicOutputs.BySlot(id="r", selector="slot", options=[
outputs=[io.Image.Output("processed")], io.DynamicOutputs.SlotOption(when=None, outputs=[]),
), ])],
]),
],
outputs=[io.Image.Output("processed"), io.DynamicOutputs.FromInput("mode")],
) )
@classmethod @classmethod
def execute(cls, **kwargs): 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"): with pytest.raises(ValueError, match="requires DynamicSlot 'slot' to declare a when=None"):
Collision.GET_SCHEMA() NoNone.GET_SCHEMA()
def test_v1_info_emits_by_key_for_combo_fromInput(): def test_v1_info_emits_byslot_entry():
class N(io.ComfyNode): class N(io.ComfyNode):
@classmethod @classmethod
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="ComboFI", node_id="SlotV1",
inputs=[io.DynamicCombo.Input("mode", options=_combo_options_with_outputs())], inputs=[_slot_input()],
outputs=[io.DynamicOutputs.FromInput("mode")], outputs=[_byslot_outputs()],
)
@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 @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(): def _make_combo_bykey_node():
"""V3 node: DynamicCombo input drives output set via FromInput placeholder."""
from comfy_api.latest import _io as io from comfy_api.latest import _io as io
class ComboFI(io.ComfyNode): class ComboBK(io.ComfyNode):
@classmethod @classmethod
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="ComboFI", node_id="ComboBK",
inputs=[ inputs=[
io.DynamicCombo.Input("mode", options=[ io.DynamicCombo.Input("mode", options=[
io.DynamicCombo.Option( io.DynamicCombo.Option(key="image", inputs=[io.Image.Input("img")]),
key="image", io.DynamicCombo.Option(key="latent", inputs=[io.Latent.Input("lat")]),
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")], 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 @classmethod
def execute(cls, mode, **kwargs): def execute(cls, mode, **kwargs):
if mode == "latent": if mode["mode"] == "latent":
return io.NodeOutput.from_named({"denoised": None}) return io.NodeOutput.from_named({"denoised": None})
return io.NodeOutput.from_named({"processed": None, "alpha": None}) return io.NodeOutput.from_named({"processed": None, "alpha": None})
ComboFI.GET_SCHEMA() ComboBK.GET_SCHEMA()
return ComboFI return ComboBK
def _make_slot_fi_node(): def _make_slot_byslot_node():
"""V3 node: DynamicSlot input drives output set via FromInput placeholder."""
from comfy_api.latest import _io as io from comfy_api.latest import _io as io
class SlotFI(io.ComfyNode): class SlotBS(io.ComfyNode):
@classmethod @classmethod
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="SlotFI", node_id="SlotBS",
inputs=[ inputs=[
io.DynamicSlot.Input("slot", options=[ io.DynamicSlot.Input("slot", options=[
io.DynamicSlot.Option(when=io.Image, io.DynamicSlot.Option(when=io.Image),
outputs=[io.Image.Output("processed"), io.Mask.Output("alpha")]), io.DynamicSlot.Option(when=io.Latent),
io.DynamicSlot.Option(when=io.Latent, io.DynamicSlot.Option(when=None),
outputs=[io.Latent.Output("denoised")]),
io.DynamicSlot.Option(when=None, outputs=[]),
]), ]),
], ],
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 @classmethod
def execute(cls, **kwargs): def execute(cls, **kwargs):
return io.NodeOutput.from_named({}) return io.NodeOutput.from_named({})
SlotFI.GET_SCHEMA() SlotBS.GET_SCHEMA()
return SlotFI return SlotBS
def test_combo_fromInput_resolver_picks_branch(fake_nodes_module, TypeResolver): def test_combo_bykey_resolver_picks_branch(fake_nodes_module, TypeResolver):
fake_nodes_module["ComboFI"] = _make_combo_fi_node() fake_nodes_module["ComboBK"] = _make_combo_bykey_node()
prompt = { prompt = {
"img": {"class_type": "ComboFI", "inputs": {"mode": "image"}}, "img": {"class_type": "ComboBK", "inputs": {"mode": {"mode": "image", "img": None}}},
"lat": {"class_type": "ComboFI", "inputs": {"mode": "latent"}}, "lat": {"class_type": "ComboBK", "inputs": {"mode": {"mode": "latent", "lat": None}}},
} }
r = TypeResolver(prompt) r = TypeResolver(prompt)
assert r.resolve_output_type("img", 0) == "IMAGE" 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 assert r.finalized_output_count("lat") == 1
def test_slot_fromInput_resolver_picks_by_resolved_type(fake_nodes_module, TypeResolver): def test_slot_byslot_resolver_picks_by_resolved_type(fake_nodes_module, TypeResolver):
fake_nodes_module["SlotFI"] = _make_slot_fi_node() fake_nodes_module["SlotBS"] = _make_slot_byslot_node()
fake_nodes_module["ImageSrc"] = _v1_node(("IMAGE",)) fake_nodes_module["ImageSrc"] = _v1_node(("IMAGE",))
fake_nodes_module["LatentSrc"] = _v1_node(("LATENT",)) fake_nodes_module["LatentSrc"] = _v1_node(("LATENT",))
prompt = { prompt = {
"img_src": {"class_type": "ImageSrc", "inputs": {}}, "img_src": {"class_type": "ImageSrc", "inputs": {}},
"lat_src": {"class_type": "LatentSrc", "inputs": {}}, "lat_src": {"class_type": "LatentSrc", "inputs": {}},
"image_consumer": {"class_type": "SlotFI", "inputs": {"slot": ["img_src", 0]}}, "image_consumer": {"class_type": "SlotBS", "inputs": {"slot": ["img_src", 0]}},
"latent_consumer": {"class_type": "SlotFI", "inputs": {"slot": ["lat_src", 0]}}, "latent_consumer": {"class_type": "SlotBS", "inputs": {"slot": ["lat_src", 0]}},
"unconnected": {"class_type": "SlotFI", "inputs": {}}, "unconnected": {"class_type": "SlotBS", "inputs": {}},
} }
r = TypeResolver(prompt) r = TypeResolver(prompt)
assert r.resolve_output_type("image_consumer", 0) == "IMAGE" assert r.resolve_output_type("image_consumer", 0) == "IMAGE"
assert r.resolve_output_type("image_consumer", 1) == "MASK" assert r.resolve_output_type("image_consumer", 1) == "MASK"
assert r.resolve_output_type("latent_consumer", 0) == "LATENT" 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 assert r.finalized_output_count("unconnected") == 0