mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-24 18:10:27 +08:00
DynamicOutputs: tighter integration with DynamicCombo / DynamicSlot via FromInput
Lets dynamic-input nodes co-declare per-option outputs so the option list is a single source of truth for both inputs and outputs. * DynamicCombo.Option / DynamicSlot.Option gain an optional outputs=[...] list. * DynamicOutputs.FromInput(input_id) is a positional placeholder in outputs[] that resolves to the referenced input's active-option outputs at finalize time. DynamicCombo selects by literal value; DynamicSlot selects by the upstream slot's resolved type (or when=None when unlinked). * get_finalized_class_outputs gains schema_inputs / live_input_types and the TypeResolver / execute() compute live_input_types only when at least one FromInput → DynamicSlot is present. * Schema.validate(): FromInput must reference an existing DynamicCombo / DynamicSlot input, each input may be referenced at most once, and option output ids stay globally unique. * V1 info synthesizes the dynamic_outputs entry as kind='by_key' for combos and kind='by_slot' for slots, inlining the option outputs. Tests: combo + slot FromInput finalization, FromInput validation (missing / duplicate / id collision), and TypeResolver end-to-end picks for both kinds. Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user