@@ -1316,6 +1316,125 @@ def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str,
13161316 out_dict [input_type ][finalized_id ] = value
13171317 out_dict ["dynamic_paths" ][finalized_id ] = finalize_prefix (curr_prefix , curr_prefix [- 1 ])
13181318
1319+ class DynamicInputError (ValueError ):
1320+ def __init__ (self , input_name : str , message : str ):
1321+ super ().__init__ (message )
1322+ self .input_name = input_name
1323+
1324+
1325+ @comfytype (io_type = "COMFY_DYNAMICGROUP_V3" )
1326+ class DynamicGroup (ComfyTypeI ):
1327+ """Repeat a widget template and pass its values to execute as a list of row dicts.
1328+
1329+ Template fields must be widget inputs without force_input or nested dynamic inputs.
1330+ Submit fields as '<group>.<index>.<field>', using indices below max without leading zeros.
1331+ The '<group>.' prefix is reserved for group fields, not separate sibling inputs.
1332+ min/max are integers that count submitted rows (defaults: 0/20), even when optional=True.
1333+ max also bounds the reconstructed list length and cannot exceed 20.
1334+ Each submitted row follows the template's required/optional field declarations.
1335+
1336+ Missing positions are dicts whose fields are None. Missing optional fields are
1337+ also None; widget defaults are not injected. With INPUT_IS_LIST, missing fields
1338+ are [None], matching the lists supplied for submitted fields.
1339+
1340+ Empty groups are [] in execute and check_lazy_status. Nonempty lazy groups
1341+ contain (value, original_key) tuples at each field.
1342+ PriceBadge widget dependencies use the same indexed field names as prompt keys.
1343+ """
1344+
1345+ Type = list [dict [str , Any ]]
1346+ _MaxRows = 20
1347+
1348+ class Input (DynamicInput ):
1349+ def __init__ (self , id : str , template : list [WidgetInput ], min : int = 0 , max : int = 20 ,
1350+ display_name : str = None , optional : bool = False , tooltip : str = None ,
1351+ lazy : bool = None , extra_dict = None , group_name : str = "Group" ):
1352+ super ().__init__ (id , display_name , optional , tooltip , lazy , extra_dict )
1353+ if not template :
1354+ raise ValueError ("DynamicGroup template must have at least one field." )
1355+ for t in template :
1356+ if isinstance (t , DynamicInput ):
1357+ raise ValueError ("Nesting dynamic inputs inside DynamicGroup is not supported." )
1358+ if not isinstance (t , WidgetInput ):
1359+ raise TypeError ("DynamicGroup template fields must be WidgetInputs." )
1360+ if t .force_input :
1361+ raise ValueError (f"DynamicGroup template field '{ t .id } ' must not use force_input." )
1362+ if not t .id or "." in t .id :
1363+ raise ValueError (f"DynamicGroup template field id must be nonempty and must not contain '.'. Got: '{ t .id } '" )
1364+ field_ids = [t .id for t in template ]
1365+ if len (field_ids ) != len (set (field_ids )):
1366+ raise ValueError ("DynamicGroup template field ids must be unique within a row." )
1367+ if not id or "." in id :
1368+ raise ValueError (f"DynamicGroup id must be nonempty and must not contain '.'. Got: '{ id } '" )
1369+ if isinstance (min , bool ) or not isinstance (min , int ) or isinstance (max , bool ) or not isinstance (max , int ):
1370+ raise TypeError ("DynamicGroup min and max must be integers." )
1371+ if not 1 <= max <= DynamicGroup ._MaxRows :
1372+ raise ValueError (f"DynamicGroup max must be between 1 and { DynamicGroup ._MaxRows } ." )
1373+ if not 0 <= min <= max :
1374+ raise ValueError ("DynamicGroup min must be between 0 and max." )
1375+ self .template = template
1376+ self .min = min
1377+ self .max = max
1378+ self .group_name = group_name
1379+
1380+ def get_all (self ) -> list [Input ]:
1381+ return [self ] + list (self .template )
1382+
1383+ def as_dict (self ):
1384+ return super ().as_dict () | prune_dict ({
1385+ "template" : create_input_dict_v1 (self .template ),
1386+ "min" : self .min ,
1387+ "max" : self .max ,
1388+ "group_name" : self .group_name ,
1389+ })
1390+
1391+ def validate (self ):
1392+ for t in self .template :
1393+ t .validate ()
1394+
1395+ @staticmethod
1396+ def _expand_schema_for_dynamic (out_dict : dict [str , Any ], live_inputs : dict [str , Any ], value : tuple [str , dict [str , Any ]], input_type : str , curr_prefix : list [str ] | None ):
1397+ info = value [1 ]
1398+ min_rows = info .get ("min" , 0 )
1399+ max_rows = info .get ("max" , DynamicGroup ._MaxRows )
1400+ template = info .get ("template" , {})
1401+ field_specs = {
1402+ field_id : (field_value , category )
1403+ for category in ("required" , "optional" )
1404+ for field_id , field_value in template .get (category , {}).items ()
1405+ }
1406+ finalized_prefix = finalize_prefix (curr_prefix )
1407+ prefix = finalized_prefix + "."
1408+ present_rows = set ()
1409+ for key in live_inputs :
1410+ if not key .startswith (prefix ):
1411+ continue
1412+ index , separator , field_id = key [len (prefix ):].partition ("." )
1413+ if (not separator or not index .isascii () or not index .isdecimal ()
1414+ or (len (index ) > 1 and index .startswith ("0" )) or field_id not in field_specs ):
1415+ raise DynamicInputError (key , f"Invalid DynamicGroup input key '{ key } '; expected '{ finalized_prefix } .<index>.<template field>'." )
1416+ row = int (index )
1417+ if row >= max_rows :
1418+ raise DynamicInputError (key , f"DynamicGroup input '{ key } ' exceeds the index limit of { max_rows - 1 } (max={ max_rows } )." )
1419+ present_rows .add (row )
1420+
1421+ if not min_rows <= len (present_rows ) <= max_rows :
1422+ raise DynamicInputError (finalized_prefix , f"DynamicGroup input '{ finalized_prefix } ' received { len (present_rows )} rows; expected between { min_rows } and { max_rows } ." )
1423+
1424+ for row in range (max (present_rows , default = - 1 ) + 1 ):
1425+ for field_id , (field_value , category ) in field_specs .items ():
1426+ slot_id = f"{ finalized_prefix } .{ row } .{ field_id } "
1427+ if row in present_rows :
1428+ out_dict [category ][slot_id ] = field_value
1429+ # Preserve gaps without adding validation inputs for them.
1430+ out_dict ["dynamic_paths" ][slot_id ] = slot_id
1431+
1432+ out_dict ["list_paths" ].add (finalized_prefix )
1433+ if not present_rows :
1434+ out_dict ["dynamic_paths" ][finalized_prefix ] = finalized_prefix
1435+ out_dict ["dynamic_paths_default_value" ][finalized_prefix ] = DynamicPathsDefaultValue .EMPTY_LIST
1436+
1437+
13191438@comfytype (io_type = "IMAGECOMPARE" )
13201439class ImageCompare (ComfyTypeI ):
13211440 Type = dict
@@ -1529,6 +1648,8 @@ def setup_dynamic_input_funcs():
15291648 register_dynamic_input_func (DynamicCombo .io_type , DynamicCombo ._expand_schema_for_dynamic )
15301649 # DynamicSlot.Input
15311650 register_dynamic_input_func (DynamicSlot .io_type , DynamicSlot ._expand_schema_for_dynamic )
1651+ # DynamicGroup.Input
1652+ register_dynamic_input_func (DynamicGroup .io_type , DynamicGroup ._expand_schema_for_dynamic )
15321653
15331654if len (DYNAMIC_INPUT_LOOKUP ) == 0 :
15341655 setup_dynamic_input_funcs ()
@@ -1540,6 +1661,8 @@ class V3Data(TypedDict):
15401661 'Dictionary where the keys are the input ids and the values dictate how to turn the inputs into a nested dictionary.'
15411662 dynamic_paths_default_value : dict [str , Any ]
15421663 'Dictionary where the keys are the input ids and the values are a string from DynamicPathsDefaultValue for the inputs if value is None.'
1664+ list_paths : set [str ]
1665+ 'Paths whose index-keyed row dictionaries are reconstructed as lists.'
15431666 create_dynamic_tuple : bool
15441667 'When True, the value of the dynamic input will be in the format (value, path_key).'
15451668
@@ -1651,16 +1774,19 @@ def validate(self) -> None:
16511774 raise ValueError ("PriceBadgeDepends.input_groups must be a list[str]." )
16521775
16531776 def as_dict (self , schema_inputs : list ["Input" ]) -> dict [str , Any ]:
1654- # Build lookup: widget_id -> io_type
16551777 input_types : dict [str , str ] = {}
1656- for inp in schema_inputs :
1657- all_inputs = inp .get_all ()
1658- input_types [inp .id ] = inp .get_io_type () # First input is always the parent itself
1659- for nested_inp in all_inputs [1 :]:
1660- # For DynamicCombo/DynamicSlot, nested inputs are prefixed with parent ID
1661- # to match frontend naming convention (e.g., "should_texture.enable_pbr")
1662- prefixed_id = f"{ inp .id } .{ nested_inp .id } "
1663- input_types [prefixed_id ] = nested_inp .get_io_type ()
1778+
1779+ def collect_inputs (inputs : list [Input ], prefix : str = "" ) -> None :
1780+ for inp in inputs :
1781+ name = prefix + inp .id
1782+ input_types [name ] = inp .get_io_type ()
1783+ if isinstance (inp , DynamicGroup .Input ):
1784+ for row in range (inp .max ):
1785+ collect_inputs (inp .template , f"{ name } .{ row } ." )
1786+ else :
1787+ collect_inputs (inp .get_all ()[1 :], name + "." )
1788+
1789+ collect_inputs (schema_inputs )
16641790
16651791 # Enrich widgets with type information, raising error for unknown widgets
16661792 widgets_data : list [dict [str , str ]] = []
@@ -1888,6 +2014,7 @@ def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], i
18882014 "optional" : {},
18892015 "dynamic_paths" : {},
18902016 "dynamic_paths_default_value" : {},
2017+ "list_paths" : set (),
18912018 }
18922019 d = d .copy ()
18932020 # ignore hidden for parsing
@@ -1899,10 +2026,12 @@ def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], i
18992026 dynamic_paths = out_dict .pop ("dynamic_paths" , None )
19002027 if dynamic_paths is not None and len (dynamic_paths ) > 0 :
19012028 v3_data ["dynamic_paths" ] = dynamic_paths
1902- # this list is used for autogrow, in the case all inputs are optional and no values are passed
19032029 dynamic_paths_default_value = out_dict .pop ("dynamic_paths_default_value" , None )
19042030 if dynamic_paths_default_value is not None and len (dynamic_paths_default_value ) > 0 :
19052031 v3_data ["dynamic_paths_default_value" ] = dynamic_paths_default_value
2032+ list_paths = out_dict .pop ("list_paths" )
2033+ if list_paths :
2034+ v3_data ["list_paths" ] = list_paths
19062035 return out_dict , hidden , v3_data
19072036
19082037def parse_class_inputs (out_dict : dict [str , Any ], live_inputs : dict [str , Any ], curr_dict : dict [str , Any ], curr_prefix : list [str ] | None = None ) -> None :
@@ -1921,11 +2050,23 @@ def parse_class_inputs(out_dict: dict[str, Any], live_inputs: dict[str, Any], cu
19212050 if curr_prefix :
19222051 out_dict ["dynamic_paths" ][finalized_id ] = finalized_id
19232052
2053+ def _dynamic_group_prefixes (inputs : list [Input ]) -> Iterable [str ]:
2054+ for inp in inputs :
2055+ if isinstance (inp , DynamicGroup .Input ):
2056+ yield inp .id + "."
2057+ elif isinstance (inp , DynamicInput ):
2058+ for prefix in _dynamic_group_prefixes (inp .get_all ()[1 :]):
2059+ yield f"{ inp .id } .{ prefix } "
2060+
2061+
19242062def create_input_dict_v1 (inputs : list [Input ]) -> dict :
19252063 input = {
19262064 "required" : {}
19272065 }
2066+ group_prefixes = tuple (_dynamic_group_prefixes (inputs ))
19282067 for i in inputs :
2068+ if group_prefixes and i .id .startswith (group_prefixes ):
2069+ raise ValueError (f"Input '{ i .id } ' conflicts with a DynamicGroup field prefix." )
19292070 add_to_dict_v1 (i , input )
19302071 return input
19312072
@@ -1938,10 +2079,12 @@ def add_to_dict_v1(i: Input, d: dict):
19382079
19392080class DynamicPathsDefaultValue :
19402081 EMPTY_DICT = "empty_dict"
2082+ EMPTY_LIST = "empty_list"
19412083
1942- def build_nested_inputs (values : dict [str , Any ], v3_data : V3Data ):
2084+ def build_nested_inputs (values : dict [str , Any ], v3_data : V3Data , * , input_is_list : bool = False ):
19432085 paths = v3_data .get ("dynamic_paths" , None )
19442086 default_value_dict = v3_data .get ("dynamic_paths_default_value" , {})
2087+ list_paths = v3_data .get ("list_paths" , set ())
19452088 if paths is None :
19462089 return values
19472090 values = values .copy ()
@@ -1958,19 +2101,31 @@ def build_nested_inputs(values: dict[str, Any], v3_data: V3Data):
19582101 is_last = (i == len (parts ) - 1 )
19592102
19602103 if is_last :
2104+ missing = key not in values
19612105 value = values .pop (key , None )
1962- if value is None :
1963- # see if a default value was provided for this key
1964- default_option = default_value_dict .get (key , None )
1965- if default_option == DynamicPathsDefaultValue .EMPTY_DICT :
1966- value = {}
1967- if create_tuple :
2106+ default_option = default_value_dict .get (key , None )
2107+ if default_option == DynamicPathsDefaultValue .EMPTY_LIST :
2108+ # No row keys were submitted, regardless of the root input value.
2109+ value = []
2110+ elif value is None and default_option == DynamicPathsDefaultValue .EMPTY_DICT :
2111+ value = {}
2112+ elif missing and input_is_list and "." .join (parts [:- 2 ]) in list_paths :
2113+ value = [None ]
2114+ if create_tuple and default_option != DynamicPathsDefaultValue .EMPTY_LIST :
19682115 value = (value , key )
19692116 current [p ] = value
19702117 else :
19712118 current = current .setdefault (p , {})
19722119
19732120 values .update (result )
2121+ for list_path in list_paths :
2122+ parts = list_path .split ("." )
2123+ container = values
2124+ for part in parts [:- 1 ]:
2125+ container = container [part ]
2126+ rows = container [parts [- 1 ]]
2127+ if isinstance (rows , dict ):
2128+ container [parts [- 1 ]] = [rows [key ] for key in sorted (rows , key = int )]
19742129 return values
19752130
19762131
@@ -2538,6 +2693,7 @@ def as_dict(self):
25382693 "MatchType" ,
25392694 "DynamicCombo" ,
25402695 "Autogrow" ,
2696+ "DynamicGroup" ,
25412697 # Other classes
25422698 "HiddenHolder" ,
25432699 "Hidden" ,
0 commit comments