diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index d2c941e6..d0a77e88 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -50,6 +50,10 @@ class Port: is_link: bool = False enum_values: list[Any] = field(default_factory=list) # preserves the option's real type (int combos stay int) options: PortOptions = field(default_factory=PortOptions) + # V3 dynamic combos (COMFY_DYNAMICCOMBO_V3): the raw option dicts + # ([{"key": ..., "inputs": {...}}]) retained for sub-input expansion. + # enum_values carries just the selector keys. + dynamic_options: list[dict] = field(default_factory=list) @property def is_autogrow(self) -> bool: @@ -195,10 +199,21 @@ def can_apply(self, available: set[str]) -> bool: # --------------------------------------------------------------------------- +def _is_dynamic_combo_type(type_id: str) -> bool: + """V3 dynamic-combo types (e.g. ``COMFY_DYNAMICCOMBO_V3``): a selector + widget whose chosen option contributes its own sub-inputs. Same rule the + UI→API converter applies (``workflow_to_api._is_widget_input``).""" + return type_id.startswith("COMFY_") and "COMBO" in type_id + + def _is_link(type_id: str, is_enum: bool, force_input: bool) -> bool: """Determine if an input participates in typed wiring (link) or is inline (widget).""" if is_enum: return False + # A dynamic combo is a widget port even when its options block is missing + # or malformed — the frontend always renders the selector inline. + if _is_dynamic_combo_type(type_id): + return False if type_id in _IMPLICIT_WIDGET_TYPES and not force_input and type_id != "*": return False return True @@ -242,22 +257,30 @@ def _control_after_generate_set(val: Any) -> bool: return True -def _parse_input_spec(spec: Any) -> tuple[str, bool, list[Any], PortOptions]: - """Returns (type_id, is_enum, enum_values, options).""" +def _parse_input_spec(spec: Any) -> tuple[str, bool, list[Any], PortOptions, list[dict]]: + """Returns (type_id, is_enum, enum_values, options, dynamic_options).""" if isinstance(spec, str): - return spec, False, [], PortOptions() + return spec, False, [], PortOptions(), [] if not isinstance(spec, list) or len(spec) == 0: - return "UNKNOWN", False, [], PortOptions() + return "UNKNOWN", False, [], PortOptions(), [] opts_raw = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {} port_opts = _parse_port_options(opts_raw) first = spec[0] if isinstance(first, str): + if _is_dynamic_combo_type(first): + # V3 dynamic combo: options are [{"key": ..., "inputs": {...}}] + # dicts, NOT membership choices. Expose the keys as the selector's + # enum and retain the raw option dicts so slot extraction / writes + # can expand the selected option's sub-inputs. + options = opts_raw.get("options") + dyn = [o for o in options if isinstance(o, dict) and "key" in o] if isinstance(options, list) else [] + return first, True, [o["key"] for o in dyn], port_opts, dyn # V3 / partner-API combo dialect: the type is the literal string - # "COMBO" (or a dynamic-combo type) and the choices live in the - # options dict, e.g. ["COMBO", {"options": ["480p", "720p"]}]. + # "COMBO" and the choices live in the options dict, e.g. + # ["COMBO", {"options": ["480p", "720p"]}]. # Without this, dict-form combos lose their enum and validate can't # enum-check them — exactly the partner nodes (ByteDance, BFL, …) # where the choices array is the precision check. @@ -266,14 +289,14 @@ def _parse_input_spec(spec: Any) -> tuple[str, bool, list[Any], PortOptions]: # Keep each option's real type: an int-valued combo (Sora-2/LTXV # `duration`) must stay [4, 8, 12], not ["4","8","12"], so `nodes # show` is truthful and agents pass the type the cloud accepts. - return first, True, list(options), port_opts - return first, False, [], port_opts + return first, True, list(options), port_opts, [] + return first, False, [], port_opts, [] if isinstance(first, list): # Same: preserve the option types for the classic list-form combo. - return "COMBO", True, list(first), port_opts + return "COMBO", True, list(first), port_opts, [] - return "UNKNOWN", False, [], port_opts + return "UNKNOWN", False, [], port_opts, [] def _is_scalar_choice(v: Any) -> bool: @@ -301,7 +324,7 @@ def _parse_inputs(raw: dict, order: list[str] | None, required: bool) -> list[Po ports: list[Port] = [] for name in _ordered_names(raw, order): spec = raw[name] - type_id, is_enum, enum_values, opts = _parse_input_spec(spec) + type_id, is_enum, enum_values, opts, dynamic_options = _parse_input_spec(spec) ports.append( Port( name=name, @@ -310,6 +333,7 @@ def _parse_inputs(raw: dict, order: list[str] | None, required: bool) -> list[Po is_link=_is_link(type_id, is_enum, opts.force_input), enum_values=enum_values, options=opts, + dynamic_options=dynamic_options, ) ) return ports @@ -696,6 +720,10 @@ def category_tree(self) -> dict: # -- Widget order -- def widget_order(self, class_name: str) -> list[str]: + """Value-independent widget order. A dynamic combo contributes only its + selector slot; use ``widget_order_for_node`` when the node's current + ``widgets_values`` are available so the selected option's sub-inputs + expand into their real positions.""" m = self._nodes.get(class_name) if m is None: return [] @@ -708,6 +736,17 @@ def widget_order(self, class_name: str) -> list[str]: order.append("control_after_generate") return order + def widget_order_for_node(self, class_name: str, widgets_values: list[Any] | None) -> list[str]: + """Value-aware widget order: like ``widget_order`` but at each dynamic + combo the current selector (read from its positional slot) picks an + option whose widget-like sub-inputs are expanded in place as + ``.`` — matching how the frontend inlines the selected + option's sub-values into the flat positional ``widgets_values``.""" + m = self._nodes.get(class_name) + if m is None: + return [] + return [e.name for e in _expand_widget_entries(m, widgets_values or [])] + # -- Validation -- def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: @@ -1335,38 +1374,128 @@ def _subgraph_defs_by_id(workflow: dict) -> dict[str, dict]: return by_id +# A dynamic combo may nest another dynamic combo among its sub-inputs. Real +# schemas are one or two levels deep; the cap defends the expansion walk +# against a pathological/malicious object_info entry. +_MAX_DYNAMIC_COMBO_DEPTH = 16 + + +@dataclass +class _WidgetEntry: + """One positional ``widgets_values`` slot in a node's value-aware order. + + ``port`` is ``None`` for a ``control_after_generate`` marker slot (it has + no schema port). ``owner`` is the dotted name of the dynamic combo whose + selected option contributed this entry (``None`` for top-level inputs) — + used to size a combo's sub-span when its selector changes. + """ + + name: str + port: Port | None + owner: str | None + + +def _sub_port_from_spec(name: str, sub_spec: Any, required: bool) -> Port: + """Build a Port for a dynamic-combo option sub-input, applying the same + widget-vs-link rules as top-level parsing.""" + type_id, is_enum, enum_values, opts, dynamic_options = _parse_input_spec(sub_spec) + return Port( + name=name, + type=type_id, + required=required, + is_link=_is_link(type_id, is_enum, opts.force_input), + enum_values=enum_values, + options=opts, + dynamic_options=dynamic_options, + ) + + +def _dynamic_combo_sub_ports(dynamic_options: list[dict], selector: Any, prefix: str) -> list[Port]: + """The selected option's sub-inputs as Ports, dotted under ``prefix``. + + Returns ``[]`` when the selector matches no option or the option block is + malformed. Connection-only sub-inputs (e.g. ``COMFY_AUTOGROW_V3`` image + lists) are included with ``is_link=True`` so callers can skip them. + """ + option = next((o for o in dynamic_options if o.get("key") == selector), None) + if option is None: + return [] + sub_def = option.get("inputs") + if not isinstance(sub_def, dict): + return [] + ports: list[Port] = [] + for section in ("required", "optional"): + section_def = sub_def.get(section) or {} + if not isinstance(section_def, dict): + continue + for sub_name, sub_spec in section_def.items(): + ports.append(_sub_port_from_spec(f"{prefix}.{sub_name}", sub_spec, section == "required")) + return ports + + +def _expand_widget_entries(m: Morphism, widgets_values: list[Any]) -> list[_WidgetEntry]: + """Flatten a node's widget ports into one entry per ``widgets_values`` slot. + + Walks declared ports in order; at a dynamic combo it reads the current + selector from that combo's own positional slot and expands the matching + option's widget-like sub-inputs in place (recursing for nested dynamic + combos; connection sub-inputs contribute no slot). A control-flagged input + — top-level or sub — is followed by its ``control_after_generate`` marker + slot, exactly as the frontend serializes it. + """ + entries: list[_WidgetEntry] = [] + + def emit(name: str, port: Port, owner: str | None, depth: int) -> None: + entries.append(_WidgetEntry(name=name, port=port, owner=owner)) + if port.dynamic_options and _is_dynamic_combo_type(port.type): + if depth >= _MAX_DYNAMIC_COMBO_DEPTH: + return + idx = len(entries) - 1 + selector = widgets_values[idx] if idx < len(widgets_values) else port.options.default + for sub in _dynamic_combo_sub_ports(port.dynamic_options, selector, name): + if sub.is_link: + continue + emit(sub.name, sub, name, depth + 1) + elif port.options.control_after_generate: + entries.append(_WidgetEntry(name="control_after_generate", port=None, owner=owner)) + + for p in m.inputs: + if p.is_link: + continue + emit(p.name, p, None, 0) + return entries + + def _node_widget_slots(node: dict, prefix: str, graph: Graph) -> list[dict]: """Surface a regular node's widget inputs as slots under ``prefix``. ``prefix`` is the addressable node path (``"3"`` at top level, ``"10/9"`` inside a subgraph). Returns one slot per widget input the schema knows - about. Returns ``[]`` for nodes whose type isn't in object_info. + about — including a dynamic combo's selector (enum = its option keys) and + the selected option's ``.`` sub-inputs. Returns ``[]`` for + nodes whose type isn't in object_info. """ node_type = node.get("type", "") m = graph.node(node_type) if m is None: return [] - order = graph.widget_order(node_type) widgets = node.get("widgets_values") or [] slots: list[dict] = [] - for port in m.inputs: - if port.is_link: - continue - try: - idx = order.index(port.name) - except ValueError: + for idx, entry in enumerate(_expand_widget_entries(m, widgets)): + if entry.port is None: # control_after_generate marker — not a slot continue current = widgets[idx] if idx < len(widgets) else None - slots.append( - { - "address": f"{prefix}.{port.name}", - "name": port.name, - "type": port.type, - "current_value": current, - "instance_id": prefix, - "node_type": node_type, - } - ) + slot = { + "address": f"{prefix}.{entry.name}", + "name": entry.name, + "type": entry.port.type, + "current_value": current, + "instance_id": prefix, + "node_type": node_type, + } + if entry.port.enum_values: + slot["enum"] = list(entry.port.enum_values) + slots.append(slot) return slots @@ -1496,12 +1625,12 @@ def _resolve_proxy_value(instance: dict, subgraph: dict, input_name: str, graph: if not isinstance(inode, dict) or str(inode.get("id", "")) != interior_id: continue interior_class = inode.get("type", "") - order = graph.widget_order(interior_class) + widgets = inode.get("widgets_values") or [] + order = graph.widget_order_for_node(interior_class, widgets) try: idx = order.index(name) except ValueError: return _UNRESOLVED - widgets = inode.get("widgets_values") or [] return widgets[idx] if idx < len(widgets) else _UNRESOLVED break return _UNRESOLVED @@ -1513,28 +1642,47 @@ def _write_widget(node: dict, input_name: str, value: Any, graph: Graph, *, exte Validates against the node's schema and returns catalog warnings. ``extend`` pads a short widget list for top-level direct edits (matches prior behavior); interior subgraph nodes always carry a full widget list and are not padded. + + Dynamic combos: the positional order is value-aware, so a selected option's + sub-inputs (``model.size_preset``) address their real slots. Writing the + selector itself to a different option rebuilds the node's sub-widget roster + (see ``_write_dynamic_combo_selector``); a ``.`` address that + doesn't exist under the current selector returns a warning without writing. """ node_type = node.get("type", "") m = graph.node(node_type) if m is None: raise ValueError(f"unknown node type {node_type!r} for node {node.get('id')}") - order = graph.widget_order(node_type) + widgets = node.get("widgets_values") or [] + order = graph.widget_order_for_node(node_type, widgets) try: widget_idx = order.index(input_name) except ValueError: + warning = _unknown_dynamic_sub_warning(m, input_name, order, widgets) + if warning is not None: + return [warning] avail = [n for n in order if n != "control_after_generate"] raise ValueError( f"widget {input_name!r} not found on {node_type}; " f"available widgets: {', '.join(avail) if avail else '(none — all inputs are links)'}" ) - widgets = node.get("widgets_values") or [] + + entries = _expand_widget_entries(m, widgets) + port = next((e.port for e in entries if e.name == input_name), None) + if port is None: + # Marker slot or an order override without matching entries (tests + # monkeypatch widget_order_for_node) — fall back to the declared port. + port = next((p for p in m.inputs if p.name == input_name), None) + + if port is not None and _is_dynamic_combo_type(port.type) and port.dynamic_options: + return _write_dynamic_combo_selector(node, port, input_name, widget_idx, value, entries, extend=extend) + if widget_idx >= len(widgets): if not extend: raise ValueError(f"widget index {widget_idx} out of range for {node_type}") widgets.extend([None] * (widget_idx + 1 - len(widgets))) warnings: list[dict] = [] - port = next((p for p in m.inputs if p.name == input_name), None) if port: err = port.validate_shape(value) if err: @@ -1546,6 +1694,120 @@ def _write_widget(node: dict, input_name: str, value: Any, graph: Graph, *, exte return warnings +def _unknown_dynamic_sub_warning(m: Morphism, input_name: str, order: list[str], widgets: list[Any]) -> dict | None: + """Warning dict for a ``.`` address not present under the + combo's CURRENT selector, or ``None`` when ``input_name`` isn't a + dynamic-combo sub-address (caller falls through to the hard error).""" + if "." not in input_name: + return None + base = input_name.split(".", 1)[0] + base_port = next((p for p in m.inputs if p.name == base), None) + if base_port is None or not _is_dynamic_combo_type(base_port.type) or base not in order: + return None + base_idx = order.index(base) + selector = widgets[base_idx] if base_idx < len(widgets) else None + valid = [n for n in order if n.startswith(f"{base}.")] + return { + "code": "unknown_dynamic_sub_input", + "field": input_name, + "message": ( + f"{input_name!r} does not exist under the current {base}={selector!r} selection; nothing was written" + ), + "hint": ( + f"valid {base}.* addresses: {', '.join(valid)}" + if valid + else f"{base}={selector!r} has no widget sub-inputs" + ) + + f" — set {base}=