From 22d467dc844d04932ef570bca6e08750eafe342a Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 1 Jun 2026 22:39:16 -0700 Subject: [PATCH] DynamicOutputs: replace FromInput with BySlot; outputs always declared in Schema.outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- comfy_api/latest/_io.py | 400 +++++++++--------- comfy_execution/type_resolver.py | 22 +- execution.py | 14 +- .../comfy_api_test/test_dynamic_outputs.py | 233 +++++----- .../test_dynamic_outputs_resolver.py | 81 ++-- 5 files changed, 357 insertions(+), 393 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 52b10de78..d5bf711a3 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -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) diff --git a/comfy_execution/type_resolver.py b/comfy_execution/type_resolver.py index 42d59028b..395a7308d 100644 --- a/comfy_execution/type_resolver.py +++ b/comfy_execution/type_resolver.py @@ -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: diff --git a/execution.py b/execution.py index 4d78cb4d8..32b72e3ab 100644 --- a/execution.py +++ b/execution.py @@ -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 diff --git a/tests-unit/comfy_api_test/test_dynamic_outputs.py b/tests-unit/comfy_api_test/test_dynamic_outputs.py index bb3c13ca2..f15e442cb 100644 --- a/tests-unit/comfy_api_test/test_dynamic_outputs.py +++ b/tests-unit/comfy_api_test/test_dynamic_outputs.py @@ -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 diff --git a/tests-unit/execution_test/test_dynamic_outputs_resolver.py b/tests-unit/execution_test/test_dynamic_outputs_resolver.py index e7c2d5352..da9323812 100644 --- a/tests-unit/execution_test/test_dynamic_outputs_resolver.py +++ b/tests-unit/execution_test/test_dynamic_outputs_resolver.py @@ -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