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