diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index d5bf711a3..26c1add7a 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -2057,11 +2057,22 @@ class Schema: nested_inputs.extend(input.get_all()) input_ids = [i.id for i in nested_inputs] input_set = set(input_ids) - 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) + # Walk DynamicCombo / DynamicSlot options so a DynamicOutputs selector can + # target a nested dynamic input by dotted path (e.g. "outer.inner_slot"). + # Other DynamicInput kinds (e.g. Autogrow) are skipped — their nested ids + # only exist at prompt time and aren't statically addressable. + selector_targets: dict[str, Input] = {} + def _walk(inputs: list[Input], prefix: str | None) -> None: + for inp in inputs: + dotted = inp.id if prefix is None else f"{prefix}.{inp.id}" + selector_targets[dotted] = inp + if isinstance(inp, (DynamicCombo.Input, DynamicSlot.Input)): + for opt in inp.options: + _walk(opt.inputs, dotted) + _walk(self.inputs, None) + combo_inputs = {sid: inp for sid, inp in selector_targets.items() if isinstance(inp, DynamicCombo.Input)} + slot_inputs = {sid: inp for sid, inp in selector_targets.items() if isinstance(inp, DynamicSlot.Input)} + all_selectable_ids = input_set | set(selector_targets) # ``output_ids`` covers every id that may ever appear in a finalized # output list — static outputs + every option's outputs across every diff --git a/tests-unit/comfy_api_test/test_dynamic_outputs.py b/tests-unit/comfy_api_test/test_dynamic_outputs.py index f15e442cb..60d6a20ef 100644 --- a/tests-unit/comfy_api_test/test_dynamic_outputs.py +++ b/tests-unit/comfy_api_test/test_dynamic_outputs.py @@ -440,3 +440,98 @@ def test_v1_info_emits_byslot_entry(): assert entry["selector"] == "slot" whens = [opt["when"] for opt in entry["options"]] assert whens == [["IMAGE"], ["LATENT"], None] + + +# --------------------------------------------------------------------------- +# Nested dynamic selectors (selector reaches into a DynamicCombo / DynamicSlot +# option's nested inputs via a dotted path). +# --------------------------------------------------------------------------- + +def test_schema_accepts_byslot_selector_into_nested_dynamic_slot(): + """A BySlot selector may target a DynamicSlot nested inside a DynamicCombo option.""" + class Nested(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="NestedBySlot", + inputs=[ + io.DynamicCombo.Input("outer", options=[ + io.DynamicCombo.Option(key="a", inputs=[ + io.DynamicSlot.Input("inner_slot", options=[ + io.DynamicSlot.Option(when=io.Image), + io.DynamicSlot.Option(when=None), + ]), + ]), + ]), + ], + outputs=[io.DynamicOutputs.BySlot(id="r", selector="outer.inner_slot", options=[ + io.DynamicOutputs.SlotOption(when=io.Image, outputs=[io.Image.Output("img")]), + io.DynamicOutputs.SlotOption(when=None, outputs=[]), + ])], + ) + + @classmethod + def execute(cls, **kwargs): + return io.NodeOutput.from_named({}) + + # Must not raise — selector resolves to the nested DynamicSlot via dotted path. + Nested.GET_SCHEMA() + + +def test_schema_accepts_bykey_selector_into_nested_dynamic_combo(): + """A ByKey selector may target a DynamicCombo nested inside another DynamicCombo option.""" + class Nested(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="NestedByKey", + inputs=[ + io.DynamicCombo.Input("outer", options=[ + io.DynamicCombo.Option(key="branch", inputs=[ + io.DynamicCombo.Input("inner", options=[ + io.DynamicCombo.Option(key="x", inputs=[]), + io.DynamicCombo.Option(key="y", inputs=[]), + ]), + ]), + ]), + ], + outputs=[io.DynamicOutputs.ByKey(id="r", selector="outer.inner", options=[ + io.DynamicOutputs.Option(key="x", outputs=[io.Int.Output("ix")]), + io.DynamicOutputs.Option(key="y", outputs=[io.String.Output("iy")]), + ])], + ) + + @classmethod + def execute(cls, **kwargs): + return io.NodeOutput.from_named({}) + + Nested.GET_SCHEMA() + + +def test_schema_rejects_byslot_nested_when_type_not_on_target_slot(): + """The when-type alignment check follows the dotted selector into the nested slot.""" + class BadNested(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="BadNested", + inputs=[ + io.DynamicCombo.Input("outer", options=[ + io.DynamicCombo.Option(key="a", inputs=[ + io.DynamicSlot.Input("inner_slot", options=[ + io.DynamicSlot.Option(when=io.Image), + ]), + ]), + ]), + ], + outputs=[io.DynamicOutputs.BySlot(id="r", selector="outer.inner_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=r"type\(s\) \['AUDIO'\] are not accepted"): + BadNested.GET_SCHEMA()