Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 108 additions & 3 deletions comfy_cli/command/workflow.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""``comfy workflow`` — slot-based editing of ComfyUI frontend-format workflows.

Three primitives:
Four editing primitives:

comfy workflow slots <file> # what can I tweak?
comfy workflow set-slot <file> ADDR=VALUE [...] # tweak one or more
comfy workflow set-slot <file> ADDR=VALUE [...] # tweak widget values
comfy workflow set-mode <file> NODE=MODE [...] # normal/mute/bypass nodes
comfy workflow vary <file> --slot ADDR='[v1,v2]' # produce N variants

Plus one read-only reader that needs no object_info at all:

comfy workflow notes <file> # what did the author write?
Expand All @@ -20,6 +20,7 @@

from __future__ import annotations

import copy
import json
import unicodedata
from pathlib import Path
Expand Down Expand Up @@ -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 <file>.",
),
] = 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
# ---------------------------------------------------------------------------
Expand Down
57 changes: 36 additions & 21 deletions comfy_cli/cql/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
1 change: 1 addition & 0 deletions comfy_cli/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions comfy_cli/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,11 @@ class ErrorCode:
"A slot override failed validation (bad shape, unknown address, etc.).",
"see `details` — addresses follow `<instance_id>.<input_name>`",
),
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",
Expand Down
8 changes: 4 additions & 4 deletions comfy_cli/schemas/workflow.json
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down Expand Up @@ -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"] },
Expand Down
9 changes: 7 additions & 2 deletions comfy_cli/skills/comfy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <node_id>.<input>, never titles
Expand All @@ -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: <instance_id>/<inner_id>
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 \
Expand Down
Loading
Loading