diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index cdaaba998..4115bdcdd 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1316,6 +1316,125 @@ class DynamicSlot(ComfyTypeI): out_dict[input_type][finalized_id] = value out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1]) +class DynamicInputError(ValueError): + def __init__(self, input_name: str, message: str): + super().__init__(message) + self.input_name = input_name + + +@comfytype(io_type="COMFY_DYNAMICGROUP_V3") +class DynamicGroup(ComfyTypeI): + """Repeat a widget template and pass its values to execute as a list of row dicts. + + Template fields must be widget inputs without force_input or nested dynamic inputs. + Submit fields as '..', using indices below max without leading zeros. + The '.' prefix is reserved for group fields, not separate sibling inputs. + min/max are integers that count submitted rows (defaults: 0/20), even when optional=True. + max also bounds the reconstructed list length and cannot exceed 20. + Each submitted row follows the template's required/optional field declarations. + + Missing positions are dicts whose fields are None. Missing optional fields are + also None; widget defaults are not injected. With INPUT_IS_LIST, missing fields + are [None], matching the lists supplied for submitted fields. + + Empty groups are [] in execute and check_lazy_status. Nonempty lazy groups + contain (value, original_key) tuples at each field. + PriceBadge widget dependencies use the same indexed field names as prompt keys. + """ + + Type = list[dict[str, Any]] + _MaxRows = 20 + + class Input(DynamicInput): + def __init__(self, id: str, template: list[WidgetInput], min: int=0, max: int=20, + display_name: str=None, optional: bool=False, tooltip: str=None, + lazy: bool=None, extra_dict=None, group_name: str="Group"): + super().__init__(id, display_name, optional, tooltip, lazy, extra_dict) + if not template: + raise ValueError("DynamicGroup template must have at least one field.") + for t in template: + if isinstance(t, DynamicInput): + raise ValueError("Nesting dynamic inputs inside DynamicGroup is not supported.") + if not isinstance(t, WidgetInput): + raise TypeError("DynamicGroup template fields must be WidgetInputs.") + if t.force_input: + raise ValueError(f"DynamicGroup template field '{t.id}' must not use force_input.") + if not t.id or "." in t.id: + raise ValueError(f"DynamicGroup template field id must be nonempty and must not contain '.'. Got: '{t.id}'") + field_ids = [t.id for t in template] + if len(field_ids) != len(set(field_ids)): + raise ValueError("DynamicGroup template field ids must be unique within a row.") + if not id or "." in id: + raise ValueError(f"DynamicGroup id must be nonempty and must not contain '.'. Got: '{id}'") + if isinstance(min, bool) or not isinstance(min, int) or isinstance(max, bool) or not isinstance(max, int): + raise TypeError("DynamicGroup min and max must be integers.") + if not 1 <= max <= DynamicGroup._MaxRows: + raise ValueError(f"DynamicGroup max must be between 1 and {DynamicGroup._MaxRows}.") + if not 0 <= min <= max: + raise ValueError("DynamicGroup min must be between 0 and max.") + self.template = template + self.min = min + self.max = max + self.group_name = group_name + + def get_all(self) -> list[Input]: + return [self] + list(self.template) + + def as_dict(self): + return super().as_dict() | prune_dict({ + "template": create_input_dict_v1(self.template), + "min": self.min, + "max": self.max, + "group_name": self.group_name, + }) + + def validate(self): + for t in self.template: + t.validate() + + @staticmethod + 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): + info = value[1] + min_rows = info.get("min", 0) + max_rows = info.get("max", DynamicGroup._MaxRows) + template = info.get("template", {}) + field_specs = { + field_id: (field_value, category) + for category in ("required", "optional") + for field_id, field_value in template.get(category, {}).items() + } + finalized_prefix = finalize_prefix(curr_prefix) + prefix = finalized_prefix + "." + present_rows = set() + for key in live_inputs: + if not key.startswith(prefix): + continue + index, separator, field_id = key[len(prefix):].partition(".") + if (not separator or not index.isascii() or not index.isdecimal() + or (len(index) > 1 and index.startswith("0")) or field_id not in field_specs): + raise DynamicInputError(key, f"Invalid DynamicGroup input key '{key}'; expected '{finalized_prefix}..