mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-06-25 17:29:24 +08:00
Rename and refactor io.List to io.DynamicGroup
This commit is contained in:
parent
708e09b2b2
commit
abd185c0ae
@ -1253,15 +1253,15 @@ class DynamicSlot(ComfyTypeI):
|
|||||||
out_dict[input_type][finalized_id] = value
|
out_dict[input_type][finalized_id] = value
|
||||||
out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1])
|
out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1])
|
||||||
|
|
||||||
@comfytype(io_type="COMFY_LIST_V3")
|
@comfytype(io_type="COMFY_DYNAMICGROUP_V3")
|
||||||
class List(ComfyTypeI):
|
class DynamicGroup(ComfyTypeI):
|
||||||
"""A repeatable group of widget inputs (e.g. lora_name + strength stacked into N rows).
|
"""A repeatable group of widget inputs (e.g. lora_name + strength stacked into N rows).
|
||||||
|
|
||||||
At execution time the node receives a ``list[dict]`` where each element is a row.
|
At execution time the node receives a ``list[dict]`` where each element is a row.
|
||||||
|
|
||||||
Example::
|
Example::
|
||||||
|
|
||||||
io.List.Input(
|
io.DynamicGroup.Input(
|
||||||
"loras",
|
"loras",
|
||||||
template=[
|
template=[
|
||||||
io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras")),
|
io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras")),
|
||||||
@ -1291,25 +1291,25 @@ class List(ComfyTypeI):
|
|||||||
):
|
):
|
||||||
super().__init__(id, display_name, optional, tooltip, lazy, extra_dict)
|
super().__init__(id, display_name, optional, tooltip, lazy, extra_dict)
|
||||||
# Validate template entries: only WidgetInput subclasses, no nesting
|
# Validate template entries: only WidgetInput subclasses, no nesting
|
||||||
assert len(template) > 0, "List template must have at least one field."
|
assert len(template) > 0, "DynamicGroup template must have at least one field."
|
||||||
for t in template:
|
for t in template:
|
||||||
assert isinstance(t, WidgetInput), (
|
assert isinstance(t, WidgetInput), (
|
||||||
f"List template field '{t.id}' must be a WidgetInput subclass "
|
f"DynamicGroup template field '{t.id}' must be a WidgetInput subclass "
|
||||||
f"(Combo, Float, Int, String, Boolean, Color). Got {type(t).__name__}."
|
f"(Combo, Float, Int, String, Boolean, Color). Got {type(t).__name__}."
|
||||||
)
|
)
|
||||||
assert not isinstance(t, DynamicInput), (
|
assert not isinstance(t, DynamicInput), (
|
||||||
f"List template field '{t.id}' must not be a DynamicInput. "
|
f"DynamicGroup template field '{t.id}' must not be a DynamicInput. "
|
||||||
"Nesting dynamic inputs inside List is not supported."
|
"Nesting dynamic inputs inside DynamicGroup is not supported."
|
||||||
)
|
)
|
||||||
# Enforce unique field ids within template
|
# Enforce unique field ids within template
|
||||||
field_ids = [t.id for t in template]
|
field_ids = [t.id for t in template]
|
||||||
assert len(field_ids) == len(set(field_ids)), (
|
assert len(field_ids) == len(set(field_ids)), (
|
||||||
f"List template field ids must be unique within a row. Got: {field_ids}"
|
f"DynamicGroup template field ids must be unique within a row. Got: {field_ids}"
|
||||||
)
|
)
|
||||||
assert min >= 0, "List min must be >= 0."
|
assert min >= 0, "DynamicGroup min must be >= 0."
|
||||||
assert max >= 1, "List max must be >= 1."
|
assert max >= 1, "DynamicGroup max must be >= 1."
|
||||||
assert max <= List._MaxRows, f"List max must be <= {List._MaxRows}."
|
assert max <= DynamicGroup._MaxRows, f"DynamicGroup max must be <= {DynamicGroup._MaxRows}."
|
||||||
assert min <= max, "List min must be <= max."
|
assert min <= max, "DynamicGroup min must be <= max."
|
||||||
self.template = template
|
self.template = template
|
||||||
self.min = min
|
self.min = min
|
||||||
self.max = max
|
self.max = max
|
||||||
@ -1338,7 +1338,7 @@ class List(ComfyTypeI):
|
|||||||
):
|
):
|
||||||
info = value[1]
|
info = value[1]
|
||||||
min_rows: int = info.get("min", 0)
|
min_rows: int = info.get("min", 0)
|
||||||
max_rows: int = info.get("max", List._MaxRows)
|
max_rows: int = info.get("max", DynamicGroup._MaxRows)
|
||||||
template: dict[str, Any] = info.get("template", {})
|
template: dict[str, Any] = info.get("template", {})
|
||||||
|
|
||||||
# Collect all template field specs across required/optional sections
|
# Collect all template field specs across required/optional sections
|
||||||
@ -1366,7 +1366,7 @@ class List(ComfyTypeI):
|
|||||||
|
|
||||||
if present_rows > max_rows:
|
if present_rows > max_rows:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"List input '{finalized_prefix}' received {present_rows} rows but max is {max_rows}."
|
f"DynamicGroup input '{finalized_prefix}' received {present_rows} rows but max is {max_rows}."
|
||||||
)
|
)
|
||||||
row_count = max(min_rows, present_rows)
|
row_count = max(min_rows, present_rows)
|
||||||
|
|
||||||
@ -1522,8 +1522,8 @@ def setup_dynamic_input_funcs():
|
|||||||
register_dynamic_input_func(DynamicCombo.io_type, DynamicCombo._expand_schema_for_dynamic)
|
register_dynamic_input_func(DynamicCombo.io_type, DynamicCombo._expand_schema_for_dynamic)
|
||||||
# DynamicSlot.Input
|
# DynamicSlot.Input
|
||||||
register_dynamic_input_func(DynamicSlot.io_type, DynamicSlot._expand_schema_for_dynamic)
|
register_dynamic_input_func(DynamicSlot.io_type, DynamicSlot._expand_schema_for_dynamic)
|
||||||
# List.Input
|
# DynamicGroup.Input
|
||||||
register_dynamic_input_func(List.io_type, List._expand_schema_for_dynamic)
|
register_dynamic_input_func(DynamicGroup.io_type, DynamicGroup._expand_schema_for_dynamic)
|
||||||
|
|
||||||
if len(DYNAMIC_INPUT_LOOKUP) == 0:
|
if len(DYNAMIC_INPUT_LOOKUP) == 0:
|
||||||
setup_dynamic_input_funcs()
|
setup_dynamic_input_funcs()
|
||||||
@ -1963,7 +1963,7 @@ def build_nested_inputs(values: dict[str, Any], v3_data: V3Data):
|
|||||||
|
|
||||||
values.update(result)
|
values.update(result)
|
||||||
|
|
||||||
# Post-pass: convert index-keyed dicts to sorted lists for io.List fields
|
# Post-pass: convert index-keyed dicts to sorted lists for io.DynamicGroup fields
|
||||||
for list_path in list_paths:
|
for list_path in list_paths:
|
||||||
parts = list_path.split(".")
|
parts = list_path.split(".")
|
||||||
# Navigate to the parent container, then convert the leaf
|
# Navigate to the parent container, then convert the leaf
|
||||||
@ -2554,7 +2554,7 @@ __all__ = [
|
|||||||
"DynamicCombo",
|
"DynamicCombo",
|
||||||
"DynamicSlot",
|
"DynamicSlot",
|
||||||
"Autogrow",
|
"Autogrow",
|
||||||
"List",
|
"DynamicGroup",
|
||||||
# Other classes
|
# Other classes
|
||||||
"HiddenHolder",
|
"HiddenHolder",
|
||||||
"Hidden",
|
"Hidden",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Unit tests for io.List: expansion/reconstruction (0-row and N-row cases)."""
|
"""Unit tests for io.DynamicGroup: expansion/reconstruction (0-row and N-row cases)."""
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
import pytest
|
import pytest
|
||||||
@ -10,7 +10,7 @@ if "torch" not in sys.modules:
|
|||||||
sys.modules["torch"] = _torch_stub
|
sys.modules["torch"] = _torch_stub
|
||||||
|
|
||||||
from comfy_api.latest._io import ( # noqa: E402
|
from comfy_api.latest._io import ( # noqa: E402
|
||||||
List,
|
DynamicGroup,
|
||||||
Float,
|
Float,
|
||||||
Int,
|
Int,
|
||||||
String,
|
String,
|
||||||
@ -29,12 +29,12 @@ setup_dynamic_input_funcs()
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _make_class_inputs(list_input: List.Input) -> dict:
|
def _make_class_inputs(group_input: DynamicGroup.Input) -> dict:
|
||||||
"""Wrap a List.Input into the required/optional dict structure."""
|
"""Wrap a DynamicGroup.Input into the required/optional dict structure."""
|
||||||
return create_input_dict_v1([list_input])
|
return create_input_dict_v1([group_input])
|
||||||
|
|
||||||
|
|
||||||
def _run(list_input: List.Input, live_values: dict) -> dict:
|
def _run(group_input: DynamicGroup.Input, live_values: dict) -> dict:
|
||||||
"""End-to-end helper: expand schema + reconstruct values.
|
"""End-to-end helper: expand schema + reconstruct values.
|
||||||
|
|
||||||
Mirrors the production split in execution.py:
|
Mirrors the production split in execution.py:
|
||||||
@ -44,7 +44,7 @@ def _run(list_input: List.Input, live_values: dict) -> dict:
|
|||||||
The two steps are separate in production because the engine resolves
|
The two steps are separate in production because the engine resolves
|
||||||
linked node outputs between them, but in tests we supply values directly.
|
linked node outputs between them, but in tests we supply values directly.
|
||||||
"""
|
"""
|
||||||
class_inputs = _make_class_inputs(list_input)
|
class_inputs = _make_class_inputs(group_input)
|
||||||
_, _, v3_data = get_finalized_class_inputs(class_inputs, live_values)
|
_, _, v3_data = get_finalized_class_inputs(class_inputs, live_values)
|
||||||
return build_nested_inputs(dict(live_values), v3_data)
|
return build_nested_inputs(dict(live_values), v3_data)
|
||||||
|
|
||||||
@ -53,9 +53,9 @@ def _run(list_input: List.Input, live_values: dict) -> dict:
|
|||||||
# Schema construction
|
# Schema construction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestListInputConstruction:
|
class TestDynamicGroupInputConstruction:
|
||||||
def test_basic_construction(self):
|
def test_basic_construction(self):
|
||||||
inp = List.Input(
|
inp = DynamicGroup.Input(
|
||||||
"loras",
|
"loras",
|
||||||
template=[
|
template=[
|
||||||
Float.Input("strength", default=1.0),
|
Float.Input("strength", default=1.0),
|
||||||
@ -70,7 +70,7 @@ class TestListInputConstruction:
|
|||||||
assert len(inp.template) == 2
|
assert len(inp.template) == 2
|
||||||
|
|
||||||
def test_get_all_includes_self_and_template(self):
|
def test_get_all_includes_self_and_template(self):
|
||||||
inp = List.Input(
|
inp = DynamicGroup.Input(
|
||||||
"items",
|
"items",
|
||||||
template=[Float.Input("value")],
|
template=[Float.Input("value")],
|
||||||
)
|
)
|
||||||
@ -79,7 +79,7 @@ class TestListInputConstruction:
|
|||||||
assert all_inputs[1].id == "value"
|
assert all_inputs[1].id == "value"
|
||||||
|
|
||||||
def test_as_dict_has_template_min_max(self):
|
def test_as_dict_has_template_min_max(self):
|
||||||
inp = List.Input(
|
inp = DynamicGroup.Input(
|
||||||
"items",
|
"items",
|
||||||
template=[Float.Input("val", default=0.5)],
|
template=[Float.Input("val", default=0.5)],
|
||||||
min=1,
|
min=1,
|
||||||
@ -92,32 +92,32 @@ class TestListInputConstruction:
|
|||||||
|
|
||||||
def test_duplicate_field_ids_raises(self):
|
def test_duplicate_field_ids_raises(self):
|
||||||
with pytest.raises(AssertionError):
|
with pytest.raises(AssertionError):
|
||||||
List.Input(
|
DynamicGroup.Input(
|
||||||
"bad",
|
"bad",
|
||||||
template=[Float.Input("x"), Float.Input("x")],
|
template=[Float.Input("x"), Float.Input("x")],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_empty_template_raises(self):
|
def test_empty_template_raises(self):
|
||||||
with pytest.raises(AssertionError):
|
with pytest.raises(AssertionError):
|
||||||
List.Input("bad", template=[])
|
DynamicGroup.Input("bad", template=[])
|
||||||
|
|
||||||
def test_min_gt_max_raises(self):
|
def test_min_gt_max_raises(self):
|
||||||
with pytest.raises(AssertionError):
|
with pytest.raises(AssertionError):
|
||||||
List.Input("bad", template=[Float.Input("x")], min=5, max=3)
|
DynamicGroup.Input("bad", template=[Float.Input("x")], min=5, max=3)
|
||||||
|
|
||||||
def test_max_exceeds_limit_raises(self):
|
def test_max_exceeds_limit_raises(self):
|
||||||
with pytest.raises(AssertionError):
|
with pytest.raises(AssertionError):
|
||||||
List.Input("bad", template=[Float.Input("x")], max=101)
|
DynamicGroup.Input("bad", template=[Float.Input("x")], max=101)
|
||||||
|
|
||||||
def test_dynamic_input_in_template_raises(self):
|
def test_dynamic_input_in_template_raises(self):
|
||||||
with pytest.raises(AssertionError):
|
with pytest.raises(AssertionError):
|
||||||
List.Input(
|
DynamicGroup.Input(
|
||||||
"bad",
|
"bad",
|
||||||
template=[List.Input("nested", template=[Float.Input("x")])],
|
template=[DynamicGroup.Input("nested", template=[Float.Input("x")])],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_validate_calls_through(self):
|
def test_validate_calls_through(self):
|
||||||
inp = List.Input("items", template=[Float.Input("val", min=-1.0, max=1.0)])
|
inp = DynamicGroup.Input("items", template=[Float.Input("val", min=-1.0, max=1.0)])
|
||||||
inp.validate() # should not raise
|
inp.validate() # should not raise
|
||||||
|
|
||||||
|
|
||||||
@ -128,12 +128,12 @@ class TestListInputConstruction:
|
|||||||
class TestZeroRows:
|
class TestZeroRows:
|
||||||
def test_empty_live_inputs_produces_empty_list(self):
|
def test_empty_live_inputs_produces_empty_list(self):
|
||||||
"""With min=0 and no live values, the result should be an empty list."""
|
"""With min=0 and no live values, the result should be an empty list."""
|
||||||
inp = List.Input("loras", template=[Float.Input("strength", default=1.0)], min=0, max=10)
|
inp = DynamicGroup.Input("loras", template=[Float.Input("strength", default=1.0)], min=0, max=10)
|
||||||
assert _run(inp, {}).get("loras") == []
|
assert _run(inp, {}).get("loras") == []
|
||||||
|
|
||||||
def test_min_zero_with_values(self):
|
def test_min_zero_with_values(self):
|
||||||
"""min=0 but 2 rows of live data."""
|
"""min=0 but 2 rows of live data."""
|
||||||
inp = List.Input("loras", template=[Float.Input("strength", default=1.0)], min=0, max=10)
|
inp = DynamicGroup.Input("loras", template=[Float.Input("strength", default=1.0)], min=0, max=10)
|
||||||
result = _run(inp, {"loras.0.strength": 0.8, "loras.1.strength": 0.5})
|
result = _run(inp, {"loras.0.strength": 0.8, "loras.1.strength": 0.5})
|
||||||
assert result["loras"] == [{"strength": 0.8}, {"strength": 0.5}]
|
assert result["loras"] == [{"strength": 0.8}, {"strength": 0.5}]
|
||||||
|
|
||||||
@ -145,7 +145,7 @@ class TestZeroRows:
|
|||||||
class TestNRows:
|
class TestNRows:
|
||||||
def test_two_rows_two_fields(self):
|
def test_two_rows_two_fields(self):
|
||||||
"""Two rows with two fields each produce a list[dict]."""
|
"""Two rows with two fields each produce a list[dict]."""
|
||||||
inp = List.Input(
|
inp = DynamicGroup.Input(
|
||||||
"loras",
|
"loras",
|
||||||
template=[String.Input("lora_name"), Float.Input("strength", default=1.0)],
|
template=[String.Input("lora_name"), Float.Input("strength", default=1.0)],
|
||||||
min=0, max=50,
|
min=0, max=50,
|
||||||
@ -161,13 +161,13 @@ class TestNRows:
|
|||||||
|
|
||||||
def test_rows_are_sorted_by_index(self):
|
def test_rows_are_sorted_by_index(self):
|
||||||
"""Rows must be in ascending index order even if dict iteration is unordered."""
|
"""Rows must be in ascending index order even if dict iteration is unordered."""
|
||||||
inp = List.Input("items", template=[Int.Input("v", default=0)], min=0, max=10)
|
inp = DynamicGroup.Input("items", template=[Int.Input("v", default=0)], min=0, max=10)
|
||||||
result = _run(inp, {"items.0.v": 10, "items.2.v": 30, "items.1.v": 20})
|
result = _run(inp, {"items.0.v": 10, "items.2.v": 30, "items.1.v": 20})
|
||||||
assert [row["v"] for row in result["items"]] == [10, 20, 30]
|
assert [row["v"] for row in result["items"]] == [10, 20, 30]
|
||||||
|
|
||||||
def test_min_rows_schema_slots(self):
|
def test_min_rows_schema_slots(self):
|
||||||
"""With min=2 and no live data, 2 slots must appear in the expanded schema."""
|
"""With min=2 and no live data, 2 slots must appear in the expanded schema."""
|
||||||
inp = List.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
|
inp = DynamicGroup.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
|
||||||
out, _, _ = get_finalized_class_inputs(_make_class_inputs(inp), {})
|
out, _, _ = get_finalized_class_inputs(_make_class_inputs(inp), {})
|
||||||
all_slots = {**out.get("required", {}), **out.get("optional", {})}
|
all_slots = {**out.get("required", {}), **out.get("optional", {})}
|
||||||
assert "items.0.val" in all_slots
|
assert "items.0.val" in all_slots
|
||||||
@ -176,28 +176,28 @@ class TestNRows:
|
|||||||
def test_min_rows_reconstructs_when_no_values(self):
|
def test_min_rows_reconstructs_when_no_values(self):
|
||||||
"""min=2 with NO live values must still yield a 2-element list,
|
"""min=2 with NO live values must still yield a 2-element list,
|
||||||
not collapse to [] (regression: parent-path clobber)."""
|
not collapse to [] (regression: parent-path clobber)."""
|
||||||
inp = List.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
|
inp = DynamicGroup.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
|
||||||
result = _run(inp, {})
|
result = _run(inp, {})
|
||||||
assert len(result["items"]) == 2
|
assert len(result["items"]) == 2
|
||||||
assert all("val" in row for row in result["items"])
|
assert all("val" in row for row in result["items"])
|
||||||
|
|
||||||
def test_min_rows_reconstructs_with_partial_values(self):
|
def test_min_rows_reconstructs_with_partial_values(self):
|
||||||
"""min=2 with only the first row's value present still yields 2 rows."""
|
"""min=2 with only the first row's value present still yields 2 rows."""
|
||||||
inp = List.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
|
inp = DynamicGroup.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
|
||||||
result = _run(inp, {"items.0.val": 0.7})
|
result = _run(inp, {"items.0.val": 0.7})
|
||||||
assert len(result["items"]) == 2
|
assert len(result["items"]) == 2
|
||||||
assert result["items"][0]["val"] == 0.7
|
assert result["items"][0]["val"] == 0.7
|
||||||
assert result["items"][1]["val"] is None
|
assert result["items"][1]["val"] is None
|
||||||
|
|
||||||
def test_list_paths_in_v3_data(self):
|
def test_list_paths_in_v3_data(self):
|
||||||
"""list_paths must contain the list id so build_nested_inputs knows to convert."""
|
"""list_paths must contain the group id so build_nested_inputs knows to convert."""
|
||||||
inp = List.Input("things", template=[Boolean.Input("flag")], min=0, max=5)
|
inp = DynamicGroup.Input("things", template=[Boolean.Input("flag")], min=0, max=5)
|
||||||
_, _, v3_data = get_finalized_class_inputs(_make_class_inputs(inp), {})
|
_, _, v3_data = get_finalized_class_inputs(_make_class_inputs(inp), {})
|
||||||
assert "things" in v3_data.get("list_paths", set())
|
assert "things" in v3_data.get("list_paths", set())
|
||||||
|
|
||||||
def test_no_leftover_flat_keys(self):
|
def test_no_leftover_flat_keys(self):
|
||||||
"""Flat keys must be consumed; only the reconstructed list remains."""
|
"""Flat keys must be consumed; only the reconstructed list remains."""
|
||||||
inp = List.Input("rows", template=[Float.Input("x", default=0.0)], min=0, max=5)
|
inp = DynamicGroup.Input("rows", template=[Float.Input("x", default=0.0)], min=0, max=5)
|
||||||
result = _run(inp, {"rows.0.x": 1.0, "rows.1.x": 2.0})
|
result = _run(inp, {"rows.0.x": 1.0, "rows.1.x": 2.0})
|
||||||
assert "rows.0.x" not in result
|
assert "rows.0.x" not in result
|
||||||
assert "rows.1.x" not in result
|
assert "rows.1.x" not in result
|
||||||
Loading…
Reference in New Issue
Block a user