diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index e21a263b6..3dc2bf763 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -1,11 +1,11 @@ """``comfy workflow`` — slot-based editing of ComfyUI frontend-format workflows. -Three primitives: +Four editing primitives: comfy workflow slots # what can I tweak? - comfy workflow set-slot ADDR=VALUE [...] # tweak one or more + comfy workflow set-slot ADDR=VALUE [...] # tweak widget values + comfy workflow set-mode NODE=MODE [...] # normal/mute/bypass nodes comfy workflow vary --slot ADDR='[v1,v2]' # produce N variants - Plus one read-only reader that needs no object_info at all: comfy workflow notes # what did the author write? @@ -20,6 +20,7 @@ from __future__ import annotations +import copy import json import unicodedata from pathlib import Path @@ -322,6 +323,110 @@ def set_slot_cmd( renderer.emit(payload, command="workflow set-slot", changed=not stdout) +# --------------------------------------------------------------------------- +# set-mode +# --------------------------------------------------------------------------- + + +_NODE_MODES = {"normal": 0, "mute": 2, "bypass": 4} + + +@app.command( + "set-mode", + help="Set one or more nodes to normal, mute, or bypass in place (or --stdout).", +) +@tracking.track_command("workflow") +def set_mode_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + overrides: Annotated[ + list[str], + typer.Argument( + metavar="NODE=MODE...", + help="NODE_ID=MODE or INSTANCE_ID/INNER_ID=MODE; MODE is normal, mute, or bypass.", + ), + ], + stdout: Annotated[ + bool, + typer.Option( + "--stdout/--in-place", + show_default=False, + help="Return the modified workflow instead of writing back to .", + ), + ] = False, +): + renderer = get_renderer() + p, workflow = _load_workflow_or_fail(renderer, file) + + parsed: dict[str, int] = {} + for raw in overrides: + if "=" not in raw: + renderer.error( + code="workflow_mode_invalid", + message=f"Expected `NODE=MODE`, got {raw!r}", + hint="MODE must be normal, mute, or bypass", + ) + raise typer.Exit(code=1) + address, _, raw_mode = raw.partition("=") + address = address.strip() + mode = raw_mode.strip().lower() + if mode not in _NODE_MODES: + renderer.error( + code="workflow_mode_invalid", + message=f"Unknown node mode {raw_mode.strip()!r} for {address!r}", + hint="MODE must be normal, mute, or bypass", + ) + raise typer.Exit(code=1) + parsed[address] = _NODE_MODES[mode] + + new_workflow = copy.deepcopy(workflow) + resolved: list[tuple[str, dict[str, Any], int]] = [] + try: + from comfy_cli.cql.engine import _resolve_node_path, _subgraph_defs_by_id + + for address, mode in parsed.items(): + segments = [part.strip() for part in address.split("/")] + if not segments or any(not part for part in segments): + raise ValueError(f"invalid node address {address!r}; expected NODE_ID or INSTANCE_ID/INNER_ID") + node = _resolve_node_path(new_workflow, segments, _subgraph_defs_by_id(new_workflow)) + resolved.append((address, node, mode)) + except ValueError as e: + renderer.error( + code="workflow_mode_invalid", + message=str(e), + hint="use NODE_ID or INSTANCE_ID/INNER_ID from the frontend workflow", + ) + raise typer.Exit(code=1) from e + + for _address, node, mode in resolved: + node["mode"] = mode + + if stdout and renderer.is_pretty(): + import sys + + sys.stdout.write(json.dumps(new_workflow, indent=2)) + sys.stdout.write("\n") + sys.stdout.flush() + return + + if not stdout: + atomic_write_text(p, json.dumps(new_workflow, indent=2)) + + payload: dict[str, Any] = { + "workflow": str(p), + "applied": list(parsed), + "warnings": [], + "wrote": None if stdout else str(p), + } + if stdout: + payload["out"] = "stdout" + payload["workflow_json"] = new_workflow + if renderer.is_pretty(): + rprint(f"[bold green]✓[/bold green] applied {len(parsed)} node mode(s) → [dim]{sanitize_markup(p)}[/dim]") + for address in parsed: + rprint(f" [dim]·[/dim] {sanitize_markup(address)}") + renderer.emit(payload, command="workflow set-mode", changed=not stdout) + + # --------------------------------------------------------------------------- # vary # --------------------------------------------------------------------------- diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index 212bcaa72..051542c09 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -2348,33 +2348,48 @@ def _resolve_node_path(workflow: dict, segments: list[str], defs_by_id: dict[str return node -def _count_instances(workflow: dict, def_id: str) -> int: - """Count nodes (top-level + interior-of-definitions) instantiating ``def_id``.""" - count = 0 - for n in workflow.get("nodes") or []: - if isinstance(n, dict) and str(n.get("type", "")) == def_id: - count += 1 - for sg in (workflow.get("definitions") or {}).get("subgraphs") or []: - if isinstance(sg, dict): - for n in sg.get("nodes") or []: - if isinstance(n, dict) and str(n.get("type", "")) == def_id: - count += 1 - return count +def _iter_workflow_nodes(workflow: dict): + for node in workflow.get("nodes") or []: + if isinstance(node, dict): + yield node + for definition in (workflow.get("definitions") or {}).get("subgraphs") or []: + if not isinstance(definition, dict): + continue + for node in definition.get("nodes") or []: + if isinstance(node, dict): + yield node + + +def _count_instances(workflow: dict, definition: dict, defs_by_id: dict[str, dict]) -> int: + """Count every raw type alias that resolves to ``definition``.""" + return sum(defs_by_id.get(str(node.get("type", ""))) is definition for node in _iter_workflow_nodes(workflow)) def _isolate_shared_subgraph(workflow: dict, instance: dict, defs_by_id: dict[str, dict]) -> None: - """If ``instance``'s subgraph definition is shared with another instance, - deep-copy it under a fresh id and repoint ``instance`` so an interior write - can't alias sibling instances. No-op when the instance already owns its def. + """Fork a shared definition and leave every sibling on the original. + + Legacy saves may type an instance by a unique definition name rather than + its UUID. Resolve sharing by definition identity, then canonicalize every + alias to the original id before adding the fork; otherwise the duplicate + name makes the untouched legacy sibling unresolvable. """ - def_id = str(instance.get("type", "")) - sg = defs_by_id.get(def_id) - if sg is None or _count_instances(workflow, def_id) <= 1: + definition = defs_by_id.get(str(instance.get("type", ""))) + if definition is None or _count_instances(workflow, definition, defs_by_id) <= 1: return - new_sg = copy.deepcopy(sg) + + original_id = definition.get("id") + if not isinstance(original_id, str) or not original_id: + original_id = str(_uuid.uuid4()) + definition["id"] = original_id + + for node in _iter_workflow_nodes(workflow): + if defs_by_id.get(str(node.get("type", ""))) is definition: + node["type"] = original_id + + new_definition = copy.deepcopy(definition) new_id = str(_uuid.uuid4()) - new_sg["id"] = new_id - workflow.setdefault("definitions", {}).setdefault("subgraphs", []).append(new_sg) + new_definition["id"] = new_id + workflow.setdefault("definitions", {}).setdefault("subgraphs", []).append(new_definition) instance["type"] = new_id diff --git a/comfy_cli/discovery.py b/comfy_cli/discovery.py index 82b3a693c..6ebefb367 100644 --- a/comfy_cli/discovery.py +++ b/comfy_cli/discovery.py @@ -75,6 +75,7 @@ # workflow editing "comfy workflow slots": "workflow", "comfy workflow set-slot": "workflow", + "comfy workflow set-mode": "workflow", "comfy workflow vary": "workflow", "comfy workflow notes": "workflow", # workflow cloud CRUD + fragment composition diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index 797e8a715..04f541bc9 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -512,6 +512,11 @@ class ErrorCode: "A slot override failed validation (bad shape, unknown address, etc.).", "see `details` — addresses follow `.`", ), + ErrorCode( + "workflow_mode_invalid", + "A workflow node-mode override failed validation.", + "use `NODE_ID=MODE` or `INSTANCE_ID/INNER_ID=MODE`; MODE is normal, mute, or bypass", + ), # --- workflow fragments / compose --------------------------------------- ErrorCode( "fragment_invalid", diff --git a/comfy_cli/schemas/workflow.json b/comfy_cli/schemas/workflow.json index 43d95a764..54f0e5671 100644 --- a/comfy_cli/schemas/workflow.json +++ b/comfy_cli/schemas/workflow.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "comfy workflow *", - "description": "Output shape for workflow editing commands (slots, set-slot, vary) and the read-only notes reader.", + "description": "Output shape for workflow editing commands (slots, set-slot, set-mode, vary) and the read-only notes reader.", "type": "object", "properties": { "workflow": { "type": "string" }, @@ -32,9 +32,9 @@ }, "applied": { "type": "array" }, "warnings": { "type": "array" }, - "wrote": { "type": ["string", "null"], "description": "file written, or null when nothing was written (set-slot --stdout)" }, - "out": { "type": "string", "description": "set-slot: where the result went — \"stdout\" when --stdout returned it instead of writing the file" }, - "workflow_json": { "type": "object", "description": "set-slot --stdout: the modified workflow itself (human mode prints it raw on stdout instead)" }, + "wrote": { "type": ["string", "null"], "description": "file written, or null when nothing was written (--stdout)" }, + "out": { "type": "string", "description": "where the result went — \"stdout\" when --stdout returned it instead of writing the file" }, + "workflow_json": { "type": "object", "description": "--stdout: the modified workflow itself (human mode prints it raw on stdout instead)" }, "variants": { "type": ["array", "null"], "description": "vary without --out-dir: the produced workflows (human mode prints them as NDJSON on stdout instead); null when they were written to --out-dir" }, "written": { "type": "array" }, "out_dir": { "type": ["string", "null"] }, diff --git a/comfy_cli/skills/comfy/SKILL.md b/comfy_cli/skills/comfy/SKILL.md index 115e7a627..81f68359d 100644 --- a/comfy_cli/skills/comfy/SKILL.md +++ b/comfy_cli/skills/comfy/SKILL.md @@ -778,8 +778,8 @@ fan-out outputs by array order** — read `outputs_by_item`. ## Edit workflows in place -`workflow slots`, `set-slot`, and `vary` work on any frontend-format -workflow JSON — not just templates. Get slot addresses first: +`workflow slots`, `set-slot`, `set-mode`, and `vary` work on any +frontend-format workflow JSON — not just templates. Get slot addresses first: ```bash # 1. Discover addressable slots — addresses are ., never titles @@ -791,6 +791,11 @@ comfy --json workflow slots path.json # 2. Set a single slot comfy workflow set-slot path.json 6.text="a cat" +# Toggle structural branches without paying the runtime cost of a zero-strength node +comfy workflow set-mode path.json 105=bypass +# subgraph interior: / +comfy workflow set-mode path.json 10/20=mute + # 3. Generate variations (slot lists are zipped — same length required) comfy --json workflow slots wf.json # discover addresses first comfy workflow vary wf.json \ diff --git a/tests/comfy_cli/command/test_workflow_slots.py b/tests/comfy_cli/command/test_workflow_slots.py index ad1b17f02..037dbbffa 100644 --- a/tests/comfy_cli/command/test_workflow_slots.py +++ b/tests/comfy_cli/command/test_workflow_slots.py @@ -441,6 +441,208 @@ def test_set_slot_unknown_node(self, patched_graph, tmp_path, capsys): assert "not found" in env["error"]["message"].lower() +# --------------------------------------------------------------------------- +# set-mode — structural workflow variants +# --------------------------------------------------------------------------- + + +class TestSetMode: + def test_set_mode_updates_top_level_node_in_place(self, tmp_path, capsys): + path = _write_workflow(tmp_path, _direct_workflow()) + + env = _run(["set-mode", str(path), "6=bypass"], capsys) + + assert env["ok"] is True + on_disk = json.loads(path.read_text()) + clip = next(n for n in on_disk["nodes"] if n["id"] == 6) + assert clip["mode"] == 4 + assert env["data"]["applied"] == ["6"] + + def test_set_mode_addresses_subgraph_interior_node(self, tmp_path, capsys): + workflow = { + "nodes": [{"id": 10, "type": "subgraph-definition"}], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": "subgraph-definition", + "nodes": [{"id": 20, "type": "LoraLoader", "mode": 0}], + } + ] + }, + } + path = _write_workflow(tmp_path, workflow) + + env = _run(["set-mode", str(path), "10/20=mute"], capsys) + + assert env["ok"] is True + on_disk = json.loads(path.read_text()) + inner = on_disk["definitions"]["subgraphs"][0]["nodes"][0] + assert inner["mode"] == 2 + + def test_set_mode_forks_shared_subgraph_definition(self, tmp_path, capsys): + workflow = { + "nodes": [ + {"id": 10, "type": "shared-definition"}, + {"id": 11, "type": "shared-definition"}, + ], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": "shared-definition", + "nodes": [{"id": 20, "type": "LoraLoader", "mode": 0}], + } + ] + }, + } + path = _write_workflow(tmp_path, workflow) + + env = _run(["set-mode", str(path), "10/20=bypass"], capsys) + + assert env["ok"] is True + on_disk = json.loads(path.read_text()) + instances = {node["id"]: node for node in on_disk["nodes"]} + assert instances[10]["type"] != instances[11]["type"] + definitions = {definition["id"]: definition for definition in on_disk["definitions"]["subgraphs"]} + changed = definitions[instances[10]["type"]]["nodes"][0] + unchanged = definitions[instances[11]["type"]]["nodes"][0] + assert changed["mode"] == 4 + assert unchanged["mode"] == 0 + + def test_set_mode_forks_shared_definitions_at_every_depth(self, tmp_path, capsys): + workflow = { + "nodes": [ + {"id": 10, "type": "outer-definition"}, + {"id": 11, "type": "outer-definition"}, + ], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": "outer-definition", + "nodes": [{"id": 20, "type": "inner-definition"}], + }, + { + "id": "inner-definition", + "nodes": [{"id": 30, "type": "LoraLoader", "mode": 0}], + }, + ] + }, + } + path = _write_workflow(tmp_path, workflow) + + env = _run(["set-mode", str(path), "10/20/30=bypass"], capsys) + + assert env["ok"] is True + on_disk = json.loads(path.read_text()) + definitions = {definition["id"]: definition for definition in on_disk["definitions"]["subgraphs"]} + top = {node["id"]: node for node in on_disk["nodes"]} + changed_outer = definitions[top[10]["type"]] + unchanged_outer = definitions[top[11]["type"]] + changed_inner_instance = changed_outer["nodes"][0] + unchanged_inner_instance = unchanged_outer["nodes"][0] + assert changed_inner_instance["type"] != unchanged_inner_instance["type"] + assert definitions[changed_inner_instance["type"]]["nodes"][0]["mode"] == 4 + assert definitions[unchanged_inner_instance["type"]]["nodes"][0]["mode"] == 0 + + def test_set_mode_supports_legacy_name_typed_subgraph(self, tmp_path, capsys): + workflow = { + "nodes": [{"id": 10, "type": "Legacy Subgraph"}], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": "subgraph-uuid", + "name": "Legacy Subgraph", + "nodes": [{"id": 20, "type": "LoraLoader", "mode": 0}], + } + ] + }, + } + path = _write_workflow(tmp_path, workflow) + + env = _run(["set-mode", str(path), "10/20=mute"], capsys) + + assert env["ok"] is True + on_disk = json.loads(path.read_text()) + assert on_disk["definitions"]["subgraphs"][0]["nodes"][0]["mode"] == 2 + + def test_set_mode_keeps_shared_source_unchanged_after_late_failure(self, tmp_path, capsys): + workflow = { + "nodes": [ + {"id": 10, "type": "shared-definition"}, + {"id": 11, "type": "shared-definition"}, + ], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": "shared-definition", + "nodes": [{"id": 20, "type": "LoraLoader", "mode": 0}], + } + ] + }, + } + path = _write_workflow(tmp_path, workflow) + original_text = path.read_text() + + env = _run(["set-mode", str(path), "10/20=bypass", "999=mute"], capsys) + + assert env["ok"] is False + assert path.read_text() == original_text + + def test_set_mode_sanitizes_pretty_output(self, tmp_path, capsys): + workflow = { + "nodes": [{"id": "[blue]6", "type": "CLIPTextEncode"}], + "links": [], + } + path = _write_workflow(tmp_path, workflow, "[red]workflow.json") + + captured, _stderr, result = _invoke( + ["set-mode", str(path), "[blue]6=bypass"], + capsys, + _force_pretty_renderer, + ) + + assert result.exit_code == 0 + assert "[red]workflow.json" in captured + assert "[blue]6" in captured + + def test_set_mode_supports_stdout_without_modifying_source(self, tmp_path, capsys): + path = _write_workflow(tmp_path, _direct_workflow()) + original_text = path.read_text() + + env = _run(["set-mode", str(path), "3=normal", "6=bypass", "--stdout"], capsys) + + assert env["ok"] is True + assert env["changed"] is False + assert path.read_text() == original_text + nodes = {n["id"]: n for n in env["data"]["workflow_json"]["nodes"]} + assert nodes[3]["mode"] == 0 + assert nodes[6]["mode"] == 4 + + def test_set_mode_rejects_invalid_mode_without_partial_write(self, tmp_path, capsys): + path = _write_workflow(tmp_path, _direct_workflow()) + original_text = path.read_text() + + env = _run(["set-mode", str(path), "3=bypass", "6=disabled"], capsys) + + assert env["ok"] is False + assert env["error"]["code"] == "workflow_mode_invalid" + assert path.read_text() == original_text + + def test_set_mode_rejects_unknown_node_without_partial_write(self, tmp_path, capsys): + path = _write_workflow(tmp_path, _direct_workflow()) + original_text = path.read_text() + + env = _run(["set-mode", str(path), "3=bypass", "999=mute"], capsys) + + assert env["ok"] is False + assert env["error"]["code"] == "workflow_mode_invalid" + assert path.read_text() == original_text + + # --------------------------------------------------------------------------- # vary — direct mode # --------------------------------------------------------------------------- diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index 11681520a..794be974b 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -2265,6 +2265,71 @@ def test_single_instance_no_fork(self, graph: Graph): # The single def got the new value directly assert wf["definitions"]["subgraphs"][0]["nodes"][0]["widgets_values"][0] == "NEW" + def test_legacy_and_uuid_instances_share_one_definition(self, graph: Graph): + from comfy_cli.cql.engine import _apply_one_slot + + wf = { + "nodes": [ + {"id": 10, "type": "Sub"}, + {"id": 12, "type": "uuid-def-1"}, + ], + "definitions": { + "subgraphs": [ + { + "id": "uuid-def-1", + "name": "Sub", + "nodes": [ + { + "id": 9, + "type": "CLIPTextEncode", + "widgets_values": ["orig"], + } + ], + } + ] + }, + } + + _apply_one_slot(wf, "10/9.text", "legacy-only", graph) + + definitions = {definition["id"]: definition for definition in wf["definitions"]["subgraphs"]} + instances = {node["id"]: node for node in wf["nodes"]} + assert definitions[instances[10]["type"]]["nodes"][0]["widgets_values"][0] == "legacy-only" + assert definitions[instances[12]["type"]]["nodes"][0]["widgets_values"][0] == "orig" + + def test_fork_keeps_second_legacy_instance_resolvable(self, graph: Graph): + from comfy_cli.cql.engine import _apply_one_slot + + wf = { + "nodes": [ + {"id": 10, "type": "Sub"}, + {"id": 12, "type": "Sub"}, + ], + "definitions": { + "subgraphs": [ + { + "id": "uuid-def-1", + "name": "Sub", + "nodes": [ + { + "id": 9, + "type": "CLIPTextEncode", + "widgets_values": ["orig"], + } + ], + } + ] + }, + } + + _apply_one_slot(wf, "10/9.text", "first", graph) + _apply_one_slot(wf, "12/9.text", "second", graph) + + definitions = {definition["id"]: definition for definition in wf["definitions"]["subgraphs"]} + instances = {node["id"]: node for node in wf["nodes"]} + assert definitions[instances[10]["type"]]["nodes"][0]["widgets_values"][0] == "first" + assert definitions[instances[12]["type"]]["nodes"][0]["widgets_values"][0] == "second" + # =========================================================================== # TestExpandVariations