diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 4662b788..3f9db0ba 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -5,7 +5,6 @@ import webbrowser from typing import Annotated -import questionary import typer from rich.console import Console @@ -787,11 +786,35 @@ def run( ), ), ] = False, + workflow_id: Annotated[ + str | None, + typer.Option( + "--workflow-id", + show_default=False, + help="Cloud workflow entity id to associate this run with (enables draft auto-save on run).", + ), + ] = None, + no_watch: Annotated[ + bool, + typer.Option( + "--no-watch", + show_default=False, + help=( + "Suppress the detached background watcher subprocess for non-blocking " + "runs (equivalent to setting COMFY_NO_WATCH=1). Agentic callers with " + "their own job-wait loop don't need a second process polling in the " + "background; it just holds onto credentials after the parent exits." + ), + ), + ] = False, ): # Snapshot kwargs before the body mutates api_key/host/port — analytics should record what user actually supplied. _track_props = tracking.filter_command_kwargs(dict(locals())) tracking.track_event("execution_start", _track_props, mixpanel_name="run") + if no_watch: + os.environ["COMFY_NO_WATCH"] = "1" + try: if api_key: api_key = api_key.strip() or None @@ -812,6 +835,11 @@ def run( renderer.error(code="where_invalid", message=str(e), hint="use --where local or --where cloud") raise typer.Exit(code=1) + # Record the RESOLVED routing target so submission analytics can tell a + # cloud run from a local one even when --where was defaulted (the raw + # `where` kwarg is None then). Rides on the execution_success/_error events. + _track_props["target"] = "cloud" if decision.target is where_module.WhereTarget.CLOUD else "local" + # Default for --notify: on when a human is at the terminal, off for # agents (they shouldn't get surprise side-channel processes they didn't # ask for). The user can override either way with --notify/--no-notify. @@ -850,6 +878,10 @@ def run( if decision.target is where_module.WhereTarget.CLOUD: where_module.cloud_preflight_or_exit() # Cloud path uses HTTPS + Bearer auth; host/port aren't applicable. + # NOTE: do NOT `return` here — falling through to the try's `else` + # is what fires `execution_success`. An early return skipped it, so + # successful cloud submissions emitted `execution_start` but never + # `execution_success` (local runs were unaffected). run_inner.execute_cloud( workflow, wait=wait, @@ -857,31 +889,31 @@ def run( timeout=timeout, notify=effective_notify, print_prompt=print_prompt, + workflow_id=workflow_id, + preloaded=preloaded, + ) + else: + from comfy_cli.host_port import parse_host_port_arg, resolve_host_port + + if host: + host, parsed_port = parse_host_port_arg(host) + if not port and parsed_port is not None: + port = parsed_port + + host, port = resolve_host_port(host, port) + + run_inner.execute( + workflow, + host, + port, + wait=wait, + verbose=verbose, + timeout=timeout, + notify=effective_notify, + api_key=api_key, + print_prompt=print_prompt, preloaded=preloaded, ) - return - - from comfy_cli.host_port import parse_host_port_arg, resolve_host_port - - if host: - host, parsed_port = parse_host_port_arg(host) - if not port and parsed_port is not None: - port = parsed_port - - host, port = resolve_host_port(host, port) - - run_inner.execute( - workflow, - host, - port, - wait=wait, - verbose=verbose, - timeout=timeout, - notify=effective_notify, - api_key=api_key, - print_prompt=print_prompt, - preloaded=preloaded, - ) except typer.Exit as e: if (e.exit_code or 0) == 0: tracking.track_event("execution_success", _track_props) @@ -902,13 +934,15 @@ def run( @app.command( - help="Validate an API-format workflow without submitting. Checks class_types, input shapes, enum values, and edge wiring." + help="Validate a workflow without submitting. Accepts API-format or a frontend/canvas " + "graph (auto-converted to API first). Checks class_types, required inputs, input shapes, " + "enum values, and edge wiring." ) @tracking.track_command() def validate( workflow: Annotated[ str, - typer.Option(help="Path to the API-format workflow JSON file."), + typer.Option(help="Path to the workflow JSON file (API format or a frontend/canvas graph)."), ], where: Annotated[ str | None, @@ -930,6 +964,8 @@ def validate( from pathlib import Path from comfy_cli.cql.engine import Graph, LoadError + from comfy_cli.cql.loader import resilient_load_object_info + from comfy_cli.workflow_to_api import WorkflowConversionError, convert_ui_to_api, is_api_format renderer = get_renderer() @@ -961,8 +997,16 @@ def validate( except Exception: pass + # Resolve object_info ONCE through the shared loader so validate honors the + # same catalog every other command does — an explicit --input dump, the + # COMFY_OBJECT_INFO_FILE offline catalog, or the cache-first live fetch — and + # so the graph we validate against is built from the SAME catalog used to + # lower a canvas workflow below (previously the graph came from Graph.load, + # which ignored COMFY_OBJECT_INFO_FILE, while lowering honored it). try: - graph = Graph.load(mode=mode, input_path=input_path, host=host or "127.0.0.1", port=port or 8188) + object_info = resilient_load_object_info( + mode=mode, input_path=input_path, host=host or "127.0.0.1", port=port or 8188 + ) except LoadError as e: renderer.error( code="cql_no_graph", @@ -971,6 +1015,28 @@ def validate( details=e.details, ) raise typer.Exit(code=1) from e + graph = Graph.from_object_info(object_info) + graph._try_default_annotations() + + # `validate_workflow` only inspects the API/prompt shape + # ({id: {class_type, inputs}}) — it iterates node inputs and checks wiring, + # required inputs, enums, and shapes. A frontend/canvas graph + # ({nodes: [...], links: [...]}) never gets its nodes examined: every + # top-level key is treated as a non-node and the result comes back + # valid:true even when the wiring is structurally broken. So a canvas + # workflow MUST be lowered to API format FIRST, using the SAME converter + # (and the SAME object_info resolution) the `run` path uses, so validate + # inspects exactly what the server would execute. + if not is_api_format(wf_data): + try: + wf_data = convert_ui_to_api(wf_data, object_info) + except WorkflowConversionError as e: + renderer.error( + code="conversion_error", + message=str(e), + hint="check that every node's required inputs are connected", + ) + raise typer.Exit(code=1) from e result = graph.validate_workflow(wf_data) @@ -1510,6 +1576,10 @@ def feedback( else str(usability_satisfaction_score), }, ) + # Imported lazily: questionary pulls in prompt_toolkit (~50ms) and is only + # needed on this interactive feedback path. + import questionary + if ( sent and questionary.confirm("Do you want to provide additional feature-specific feedback on our GitHub page?").ask() diff --git a/comfy_cli/comfy_client.py b/comfy_cli/comfy_client.py index 02b8c8eb..bdd23e32 100644 --- a/comfy_cli/comfy_client.py +++ b/comfy_cli/comfy_client.py @@ -324,10 +324,15 @@ def submit_prompt( *, timeout: float | None = None, extra_data: dict | None = None, + workflow_id: str | None = None, ) -> SubmitResult: """POST {prefix}/prompt — submit a workflow for execution. Caller may pass ``extra_data`` (merged into the request, not overwritten). + For cloud submissions, ``workflow_id`` (the cloud workflow entity id) is + forwarded as a top-level ``workflow_id`` field so the server can associate + the job with an existing workflow and auto-promote a draft on run. Omitted + from the body entirely when unset. For cloud submissions, the user's OAuth token is injected as ``auth_token_comfy_org`` so partner-API nodes (BFL Flux Pro, Gemini Nano Banana, etc.) can call out to comfy.org — matching what the web @@ -352,6 +357,10 @@ def payload() -> dict[str, Any]: merged_extra.setdefault("api_key_comfy_org", self.target.api_key) if merged_extra: request_payload["extra_data"] = merged_extra + # Cloud workflow entity id: associate this job with an existing + # workflow (auto-promotes a draft on run). Only sent when provided. + if workflow_id: + request_payload["workflow_id"] = workflow_id return request_payload resp = self._request("POST", ("prompt",), body_factory=payload, timeout=timeout) diff --git a/comfy_cli/command/assets_library.py b/comfy_cli/command/assets_library.py new file mode 100644 index 00000000..233b2c86 --- /dev/null +++ b/comfy_cli/command/assets_library.py @@ -0,0 +1,113 @@ +"""``comfy assets library`` — browse and borrow assets from Comfy Cloud's +asset library. + +Mirrors the cloud-saved-workflow subcommands in ``workflow.py`` (``list``, +``get``, ...): thin Typer commands over ``cloud_http``'s shared helpers, +emitting a JSON envelope via the renderer. Cloud-only — there is no local +``/api/assets`` surface. +""" + +from __future__ import annotations + +from typing import Annotated, Any + +import typer + +from comfy_cli import tracking +from comfy_cli.command.cloud_http import ( + cloud_target_or_local_error, + handle_cloud_http_error, + http_request, +) +from comfy_cli.output.renderer import get_renderer + +app = typer.Typer(help="Browse your Comfy Cloud asset library (list, borrow).") + + +@app.command("ls", help="List your assets on Comfy Cloud.") +@tracking.track_command("assets") +def ls_cmd( + name: Annotated[ + str | None, + typer.Option("--name", show_default=False, help="Case-insensitive substring match on asset name."), + ] = None, + tags: Annotated[ + str | None, + typer.Option( + "--tags", show_default=False, help="Comma-separated tags; assets must have ALL of them (e.g. input,output)." + ), + ] = None, + limit: Annotated[int, typer.Option("--limit", help="Cap rows returned (max 500).")] = 20, + where: Annotated[str | None, typer.Option("--where", show_default=False)] = None, +): + import urllib.error + import urllib.parse + + renderer = get_renderer() + target = cloud_target_or_local_error(where, renderer) + + params: list[tuple[str, Any]] = [("limit", min(max(limit, 1), 500))] + if name: + params.append(("name_contains", name)) + for t in tags.split(",") if tags else []: + t = t.strip() + if t: + params.append(("include_tags", t)) + url = target.url("assets") + "?" + urllib.parse.urlencode(params) + + try: + _, body = http_request(url, target) + except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e: + raise handle_cloud_http_error(renderer, e, operation="list") from e + + rows = (body or {}).get("assets") or [] + payload = { + "count": len(rows), + "assets": [ + { + "id": r.get("id"), + "name": r.get("name"), + "hash": r.get("hash"), + "mime_type": r.get("mime_type"), + "size": r.get("size"), + "tags": r.get("tags"), + "preview_url": r.get("preview_url"), + "job_id": r.get("job_id"), + "created_at": r.get("created_at"), + } + for r in rows + if isinstance(r, dict) + ], + } + renderer.emit(payload, command="assets library ls", where="cloud") + + +@app.command("ensure", help="Ensure you own an asset by content hash (borrows public/shared bytes, no re-upload).") +@tracking.track_command("assets") +def ensure_cmd( + hash: Annotated[str, typer.Option("--hash", help="Asset content hash (as returned by `assets library ls`).")], + tags: Annotated[ + str, + typer.Option("--tags", help="Comma-separated tags to attach (>=1 required by the API)."), + ] = "input", + where: Annotated[str | None, typer.Option("--where", show_default=False)] = None, +): + import urllib.error + + renderer = get_renderer() + target = cloud_target_or_local_error(where, renderer) + + tag_list = [t.strip() for t in tags.split(",") if t.strip()] or ["input"] + url = target.url("assets/from-hash") + try: + status, body = http_request(url, target, method="POST", body={"hash": hash, "tags": tag_list}) + except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e: + raise handle_cloud_http_error(renderer, e, operation="ensure") from e + + b = body or {} + payload = { + "id": b.get("id"), + "hash": b.get("hash", hash), + "created_new": status == 201, + } + renderer.emit(payload, command="assets library ensure", where="cloud") diff --git a/comfy_cli/command/cloud_http.py b/comfy_cli/command/cloud_http.py new file mode 100644 index 00000000..e36538e5 --- /dev/null +++ b/comfy_cli/command/cloud_http.py @@ -0,0 +1,105 @@ +"""Shared cloud-HTTP helpers used by ``comfy workflow``'s cloud-saved-workflow +subcommands (and by other commands that talk to Comfy Cloud's ``/api/*`` +surface, e.g. ``comfy assets library``). + +Extracted verbatim from ``comfy_cli/command/workflow.py`` so call sites in +multiple command modules can share one implementation instead of importing +underscore-prefixed privates across module boundaries. +""" + +from __future__ import annotations + +import json + +import typer + + +def cloud_target_or_local_error(where: str | None, renderer): + """Resolve a cloud Target, or emit ``cloud_only_command`` for a non-cloud target.""" + from comfy_cli.target import resolve_target + + target = resolve_target(where=where) + if not target.is_cloud: + renderer.error( + code="cloud_only_command", + message="This command requires Comfy Cloud; there is no local equivalent.", + hint="sign in with `comfy cloud login` and re-run with `--where cloud`", + ) + raise typer.Exit(code=1) + return target + + +def _authed_request( + url: str, target, *, method: str = "GET", data: bytes | None = None, content_type: str | None = None +): + """Build an authenticated urllib Request. The return type is annotated + loosely to keep urllib out of the module's top-level imports.""" + import urllib.request + + req = urllib.request.Request(url, data=data, method=method) + if target.api_key: + req.add_header("X-API-Key", target.api_key) + elif target.auth_token: + req.add_header("Authorization", f"Bearer {target.auth_token}") + if content_type: + req.add_header("Content-Type", content_type) + return req + + +def http_request( + url: str, target, *, method: str = "GET", body: dict | None = None, timeout: float = 30.0 +) -> tuple[int, dict | None]: + """Authed HTTP call returning (status, parsed_json_or_none). Raises + urllib errors verbatim so callers can surface the right error code.""" + import urllib.request + + data = json.dumps(body).encode("utf-8") if body is not None else None + ct = "application/json" if data is not None else None + req = _authed_request(url, target, method=method, data=data, content_type=ct) + with urllib.request.urlopen(req, timeout=timeout) as resp: + status = resp.status + raw = resp.read(64 * 1024 * 1024) # 64 MiB cap + if not raw: + return status, None + try: + return status, json.loads(raw) + except json.JSONDecodeError: + return status, None + + +def handle_cloud_http_error(renderer, e, *, operation: str, workflow_id: str | None = None) -> typer.Exit: + """Map HTTP failures to envelope codes. Returns an Exit to ``raise from``.""" + import urllib.error + + if isinstance(e, urllib.error.HTTPError): + body = (e.read() or b"")[:1000].decode("utf-8", "replace") + if e.code == 404: + renderer.error( + code="workflow_not_found", + message=f"no saved workflow with id {workflow_id!r}" + if workflow_id + else f"workflow not found ({operation})", + hint="list available workflows via `comfy --json workflow list`", + details={"workflow_id": workflow_id, "operation": operation}, + ) + elif e.code in (401, 403): + renderer.error( + code="cloud_unauthorized", + message=f"HTTP {e.code} during {operation}", + hint="re-run `comfy cloud login`", + details={"status": e.code}, + ) + else: + renderer.error( + code="cloud_http_error", + message=f"HTTP {e.code} during {operation}", + hint="check `details.body` for the server's message", + details={"status": e.code, "body": body, "operation": operation}, + ) + else: + renderer.error( + code="cloud_http_error", + message=f"{operation} failed: {e}", + hint="check network / `comfy auth whoami`", + ) + return typer.Exit(code=1) diff --git a/comfy_cli/command/code_search.py b/comfy_cli/command/code_search.py index 5dd3c6ed..2b925e66 100644 --- a/comfy_cli/command/code_search.py +++ b/comfy_cli/command/code_search.py @@ -6,7 +6,6 @@ from typing import Annotated from urllib.parse import quote -import requests import typer from rich.console import Console from rich.text import Text @@ -40,6 +39,10 @@ def _build_query(query: str, repo: str | None, count: int) -> str: def _fetch_results(query: str) -> dict: + # Imported lazily: requests costs ~30ms to import and this module is on + # the import path of every CLI invocation. + import requests + response = requests.get(API_URL, params={"query": query}, timeout=REQUEST_TIMEOUT) response.raise_for_status() return response.json() @@ -168,6 +171,8 @@ def code_search( ] = False, ): """Search code across ComfyUI repositories.""" + import requests # deferred; see _fetch_results + built_query = _build_query(query, repo, count) try: diff --git a/comfy_cli/command/generate/emit.py b/comfy_cli/command/generate/emit.py index 6ff0b152..55f8d1b6 100644 --- a/comfy_cli/command/generate/emit.py +++ b/comfy_cli/command/generate/emit.py @@ -39,6 +39,21 @@ class NodeSpec: constant (defaults the node requires but that ``generate`` doesn't surface). ``output`` selects the save node (IMAGE → SaveImage, VIDEO → SaveVideo) and the partner node's output port that carries the media. + + COMPLETENESS CONTRACT for ``fixed``: it must supply a default for EVERY + widget (non-link) input of the node — the *optional* schema section + included — unless the value always arrives via ``param_map``/``image_params``. + A schema-``optional`` input is not necessarily optional at execution time: + V3 nodes may declare an input ``optional=True`` while their ``execute()`` + signature has no Python default (ByteDanceImageToVideoNode's + ``seed``/``camera_fixed``/``watermark`` do exactly this), so a workflow + that omits it validates cleanly but crashes at run time with + "missing N required positional arguments". The ComfyUI frontend always + serializes every widget value, which is why UI-exported workflows never + hit this — the emitter must match that behavior. + ``tests/comfy_cli/command/generate/test_emit.py`` enforces the contract + against a recorded object_info snapshot + (``fixtures/partner_nodes_object_info.json``). """ node_class: str @@ -63,7 +78,23 @@ class NodeSpec: "aspect_ratio": "aspect_ratio", }, image_params={"image": "images", "images": "images"}, - fixed={"model": "gemini-2.5-flash-image", "seed": 42}, + fixed={ + "model": "gemini-2.5-flash-image", + "seed": 42, + "aspect_ratio": "auto", + "response_modalities": "IMAGE+TEXT", + # Schema default (snapshot 2026-07); the node's execute() falls back + # to "" but the UI serializes this steering prompt, so match it. + "system_prompt": ( + "You are an expert image-generation engine. You must ALWAYS produce an image.\n" + "Interpret all user input—regardless of format, intent, or abstraction—as literal " + "visual directives for image composition.\n" + "If a prompt is conversational or lacks specific visual details, you must creatively " + "invent a concrete visual scenario that depicts the concept.\n" + "Prioritize generating the visual representation above any text, formatting, or " + "conversational requests." + ), + }, output="IMAGE", ), # ByteDance Seedance image-to-video. Node: ByteDanceImageToVideoNode. @@ -73,9 +104,15 @@ class NodeSpec: "prompt": "prompt", "model": "model", "resolution": "resolution", + # The proxy flag is --ratio; the node input is aspect_ratio. + "ratio": "aspect_ratio", "aspect_ratio": "aspect_ratio", "duration": "duration", "seed": "seed", + # Proxy flag --camerafixed; node input camera_fixed. + "camerafixed": "camera_fixed", + "watermark": "watermark", + "generate_audio": "generate_audio", }, image_params={"image": "image"}, fixed={ @@ -83,6 +120,13 @@ class NodeSpec: "resolution": "720p", "aspect_ratio": "16:9", "duration": 5, + # Schema-optional but positionally REQUIRED by execute() — omitting + # any of these fails the run with "missing required positional + # arguments" (observed live on cloud). See the NodeSpec docstring. + "seed": 0, + "camera_fixed": False, + "watermark": False, + "generate_audio": False, }, output="VIDEO", ), @@ -179,7 +223,7 @@ def build_workflow(model: str, values: dict[str, Any], *, output_prefix: str = " raw = values.get(flag) if raw is None: continue - if isinstance(raw, (list, tuple)): + if isinstance(raw, list | tuple): raise EmitError( f"--{flag} received multiple files, but emit-workflow currently " "maps this input to a single LoadImage node." diff --git a/comfy_cli/command/install.py b/comfy_cli/command/install.py index ed5a0c27..7683a719 100755 --- a/comfy_cli/command/install.py +++ b/comfy_cli/command/install.py @@ -6,8 +6,6 @@ from typing import TypedDict from urllib.parse import urlparse -import git -import requests import semver import typer from rich.console import Console @@ -192,6 +190,10 @@ def execute( sys.exit(1) elif not check_comfy_repo(repo_dir)[0]: + # Imported lazily: GitPython costs ~90ms to import and is only needed + # on this error-reporting path. + import git + # Get actual remote URL for better error message try: repo = git.Repo(repo_dir) @@ -602,6 +604,10 @@ def get_latest_release(repo_owner: str, repo_name: str) -> GithubRelease | None: if github_token := os.getenv("GITHUB_TOKEN"): headers["Authorization"] = f"Bearer {github_token}" + # Imported lazily: requests costs ~30ms to import and this module is on + # the import path of every CLI invocation. + import requests + try: response = requests.get(url, headers=headers, timeout=5) @@ -679,6 +685,8 @@ def fetch_pr_info(repo_owner: str, repo_name: str, pr_number: int) -> PRInfo: if github_token := os.getenv("GITHUB_TOKEN"): headers["Authorization"] = f"Bearer {github_token}" + import requests # deferred; see fetch_github_release_data + try: response = requests.get(url, headers=headers, timeout=10) @@ -714,6 +722,8 @@ def find_pr_by_branch(repo_owner: str, repo_name: str, username: str, branch: st if github_token := os.getenv("GITHUB_TOKEN"): headers["Authorization"] = f"Bearer {github_token}" + import requests # deferred; see fetch_github_release_data + try: response = requests.get(url, headers=headers, params=params, timeout=10) response.raise_for_status() diff --git a/comfy_cli/command/models/models.py b/comfy_cli/command/models/models.py index a654a18e..5410efe6 100644 --- a/comfy_cli/command/models/models.py +++ b/comfy_cli/command/models/models.py @@ -5,7 +5,6 @@ from typing import Annotated from urllib.parse import parse_qs, unquote, urlparse -import requests import typer from rich.markup import escape @@ -152,6 +151,10 @@ def check_civitai_url(url: str) -> tuple[bool, bool, int | None, int | None]: def request_civitai_model_version_api(version_id: int, headers: dict | None = None): + # Imported lazily: requests costs ~30ms to import and this module is on + # the import path of every CLI invocation. + import requests + # Make a request to the CivitAI API to get the model information response = requests.get( f"https://civitai.com/api/v1/model-versions/{version_id}", @@ -171,6 +174,8 @@ def request_civitai_model_version_api(version_id: int, headers: dict | None = No def request_civitai_model_api(model_id: int, version_id: int = None, headers: dict | None = None): + import requests # deferred; see request_civitai_model_version_api + # Make a request to the CivitAI API to get the model information response = requests.get(f"https://civitai.com/api/v1/models/{model_id}", headers=headers, timeout=10) response.raise_for_status() # Raise an error for bad status codes diff --git a/comfy_cli/command/preview.py b/comfy_cli/command/preview.py index 90680ac5..e01d3b6f 100644 --- a/comfy_cli/command/preview.py +++ b/comfy_cli/command/preview.py @@ -110,9 +110,9 @@ def build_preview_cmd( return base + ["-frames:v", "1", "-vf", f"scale='min({width},iw)':-1", out_path] -def _ffprobe(path: Path) -> dict: +def _ffprobe(path: Path, ffprobe_bin: str = "ffprobe") -> dict: proc = subprocess.run( - ["ffprobe", "-v", "error", "-print_format", "json", "-show_streams", "-show_format", str(path)], + [ffprobe_bin, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", str(path)], capture_output=True, text=True, ) @@ -121,6 +121,45 @@ def _ffprobe(path: Path) -> dict: return json.loads(proc.stdout or "{}") +def _resolve_ffmpeg() -> str | None: + """System ``ffmpeg``, else the static binary bundled with imageio-ffmpeg + (if installed). Lets `comfy preview` render on a box with no system ffmpeg.""" + found = shutil.which("ffmpeg") + if found: + return found + try: + import imageio_ffmpeg + + return imageio_ffmpeg.get_ffmpeg_exe() + except Exception: + return None + + +_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff"} +_AUDIO_EXTS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus"} +_VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v", ".flv", ".wmv", ".mpg", ".mpeg", ".ts", ".mts", ".3gp"} + + +def _classify_by_ext(path: Path) -> dict: + """Fallback classification when ffprobe is unavailable (e.g. only the + imageio-ffmpeg static ffmpeg is present): pick kind from the extension. + + An unrecognized extension is ``"unknown"`` (not blindly ``"video"``) so a + non-media file still yields the clean ``preview_unsupported_media`` envelope + instead of being handed to ffmpeg and failing with a raw ffmpeg error — the + same outcome the ffprobe path produces for a file with no media stream.""" + ext = path.suffix.lower() + if ext in _IMAGE_EXTS: + kind = "image" + elif ext in _AUDIO_EXTS: + kind = "audio" + elif ext in _VIDEO_EXTS: + kind = "video" + else: + kind = "unknown" + return {"kind": kind, "width": None, "height": None, "fps": None, "duration": None, "has_audio": None} + + @tracking.track_command("preview") def preview_cmd( file: Annotated[Path, typer.Argument(help="Image, video, or audio file to preview.")], @@ -137,19 +176,26 @@ def preview_cmd( if not file.is_file(): renderer.error(code="preview_input_not_found", message=f"File not found: {file}", hint="check the path") raise typer.Exit(code=1) - if not (shutil.which("ffmpeg") and shutil.which("ffprobe")): + ffmpeg_bin = _resolve_ffmpeg() + if not ffmpeg_bin: renderer.error( code="ffmpeg_unavailable", - message="ffmpeg/ffprobe not found on PATH — `comfy preview` needs them.", - hint="install ffmpeg (e.g. `brew install ffmpeg` / `apt install ffmpeg`)", + message="ffmpeg not found — `comfy preview` needs it to render.", + hint="install ffmpeg (`brew install ffmpeg` / `apt install ffmpeg`) or `pip install imageio-ffmpeg`", ) raise typer.Exit(code=1) - try: - info = classify_streams(_ffprobe(file)) - except (RuntimeError, json.JSONDecodeError) as e: - renderer.error(code="preview_failed", message=f"Could not probe {file}: {e}") - raise typer.Exit(code=1) from e + ffprobe_bin = shutil.which("ffprobe") + if ffprobe_bin: + try: + info = classify_streams(_ffprobe(file, ffprobe_bin)) + except (RuntimeError, json.JSONDecodeError) as e: + renderer.error(code="preview_failed", message=f"Could not probe {file}: {e}") + raise typer.Exit(code=1) from e + else: + # ffmpeg present (likely the imageio-ffmpeg static build) but no ffprobe: + # classify by extension and let ffmpeg render the preview anyway. + info = _classify_by_ext(file) if info["kind"] == "unknown": renderer.error( code="preview_unsupported_media", @@ -168,6 +214,7 @@ def preview_cmd( cmd = build_preview_cmd( info["kind"], str(file), str(out_path), grid=(cols, rows), width=width, duration=info["duration"] ) + cmd[0] = ffmpeg_bin # use the resolved binary (system or imageio-ffmpeg's static build) proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0 or not out_path.is_file(): renderer.error( diff --git a/comfy_cli/command/project.py b/comfy_cli/command/project.py index cb6f19dd..11ed5a39 100644 --- a/comfy_cli/command/project.py +++ b/comfy_cli/command/project.py @@ -87,6 +87,11 @@ def _assets_callback(): """ +from comfy_cli.command.assets_library import app as _assets_library_app # noqa: E402 + +assets_app.add_typer(_assets_library_app, name="library") + + # The marker `comfy project init` writes — deliberately literal and minimal. # The where default is resolved at init time (flag, else auto-detect) so a # local-only machine never gets a project that routes every command to cloud. diff --git a/comfy_cli/command/run/__init__.py b/comfy_cli/command/run/__init__.py index fc230d00..142494e3 100644 --- a/comfy_cli/command/run/__init__.py +++ b/comfy_cli/command/run/__init__.py @@ -493,6 +493,7 @@ def execute_cloud( timeout: int = 600, notify: bool = False, print_prompt: bool = False, + workflow_id: str | None = None, preloaded: tuple[dict, str, bool] | None = None, ): """Run a workflow against Comfy Cloud via the stored OAuth session. @@ -521,12 +522,15 @@ def execute_cloud( # exporter and `comfy templates fetch`) have to be lowered to the API # shape before submit. We do it client-side using the cloud snapshot # of object_info — the cloud server has no /workflow/convert endpoint. - from comfy_cli.cql.engine import _load_from_target + # Routed through resilient_load_object_info so COMFY_OBJECT_INFO_FILE + # (a pre-warmed/baked catalog, e.g. from an agent host) is honored + # before falling back to a live multi-MB /object_info fetch. + from comfy_cli.cql.loader import resilient_load_object_info if renderer.is_pretty(): pprint("[yellow]Detected UI-format workflow, converting to API format…[/yellow]") try: - object_info = _load_from_target(mode="cloud") + object_info = resilient_load_object_info(mode="cloud") except Exception as e: # noqa: BLE001 renderer.error( code="cql_no_graph", @@ -586,10 +590,12 @@ def execute_cloud( # Pre-submit validation via pure-Python CQL engine. # Cloud path uses cached/bundled object_info (no live server needed). + # resilient_load_object_info honors COMFY_OBJECT_INFO_FILE first (the + # baked/offline catalog an agent host provides) before a live fetch. try: - from comfy_cli.cql.engine import _load_from_target + from comfy_cli.cql.loader import resilient_load_object_info - cloud_object_info = _load_from_target(mode="cloud") + cloud_object_info = resilient_load_object_info(mode="cloud") except Exception: # noqa: BLE001 cloud_object_info = {} @@ -617,9 +623,9 @@ def execute_cloud( try: if not wait and renderer.is_pretty(): with renderer.console().status("[cyan]Submitting to Comfy Cloud…", spinner="dots"): - submit = client.submit_prompt(parsed_workflow, client_id) + submit = client.submit_prompt(parsed_workflow, client_id, workflow_id=workflow_id) else: - submit = client.submit_prompt(parsed_workflow, client_id) + submit = client.submit_prompt(parsed_workflow, client_id, workflow_id=workflow_id) except Unauthenticated as e: renderer.error(code="cloud_unauthorized", message=str(e), hint="run: comfy cloud login") raise typer.Exit(code=1) from e diff --git a/comfy_cli/command/run/watcher.py b/comfy_cli/command/run/watcher.py index 9a8d3c4c..87b03121 100644 --- a/comfy_cli/command/run/watcher.py +++ b/comfy_cli/command/run/watcher.py @@ -8,6 +8,7 @@ from __future__ import annotations +import os import subprocess import sys @@ -15,6 +16,24 @@ from comfy_cli.output import rprint as pprint +def _no_watch_requested() -> bool: + """``COMFY_NO_WATCH=1`` (or any other truthy value) suppresses the + detached watcher subprocess entirely. + + This is the env kill switch for agentic callers: the cloud agent has its + own native job-wait (Redis pub/sub + a reconcile GET) and has no use for + a second, credential-holding, ``start_new_session=True`` process that + outlives the parent and polls the jobs API for up to 6h. Checked as an + env var (rather than threaded through every call site) so a host can set + it once in the subprocess's environment without touching argv; ``comfy + run --no-watch`` sets the same variable for interactive use. + """ + value = os.environ.get("COMFY_NO_WATCH") + if value is None: + return False + return value.strip().lower() not in {"", "0", "false", "no", "off"} + + def _tail_state_file(prompt_id: str, *, seconds: float = 8.0) -> None: """Pretty-mode only: poll the state file for up to ``seconds`` showing live status transitions, then return. The background watcher keeps @@ -84,8 +103,11 @@ def _spawn_watcher( callers can find it there if needed. Returns ``True`` on success, ``False`` if the subprocess could not be - spawned. + spawned — or if suppressed entirely via ``COMFY_NO_WATCH=1`` + (see ``_no_watch_requested``). """ + if _no_watch_requested(): + return False argv = [sys.executable, "-m", "comfy_cli", "_watch", "_watch-job", prompt_id, "--where", where] if host: argv += ["--host", host] diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index d953c10e..4a676e9a 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -72,7 +72,7 @@ def _load_workflow_or_fail(renderer, path: str) -> tuple[Path, dict[str, Any]]: return p, data -def _get_graph(input_path: str | None, host: str | None, port: int | None, on_stale=None): +def _get_graph(input_path: str | None, host: str | None, port: int | None, on_stale=None, where: str | None = None): """Build a Graph from the resolved object_info source. The live (non-``--input``) fetch goes through ``resilient_load_object_info``, @@ -93,7 +93,15 @@ def _get_graph(input_path: str | None, host: str | None, port: int | None, on_st # Live fetch: resolve mode from global routing chain, then use resilient loader. from comfy_cli import where as where_module - decision = where_module.resolve_default() + # Honor an explicit --where (threaded from the agent edit commands) via + # the convenience wrapper, which folds in the config/project precedence. + # A bad --where value raises ValueError — surface it as the agent-first + # error envelope, not a raw traceback out of every edit command. + try: + decision = where_module.resolve_default(where) + except ValueError as e: + renderer.error(code="where_invalid", message=str(e), hint="use --where local or --where cloud") + raise typer.Exit(code=1) from e mode = "cloud" if decision.target is where_module.WhereTarget.CLOUD else "local" from comfy_cli.cql.loader import resilient_load_object_info @@ -1164,3 +1172,27 @@ def delete_cmd( help="Project a workflow (template or API JSON) into a reusable fragment — the inverse of compose.", )(_wfrag.decompose_cmd) app.add_typer(_wfrag.fragment_app, name="fragment") + + +# --------------------------------------------------------------------------- +# Structured, CRDT-ready edit primitives (add-node / connect / set-widget / +# delete). Implemented in workflow_edit.py; mounted here so the surface stays +# under `comfy workflow`. Each emits a replayable op in `data.op`. +# --------------------------------------------------------------------------- + +from comfy_cli.command import workflow_edit as _wedit # noqa: E402 + +app.command("add-node", help="Add a node to the graph; emits an add_node op.")(_wedit.add_node_cmd) +app.command("connect", help="Wire an output slot to an input slot; emits a connect op.")(_wedit.connect_cmd) +app.command("set-widget", help="Set a widget by name (`.`); emits a set_widget op.")(_wedit.set_widget_cmd) +app.command("delete-node", help="Delete a node and its links; emits a delete_node op.")(_wedit.delete_cmd) +app.command("ls-nodes", help="List nodes (id/type/title) in a workflow file.")(_wedit.ls_nodes_cmd) +app.command("apply", help="Apply a recipe / batch of edits in one pass; supports node aliases + --param.")( + _wedit.apply_cmd +) +app.command("capture", help="Project a workflow into a reusable recipe (the op-batch that rebuilds it).")( + _wedit.capture_cmd +) +app.command("foreach", help="Instantiate a recipe over N param-sets → N workflows (bulk generation).")( + _wedit.foreach_cmd +) diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py new file mode 100644 index 00000000..b0addf4e --- /dev/null +++ b/comfy_cli/command/workflow_edit.py @@ -0,0 +1,562 @@ +"""``comfy workflow add-node/connect/set-widget/delete`` — structured, +CRDT-ready edit primitives for frontend-format ComfyUI workflows. + +Each command mutates the workflow file in place (or ``--stdout``) AND emits a +replayable operation in the envelope's ``data.op``. The op is what a CRDT/merge +consumer (the cloud agent) applies; the file write is the single-writer local +path. Both come from the same ``comfy_cli.workflow_ops`` core, so a local edit +and a server-side merge stay in lock-step. + +Node/link identity is leaderless (random 53-bit ints) so concurrent edits never +collide; widgets are addressed by name, not array index. See ``workflow_ops``. +""" + +from __future__ import annotations + +import json +from typing import Annotated, Any + +import typer + +from comfy_cli import tracking, workflow_ops +from comfy_cli.command.workflow import ( + _atomic_write_text, + _get_graph, + _load_workflow_or_fail, + _parse_value, +) +from comfy_cli.output import get_renderer, rprint + +# Shared option aliases — the edit commands (add-node/set-widget/connect/ +# delete-node/capture/apply/foreach) all take the same catalog + CRDT-stamping +# options. Declaring each once here means a help string or default can't drift +# between the 7 near-identical signatures. +ActorOpt = Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] +BaseVersionOpt = Annotated[int, typer.Option("--base-version", help="Draft version this edit is based on.")] +StdoutOpt = Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] +InputOpt = Annotated[str | None, typer.Option("--input", show_default=False)] +HostOpt = Annotated[str | None, typer.Option(show_default=False)] +PortOpt = Annotated[int | None, typer.Option(show_default=False)] +WhereOpt = Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] + + +def _split_addr(addr: str, renderer) -> tuple[Any, str]: + """Split ``.`` → (node_id, name). node_id is int when numeric.""" + if "." not in addr: + renderer.error( + code="workflow_edit_invalid", + message=f"expected `.`, got {addr!r}", + hint="example: `3.steps` — run `comfy workflow slots ` to list node ids", + ) + raise typer.Exit(code=1) + node_str, _, name = addr.partition(".") + node_str = node_str.strip() + node_id: Any = int(node_str) if node_str.lstrip("-").isdigit() else node_str + return node_id, name.strip() + + +def _finish(renderer, p, workflow: dict, op: dict, base_version: int, stdout: bool, command: str) -> None: + """Serialize the mutated workflow (file or stdout) and emit the op envelope.""" + workflow_ops.strip_internal(workflow) + serialized = json.dumps(workflow, indent=2) + wrote: str | None = None + if stdout: + import sys + + sys.stdout.write(serialized) + sys.stdout.write("\n") + else: + _atomic_write_text(p, serialized) + wrote = str(p) + payload = { + "workflow": str(p), + "op": op, + "base_version": base_version, + "version": base_version + 1, + "wrote": wrote, + } + if op.get("warnings"): + payload["warnings"] = op["warnings"] + if renderer.is_pretty(): + rprint(f"[bold green]✓[/bold green] {op['op']} → [dim]{p}[/dim]") + renderer.emit(payload, command=command, changed=True) + + +def _graph_or_exit(input_path, host, port, renderer, where=None): + return _get_graph(input_path, host, port, where=where) + + +# --------------------------------------------------------------------------- +# add-node +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def add_node_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + class_type: Annotated[str, typer.Argument(help="Node class_type, e.g. KSampler.")], + at: Annotated[ + str | None, + typer.Option("--at", show_default=False, help="Canvas position 'x,y' for the new node."), + ] = None, + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, +): + renderer = get_renderer() + renderer.command = "workflow add-node" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + pos = None + if at: + try: + pos = [float(x) for x in at.split(",", 1)] + except ValueError as e: + renderer.error(code="workflow_edit_invalid", message=f"--at must be 'x,y': {e}") + raise typer.Exit(code=1) from e + try: + workflow, op = workflow_ops.add_node( + workflow, graph, class_type, pos=pos, actor=actor, base_version=base_version + ) + except ValueError as e: + renderer.error(code="workflow_edit_invalid", message=str(e), hint="run `comfy nodes types` to list class_types") + raise typer.Exit(code=1) from e + _finish(renderer, p, workflow, op, base_version, stdout, "workflow add-node") + + +# --------------------------------------------------------------------------- +# set-widget +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def set_widget_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + addr: Annotated[str, typer.Argument(help="Widget address `.`.")], + value: Annotated[str, typer.Argument(help="New value (parsed as JSON, else literal string).")], + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, +): + renderer = get_renderer() + renderer.command = "workflow set-widget" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + node_id, widget = _split_addr(addr, renderer) + # Subgraph addresses — a promoted input on a subgraph instance (flat + # ``57.text``, exactly what `slots` advertises) or an interior node (nested + # ``57/27.text``) — are resolved inside ``workflow_ops.set_widget`` against + # the same CQL resolver `slots` uses, and emit a replayable op that writes + # back into the subgraph definition. + try: + workflow, op = workflow_ops.set_widget( + workflow, graph, node_id, widget, _parse_value(value), actor=actor, base_version=base_version + ) + except ValueError as e: + renderer.error( + code="workflow_edit_invalid", + message=str(e), + hint="run `comfy workflow slots ` to list widget addresses", + ) + raise typer.Exit(code=1) from e + _finish(renderer, p, workflow, op, base_version, stdout, "workflow set-widget") + + +# --------------------------------------------------------------------------- +# connect +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def connect_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + source: Annotated[str, typer.Argument(help="Source `.` (slot name or index).")], + target: Annotated[str, typer.Argument(help="Target `.` (slot name or index).")], + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, +): + renderer = get_renderer() + renderer.command = "workflow connect" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + from_node, from_slot = _split_addr(source, renderer) + to_node, to_slot = _split_addr(target, renderer) + try: + workflow, op = workflow_ops.connect( + workflow, graph, from_node, from_slot, to_node, to_slot, actor=actor, base_version=base_version + ) + except ValueError as e: + renderer.error(code="workflow_edit_invalid", message=str(e)) + raise typer.Exit(code=1) from e + _finish(renderer, p, workflow, op, base_version, stdout, "workflow connect") + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def delete_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + node: Annotated[str, typer.Argument(help="Node id to delete.")], + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, +): + renderer = get_renderer() + renderer.command = "workflow delete-node" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + node_id: Any = int(node) if node.lstrip("-").isdigit() else node + try: + workflow, op = workflow_ops.delete_node(workflow, graph, node_id, actor=actor, base_version=base_version) + except ValueError as e: + renderer.error(code="workflow_edit_invalid", message=str(e)) + raise typer.Exit(code=1) from e + _finish(renderer, p, workflow, op, base_version, stdout, "workflow delete") + + +# --------------------------------------------------------------------------- +# ls-nodes — recover node ids/types (so an agent can address minted nodes) +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def ls_nodes_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], +): + renderer = get_renderer() + renderer.command = "workflow ls-nodes" + p, workflow = _load_workflow_or_fail(renderer, file) + rows = [] + for n in workflow.get("nodes") or []: + if not isinstance(n, dict): + continue + rows.append( + { + "id": n.get("id"), + "type": n.get("type"), + "title": n.get("title") or (n.get("properties") or {}).get("Node name for S&R"), + } + ) + payload = {"workflow": str(p), "count": len(rows), "nodes": rows} + if renderer.is_pretty(): + from rich.table import Table + + tbl = Table(show_header=True, header_style="bold") + tbl.add_column("id", no_wrap=True) + tbl.add_column("type") + tbl.add_column("title", style="dim") + for r in rows: + tbl.add_row(str(r["id"]), str(r["type"]), str(r["title"] or "")) + renderer.console().print(tbl) + renderer.emit(payload, command="workflow ls-nodes") + + +# --------------------------------------------------------------------------- +# capture — project a graph into a reusable recipe (the decompose analog) +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def capture_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON to capture.")], + name: Annotated[str | None, typer.Option("--name", show_default=False, help="Recipe name.")] = None, + param: Annotated[ + list[str] | None, + typer.Option( + "--param", + show_default=False, + help="Lift a widget to a recipe param: `.=` (repeatable).", + ), + ] = None, + out: Annotated[ + str | None, + typer.Option("--out", "-o", show_default=False, help="Write the recipe JSON here (else stdout)."), + ] = None, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, +): + """Project a workflow into a reusable recipe — the op-batch that rebuilds it. + `apply` that recipe onto an empty graph to reproduce the workflow; edit a value + to a `${param}` to make it parameterized.""" + from pathlib import Path + + renderer = get_renderer() + renderer.command = "workflow capture" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + lift: dict[tuple[Any, str], str] = {} + for spec in param or []: + if "=" not in spec or "." not in spec.split("=", 1)[0]: + renderer.error( + code="workflow_edit_invalid", + message=f"--param must be `.=`, got {spec!r}", + ) + raise typer.Exit(code=1) + target, _, pname = spec.partition("=") + node_str, _, widget = target.partition(".") # node id has no dot; widget may (model.resolution) + node_id: Any = int(node_str) if node_str.lstrip("-").isdigit() else node_str + lift[(node_id, widget)] = pname.strip() + try: + recipe = workflow_ops.capture_recipe(workflow, graph, name=name or p.stem, lift=lift) + except workflow_ops.RecipeError as e: + renderer.error(code="workflow_edit_invalid", message=str(e)) + raise typer.Exit(code=1) from e + + serialized = json.dumps(recipe, indent=2) + wrote: str | None = None + if out: + out_path = Path(out).expanduser() + out_path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_text(out_path, serialized) + wrote = str(out_path) + elif renderer.is_pretty(): + import sys + + sys.stdout.write(serialized + "\n") + payload = { + "recipe": recipe["recipe"], + "op_count": len(recipe["ops"]), + "out": wrote or "stdout", + "recipe_doc": recipe, + } + if renderer.is_pretty() and wrote: + rprint(f"[bold green]✓[/bold green] captured {len(recipe['ops'])} ops → [dim]{wrote}[/dim]") + renderer.emit(payload, command="workflow capture") + + +# --------------------------------------------------------------------------- +# apply — batch: one object_info load, many edits, aliases for just-made nodes +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def apply_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + ops_file: Annotated[ + str, + typer.Option("--ops", help="Recipe file (JSON array of ops, or {params, ops}), or '-' for stdin."), + ], + param: Annotated[ + list[str] | None, + typer.Option("--param", show_default=False, help="Recipe param as key=value; repeatable."), + ] = None, + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, +): + """Apply a batch of edits in one pass — the catalog loads once, and an + `add_node` spec may set `"as": ""` so later specs reference the + minted node by alias instead of a captured id.""" + renderer = get_renderer() + renderer.command = "workflow apply" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + + if ops_file == "-": + import sys + + raw = sys.stdin.read() + else: + from pathlib import Path + + try: + raw = Path(ops_file).expanduser().read_text(encoding="utf-8") + except OSError as e: + renderer.error(code="workflow_edit_invalid", message=f"cannot read --ops file: {e}") + raise typer.Exit(code=1) from e + try: + doc = json.loads(raw) + except json.JSONDecodeError as e: + renderer.error(code="workflow_edit_invalid", message=f"--ops is not valid JSON: {e}") + raise typer.Exit(code=1) from e + + # A recipe is `{params?, ops}` (or a bare op list); `${param}` holes are filled + # from --param with strict validation (no silent blanks). + provided: dict[str, str] = {} + for kv in param or []: + if "=" not in kv: + renderer.error(code="workflow_edit_invalid", message=f"--param must be key=value, got {kv!r}") + raise typer.Exit(code=1) + k, _, v = kv.partition("=") + provided[k.strip()] = v + try: + specs, params_decl = workflow_ops.parse_recipe(doc) + params = workflow_ops.resolve_params(params_decl, provided) + specs = workflow_ops.substitute_params(specs, params) + except workflow_ops.RecipeError as e: + renderer.error(code="workflow_edit_invalid", message=str(e)) + raise typer.Exit(code=1) from e + + try: + workflow, ops, aliases = workflow_ops.apply_specs( + workflow, graph, specs, actor=actor, base_version=base_version + ) + except (ValueError, KeyError) as e: + # Atomic batch: nothing is written if any spec fails. + renderer.error(code="workflow_edit_invalid", message=f"batch failed: {e}") + raise typer.Exit(code=1) from e + + workflow_ops.strip_internal(workflow) + serialized = json.dumps(workflow, indent=2) + wrote: str | None = None + if stdout: + import sys + + sys.stdout.write(serialized + "\n") + else: + _atomic_write_text(p, serialized) + wrote = str(p) + payload = { + "workflow": str(p), + "count": len(ops), + "ops": ops, + "aliases": aliases, + "base_version": base_version, + "version": base_version + len(ops), + "wrote": wrote, + } + if renderer.is_pretty(): + rprint(f"[bold green]✓[/bold green] applied {len(ops)} edit(s) → [dim]{p}[/dim]") + renderer.emit(payload, command="workflow apply", changed=True) + + +# --------------------------------------------------------------------------- +# foreach — instantiate a recipe over N param-sets → N ready-to-run workflows +# --------------------------------------------------------------------------- + + +def _load_param_sets(raw: str, renderer) -> list[dict]: + """Param-sets are a JSON array of objects, a single object, or JSONL.""" + raw = raw.strip() + try: + doc = json.loads(raw) + return doc if isinstance(doc, list) else [doc] + except json.JSONDecodeError: + pass + sets: list[dict] = [] + for ln in raw.splitlines(): + ln = ln.strip() + if not ln: + continue + try: + sets.append(json.loads(ln)) + except json.JSONDecodeError as e: + renderer.error(code="workflow_edit_invalid", message=f"--params line is not JSON: {e}") + raise typer.Exit(code=1) from e + return sets + + +@tracking.track_command("workflow") +def foreach_cmd( + recipe_file: Annotated[str, typer.Argument(help="Recipe file: {params, ops}.")], + params_file: Annotated[ + str, + typer.Option("--params", help="Param-sets: a JSON array of objects, one object, or JSONL; '-' for stdin."), + ], + out_dir: Annotated[str, typer.Option("--out-dir", help="Directory to write the N materialized workflows.")], + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, +): + """Instantiate a recipe over N param-sets → N ready-to-run workflows (bulk). + Run them with `comfy run --workflow --where cloud`.""" + import sys + from pathlib import Path + + renderer = get_renderer() + renderer.command = "workflow foreach" + graph = _graph_or_exit(input_path, host, port, renderer, where) + try: + doc = json.loads(Path(recipe_file).expanduser().read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + renderer.error(code="workflow_edit_invalid", message=f"cannot read recipe: {e}") + raise typer.Exit(code=1) from e + raw = sys.stdin.read() if params_file == "-" else _read_text_or_exit(renderer, params_file) + param_sets = _load_param_sets(raw, renderer) + if not param_sets: + renderer.error(code="workflow_edit_invalid", message="--params yielded no param-sets") + raise typer.Exit(code=1) + + try: + specs_template, params_decl = workflow_ops.parse_recipe(doc) + except workflow_ops.RecipeError as e: + renderer.error(code="workflow_edit_invalid", message=str(e)) + raise typer.Exit(code=1) from e + + name = (doc.get("recipe") if isinstance(doc, dict) else None) or Path(recipe_file).expanduser().stem + out = Path(out_dir).expanduser() + out.mkdir(parents=True, exist_ok=True) + written: list[str] = [] + try: + for i, pset in enumerate(param_sets): + if not isinstance(pset, dict): + raise workflow_ops.RecipeError(f"param-set #{i} must be a JSON object") + params = workflow_ops.resolve_params(params_decl, {k: str(v) for k, v in pset.items()}) + specs = workflow_ops.substitute_params(specs_template, params) + wf: dict = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + wf, _ops, _aliases = workflow_ops.apply_specs(wf, graph, specs, actor=actor, base_version=base_version) + workflow_ops.strip_internal(wf) + target = out / f"{name}_{i:03d}.json" + _atomic_write_text(target, json.dumps(wf, indent=2)) + written.append(str(target)) + except (workflow_ops.RecipeError, ValueError, KeyError) as e: + # foreach writes one file per param-set as it goes, so a mid-batch failure + # leaves the earlier files on disk. Surface them (in the hint AND machine- + # readable details) so the caller isn't blind to the partial output. + renderer.error( + code="workflow_edit_invalid", + message=f"foreach failed: {e}", + hint=( + f"{len(written)} workflow(s) were written to {out} before the failure — " + "delete them or fix the failing param-set and re-run" + ) + if written + else None, + details={"written": written} if written else None, + ) + raise typer.Exit(code=1) from e + + payload = {"recipe": name, "count": len(written), "out_dir": str(out), "written": written} + if renderer.is_pretty(): + rprint(f"[bold green]✓[/bold green] materialized {len(written)} workflow(s) → [dim]{out}[/dim]") + rprint("[dim]run each: comfy run --workflow --where cloud[/dim]") + renderer.emit(payload, command="workflow foreach", changed=bool(written)) + + +def _read_text_or_exit(renderer, path: str) -> str: + from pathlib import Path + + try: + return Path(path).expanduser().read_text(encoding="utf-8") + except OSError as e: + renderer.error(code="workflow_edit_invalid", message=f"cannot read --params file: {e}") + raise typer.Exit(code=1) from e diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index a8068aaa..c3c417af 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -11,12 +11,12 @@ import copy import difflib +import hashlib as _hashlib import json import logging import urllib.error import urllib.parse import urllib.request -import uuid as _uuid from collections import defaultdict from dataclasses import dataclass, field from typing import Any @@ -40,6 +40,10 @@ class PortOptions: multiline: bool = False control_after_generate: bool = False force_input: bool = False + # For COMFY_DYNAMICCOMBO_V3: the raw options list ({key, inputs} dicts) so the + # engine can expand key-dependent sub-widgets (e.g. model → model.resolution), + # matching the converter. None for ordinary inputs. + dynamic_options: list | None = None @dataclass @@ -65,6 +69,52 @@ def autogrow_slot_example(self) -> str: stem = self.name[:-1] if self.name.endswith("s") else self.name return f"{self.name}.{stem}0, {self.name}.{stem}1, …" + def canonical_combo(self, value: Any) -> Any | None: + """Map a *mangled* COMBO value to the real option it clearly means, or + None if it can't be resolved unambiguously. + + A model name is one of these enum options, but an LLM tends to rebuild it + from memory — adding a directory prefix (``checkpoints/foo.safetensors`` + when the option is bare ``foo.safetensors``), dropping a subfolder, or + drifting case. The filename is almost always right, so we match by + basename (case-insensitive) and, only when EXACTLY ONE option matches, + return it. Ambiguous or unmatched values return None so the caller still + surfaces ``unknown_enum_value``. Exact values return None (nothing to do). + """ + if self.type != "COMBO" or not self.enum_values: + return None + opts = [str(e) for e in self.enum_values] + s = str(value) + if s in opts: + return None + base = s.rsplit("/", 1)[-1].lower() + matches = [o for o in opts if o.rsplit("/", 1)[-1].lower() == base] + if len(matches) == 1: + return matches[0] + ci = [o for o in opts if o.lower() == s.lower()] + if len(ci) == 1: + return ci[0] + return None + + def suggest_combo(self, value: Any, *, limit: int = 5) -> list[str]: + """Closest real options to a rejected COMBO value, for a ``did_you_mean`` + hint — so an unavailable model points at the nearest available one the + agent can substitute or offer, instead of a dead value.""" + if self.type != "COMBO" or not self.enum_values: + return [] + import difflib + + opts = [str(e) for e in self.enum_values] + base = str(value).rsplit("/", 1)[-1] + bases = [o.rsplit("/", 1)[-1] for o in opts] + out: list[str] = [] + for g in difflib.get_close_matches(base, bases, n=limit, cutoff=0.5): + for o in opts: + if o.rsplit("/", 1)[-1] == g and o not in out: + out.append(o) + break + return out[:limit] + def validate_shape(self, value: Any) -> str | None: """Hard-reject on JSON-shape mismatch. Returns error message or None.""" if self.type == "INT": @@ -107,14 +157,17 @@ def validate_catalog(self, value: Any) -> list[dict]: candidates.add(str(int(value))) enum_str = {str(e) for e in self.enum_values} if not (candidates & enum_str): - warnings.append( - { - "code": "unknown_enum_value", - "field": self.name, - "message": f"{value!r} not in {len(self.enum_values)} known options for {self.name}", - "valid_options": list(self.enum_values), - } - ) + warning = { + "code": "unknown_enum_value", + "field": self.name, + "message": f"{value!r} not in {len(self.enum_values)} known options for {self.name}", + "valid_options": list(self.enum_values), + } + suggestions = self.suggest_combo(value) + if suggestions: + warning["did_you_mean"] = suggestions + warning["message"] += f" — closest: {', '.join(suggestions)}" + warnings.append(warning) if self.type in ("INT", "FLOAT", "NUMBER") and isinstance(value, int | float): if self.options.min is not None and value < self.options.min: warnings.append( @@ -267,6 +320,13 @@ def _parse_input_spec(spec: Any) -> tuple[str, bool, list[Any], PortOptions]: # `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 + # Dynamic combo (COMFY_DYNAMICCOMBO_V3): options are {key, inputs} dicts. + # It IS a widget (the frontend renders a selector + key-dependent + # sub-widgets); the selector's choices are the keys. Capture the tree so + # widget_order can expand the sub-widgets — matching the API converter. + if isinstance(options, list) and options and all(isinstance(v, dict) and "key" in v for v in options): + port_opts.dynamic_options = options + return first, True, [v["key"] for v in options], port_opts return first, False, [], port_opts if isinstance(first, list): @@ -276,6 +336,49 @@ def _parse_input_spec(spec: Any) -> tuple[str, bool, list[Any], PortOptions]: return "UNKNOWN", False, [], port_opts +_FIRST_KEY = object() # sentinel: expand the first/default dynamic-combo key + + +def _dynamic_sub_widget_names(base: str, options: list, selected: Any = _FIRST_KEY) -> list[str]: + """Sub-widget names a dynamic combo expands to for the ``selected`` key + (default: the first/default key) — e.g. ``model`` → ``["model.resolution"]``. + Static mirror of the converter's value-driven ``_dynamic_combo_sub_inputs``.""" + return [name for name, _ in _dynamic_sub_widget_defaults(base, options, selected).items()] + + +def _dynamic_sub_widget_defaults(base: str, options: list, selected: Any = _FIRST_KEY) -> dict[str, Any]: + """``{f"{base}.{sub}": default}`` for the ``selected`` key's sub-inputs. + + Defaults to the first key (fresh nodes select it). Passing the node's actual + selected key — as ``widget_order_for_node`` does — keeps the widget order + aligned to ``widgets_values`` when a node picks an option whose sub-widget + count differs from the default. An unknown key expands to nothing, matching + the converter's ``_dynamic_combo_sub_inputs``.""" + if not options: + return {} + if selected is _FIRST_KEY: + option = options[0] if isinstance(options[0], dict) else None + else: + option = next((o for o in options if isinstance(o, dict) and o.get("key") == selected), None) + if option is None: + return {} + sub_def = option.get("inputs") + if not isinstance(sub_def, dict): + return {} + out: dict[str, Any] = {} + for section in ("required", "optional"): + section_def = sub_def.get(section) or {} + if not isinstance(section_def, dict): + continue + for sub_name, spec in section_def.items(): + _t, _e, enum_values, opts = _parse_input_spec(spec) + default = opts.default + if default is None and enum_values: + default = enum_values[0] + out[f"{base}.{sub_name}"] = default + return out + + def _is_scalar_choice(v: Any) -> bool: """A combo option is enumerable only if it's a scalar. Dynamic combos (COMFY_DYNAMICCOMBO_V3) carry dict options describing sub-inputs — those @@ -688,10 +791,74 @@ def widget_order(self, class_name: str) -> list[str]: if p.is_link: continue order.append(p.name) + if p.options.dynamic_options: + order.extend(_dynamic_sub_widget_names(p.name, p.options.dynamic_options)) + if p.options.control_after_generate: + order.append("control_after_generate") + return order + + def widget_order_for_node(self, class_name: str, widgets_values: Any = None) -> list[str]: + """Widget order aligned to a SPECIFIC node's ``widgets_values``. + + Identical to :meth:`widget_order`, except a dynamic combo expands the + sub-widgets of its *currently selected* key (read from ``widgets_values``) + rather than the schema's first key. The two agree for a fresh node (which + defaults to the first key) but diverge once a node selects an option whose + sub-widget count differs — and there the static first-key order mis-indexes + every widget after the combo, so e.g. ``set-widget .seed`` would write + into ``model.resolution``. Falls back to the static order when + ``widgets_values`` is empty (a fresh/unselected node selects the first key). + """ + base = self.widget_order(class_name) + m = self._nodes.get(class_name) + values = list(widgets_values) if widgets_values else [] + # The static order is already exact unless this node BOTH has a dynamic + # combo AND carries a selection to read. Delegating otherwise keeps the + # single source of truth (and any override) for the common case. + if m is None or not base or not values or not any(p.options.dynamic_options for p in m.inputs if not p.is_link): + return base + order: list[str] = [] + vidx = 0 + for p in m.inputs: + if p.is_link: + continue + order.append(p.name) + selector_idx = vidx + vidx += 1 + if p.options.dynamic_options: + selected = values[selector_idx] if selector_idx < len(values) else _FIRST_KEY + subs = _dynamic_sub_widget_names(p.name, p.options.dynamic_options, selected) + order.extend(subs) + vidx += len(subs) if p.options.control_after_generate: order.append("control_after_generate") + vidx += 1 return order + def widget_defaults(self, class_name: str) -> dict[str, Any]: + """Default value per widget-order name — including dynamic-combo selectors + (first key), their sub-widgets, and control_after_generate. Used by + ``add-node`` so a fresh node is runtime-valid, aligned with the converter.""" + m = self._nodes.get(class_name) + if m is None: + return {} + out: dict[str, Any] = {} + for p in m.inputs: + if p.is_link: + continue + if p.options.dynamic_options: + out[p.name] = p.enum_values[0] if p.enum_values else None # selected key + out.update(_dynamic_sub_widget_defaults(p.name, p.options.dynamic_options)) + elif p.options.default is not None: + out[p.name] = p.options.default + elif p.enum_values: + out[p.name] = p.enum_values[0] + else: + out[p.name] = None + if p.options.control_after_generate: + out["control_after_generate"] = "fixed" + return out + # -- Validation -- def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: @@ -868,6 +1035,33 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: errors.extend(_check_autogrow_required(node_id, autogrow_ports, autogrow_seen, node_data)) + # Required inputs must be PRESENT, not just well-typed when given. + # The server rejects a node whose required input is absent + # ("Required input is missing"), so a validate pass that only + # inspects the inputs the workflow happens to contain is a false + # green. Autogrow ports are covered by the slot check above. + provided = set((node_data.get("inputs") or {}).keys()) + for port in m.inputs: + if not port.required or port.is_autogrow or port.name in provided: + continue + hint = f"set {port.name!r} to a {port.type} value" + if port.options.default is not None: + hint += f" (default: {port.options.default!r})" + elif port.enum_values: + hint += f" (e.g. {port.enum_values[0]!r})" + errors.append( + { + "node_id": node_id, + "field": port.name, + "code": "missing_required_input", + "message": ( + f"required input {port.name!r} of {class_type} is missing — " + f"the server will reject this node" + ), + "hint": hint, + } + ) + return { "valid": len(errors) == 0, "errors": errors, @@ -1239,8 +1433,8 @@ def _node_widget_slots(node: dict, prefix: str, graph: Graph) -> list[dict]: m = graph.node(node_type) if m is None: return [] - order = graph.widget_order(node_type) widgets = node.get("widgets_values") or [] + order = graph.widget_order_for_node(node_type, widgets) slots: list[dict] = [] for port in m.inputs: if port.is_link: @@ -1389,12 +1583,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 @@ -1411,7 +1605,7 @@ def _write_widget(node: dict, input_name: str, value: Any, graph: Graph, *, exte 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) + order = graph.widget_order_for_node(node_type, node.get("widgets_values")) try: widget_idx = order.index(input_name) except ValueError: @@ -1488,19 +1682,87 @@ def _isolate_shared_subgraph(workflow: dict, instance: dict, defs_by_id: dict[st """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. + + The fork id is DERIVED DETERMINISTICALLY from ``(definition id, instance id)`` + — never a random UUID — so two replicas replaying the same op produce + byte-identical graphs (a convergence requirement of the op model in + :mod:`comfy_cli.workflow_ops`). """ def_id = str(instance.get("type", "")) sg = defs_by_id.get(def_id) if sg is None or _count_instances(workflow, def_id) <= 1: return new_sg = copy.deepcopy(sg) - new_id = str(_uuid.uuid4()) + new_id = _deterministic_fork_id(def_id, instance.get("id")) new_sg["id"] = new_id workflow.setdefault("definitions", {}).setdefault("subgraphs", []).append(new_sg) instance["type"] = new_id +def _deterministic_fork_id(def_id: str, instance_id: Any) -> str: + """A stable id for the isolated copy of ``def_id`` owned by ``instance_id``. + Deterministic across processes (``hashlib``, not the salted builtin ``hash``) + so replaying the same op anywhere yields the same id. SHA-256 (not SHA-1) — + this isn't a security boundary, but there's no reason to reach for a broken + hash, and it keeps the scanners quiet.""" + seed = f"{def_id}\x00{instance_id}".encode() + return "sg-" + _hashlib.sha256(seed).hexdigest()[:32] + + +def _suggest_slots_for_input(workflow: dict, input_name: str, graph: Graph, *, limit: int = 6) -> list[str]: + """Real slot addresses whose widget name matches ``input_name``. + + Turns an unresolvable address into an actionable correction: an agent that + named the right widget but the wrong node or separator (e.g. + ``285/288.vae_name`` or ``285:288.vae_name`` when the VAELoader is ``285/29``) + is pointed at the address that actually carries ``vae_name``. Best-effort — + any extraction failure yields no suggestions rather than masking the error. + """ + if not input_name: + return [] + try: + slots = _extract_frontend_slots(workflow, graph) + except Exception: + return [] + out: list[str] = [] + for s in slots: + if s.get("name") == input_name: + addr = s.get("address") or "" + node_type = s.get("node_type") or "" + out.append(f"{addr} ({node_type})" if node_type else addr) + if len(out) >= limit: + break + return out + + def _apply_one_slot(workflow: dict, addr: str, value: Any, graph: Graph) -> list[dict]: + """Apply one slot override, enriching *not-found* errors with real address + suggestions so a mistargeted edit self-corrects in one step. + + An LLM that reconstructs an interior address from memory (rather than copying + it from ``slots``) tends to hit a real *sibling* node — e.g. writing + ``285/288.vae_name`` (a CLIPLoader) when the VAELoader is ``285/29``. The + intended widget name is almost always right, so on a not-found failure we + scan the workflow for the address that actually carries that widget and name + it in the error. Shape/enum errors (the target resolved fine) pass through + unchanged. + """ + try: + return _apply_one_slot_impl(workflow, addr, value, graph) + except ValueError as e: + if "not found" not in str(e): + raise + input_name = addr.split(".", 1)[1] if "." in addr else "" + suggestions = _suggest_slots_for_input(workflow, input_name, graph) + if not suggestions: + raise + raise ValueError( + f"{e}. Did you mean: {'; '.join(suggestions)}? " + "Copy the address verbatim from `comfy workflow slots` — never rebuild it." + ) from e + + +def _apply_one_slot_impl(workflow: dict, addr: str, value: Any, graph: Graph) -> list[dict]: """Apply a single slot override. Returns warnings. Raises ValueError on hard errors. Address forms (see ``_extract_frontend_slots`` / ``_SUBGRAPH_PATH_SEP``): diff --git a/comfy_cli/cql/loader.py b/comfy_cli/cql/loader.py index c3d0e4b1..9ec6995b 100644 --- a/comfy_cli/cql/loader.py +++ b/comfy_cli/cql/loader.py @@ -22,6 +22,7 @@ import json import os import sys +import time import urllib.error import urllib.parse import urllib.request @@ -266,9 +267,12 @@ def _from_api_workflow(data: dict[str, Any]) -> dict[str, Any]: # raw object_info path didn't leverage it, and there was no offline fallback. # # ``resilient_load_object_info`` wraps the engine's network fetch with: -# 1. auto-cache of every successful fetch (per host), -# 2. one refresh-and-retry on failure, and -# 3. a stale-cache fallback (with a clear stderr warning) when the retry +# 1. a cache-first TTL gate: a cache entry younger than the TTL (default +# 10 minutes, ``COMFY_OBJECT_INFO_TTL`` seconds to override, ``0`` to +# always fetch) is served without any network call, +# 2. auto-cache of every successful fetch (per host), +# 3. one refresh-and-retry on failure, and +# 4. a stale-cache fallback (with a clear stderr warning) when the retry # still fails — only raising the original error when no cache exists. # # An explicit ``--input `` always wins and is never cached. @@ -340,6 +344,51 @@ def read_object_info_cache(host_key: str) -> dict[str, Any] | None: return data if isinstance(data, dict) else None +# Cache-first TTL policy. A cache entry younger than this is served without a +# network call; the entry's age is its file mtime (``write_object_info_cache`` +# writes via tmp + ``os.replace``, so mtime == fetch time). +DEFAULT_OBJECT_INFO_TTL_SECONDS = 600.0 +OBJECT_INFO_TTL_ENV = "COMFY_OBJECT_INFO_TTL" + + +def object_info_cache_ttl() -> float: + """TTL (seconds) for the cache-first object_info gate. + + Reads ``COMFY_OBJECT_INFO_TTL``; unset/blank/unparseable values fall back + to the 10-minute default. ``0`` (or any value <= 0) disables the + cache-first gate entirely — every call fetches live, restoring the + pre-TTL behavior (the stale-cache *failure* fallback still applies). + """ + raw = os.environ.get(OBJECT_INFO_TTL_ENV) + if raw is None or not raw.strip(): + return DEFAULT_OBJECT_INFO_TTL_SECONDS + try: + ttl = float(raw) + except ValueError: + return DEFAULT_OBJECT_INFO_TTL_SECONDS + return max(ttl, 0.0) + + +def read_fresh_object_info_cache(host_key: str, ttl: float) -> dict[str, Any] | None: + """Return the cached dump for ``host_key`` iff it is younger than ``ttl``. + + Freshness is judged by the cache file's mtime. Returns ``None`` when the + TTL gate is disabled (``ttl <= 0``), the file is missing/unreadable, the + entry has expired, or the mtime is in the future (clock skew — treat as + expired rather than trusting a timestamp we can't reason about). + """ + if ttl <= 0: + return None + path = object_info_cache_path(host_key) + try: + age = time.time() - path.stat().st_mtime + except OSError: + return None + if age < 0 or age >= ttl: + return None + return read_object_info_cache(host_key) + + def _resolve_host_key(mode: str, host: str, port: int) -> str: """Resolve the cache key (the target base URL) without doing any I/O. @@ -365,19 +414,25 @@ def resilient_load_object_info( _warn=None, on_stale=None, ) -> dict[str, Any]: - """Fetch ``object_info`` with cache + refresh-retry + stale fallback. + """Fetch ``object_info`` cache-first, with refresh-retry + stale fallback. Resolution order: 1. ``input_path`` — explicit offline dump always wins; never cached. - 2. Live fetch via the engine. On success, write the per-host cache. - 3. On failure: attempt ``ensure_fresh_session`` and retry the fetch ONCE. + 2. Cache-first TTL gate: a per-host cache entry younger than the TTL + (default 10 minutes; ``COMFY_OBJECT_INFO_TTL`` seconds to override, + ``0`` to always fetch live) is returned with NO network call. + 3. Live fetch via the engine. On success, write the per-host cache. + 4. On failure: attempt ``ensure_fresh_session`` and retry the fetch ONCE. On success, write the cache. - 4. Still failing: fall back to the cached dump (if any) with a clear - stderr WARNING that it may be stale. - 5. No cache: re-raise the original ``LoadError`` (callers map it to the + 5. Still failing: fall back to the cached dump (if any, regardless of + age) with a clear stderr WARNING that it may be stale. + 6. No cache: re-raise the original ``LoadError`` (callers map it to the ``cql_no_graph`` envelope with their existing hint). + The cache key is the resolved target base URL, so local vs cloud — and + distinct base URLs — never share an entry. + ``_warn`` is an injectable sink for the stale-cache warning (defaults to stderr); tests pass their own to assert on it. """ @@ -388,8 +443,31 @@ def resilient_load_object_info( # already pinning a known-good file. return _load_from_file(input_path) + # Offline default catalog: COMFY_OBJECT_INFO_FILE is honored exactly like an + # explicit --input dump, so EVERY object_info consumer routed through this + # loader — workflow edits, `nodes show`/`find`, `validate`, fragments — + # resolves the node schema from a pre-warmed / baked file with no network + # fetch and no cloud credential. A host (e.g. a server-side agent) sets it + # once instead of threading --input through each command. + env_dump = os.environ.get("COMFY_OBJECT_INFO_FILE") + if env_dump: + return _load_from_file(env_dump) + host_key = _resolve_host_key(mode, host, port) + # Cache-first TTL is CLOUD-only. The cloud catalog is stable and its remote + # /object_info fetch is slow (multi-MB over the network), so a fresh cache hit + # is a real win. Local is the opposite: the localhost fetch is cheap, and a + # user installs custom nodes into their OWN server — serving a cached local + # catalog would hide a just-added node for the whole TTL. So local always + # fetches live. (The stale-cache *failure* fallback below still applies to + # both: a cache is still written on a successful local fetch so a later + # unreachable-server call can fall back to it.) + if mode == "cloud": + fresh = read_fresh_object_info_cache(host_key, object_info_cache_ttl()) + if fresh is not None: + return fresh + try: data = _load_from_target(mode=mode, host=host, port=port) write_object_info_cache(host_key, data) diff --git a/comfy_cli/credentials.py b/comfy_cli/credentials.py index ace6908e..4f05f969 100644 --- a/comfy_cli/credentials.py +++ b/comfy_cli/credentials.py @@ -41,6 +41,15 @@ # (Re-exported by ``comfy_cli.target`` for back-compat.) CLOUD_API_KEY_PROVIDER = "comfy-cloud-api-key" +# Env var carrying a pre-obtained Comfy Cloud Bearer token (a Firebase/Cloud +# JWT). Unlike ``COMFY_CLOUD_API_KEY`` (sent as ``X-API-Key``), this is sent as +# ``Authorization: Bearer``. It exists so a trusted caller that already holds +# the user's validated token — e.g. the cloud agent forwarding the request's +# ``X-Comfy-Token`` — can authenticate as that user without an interactive +# ``comfy cloud login`` session. It is NOT refreshed client-side; the server +# validates it at request time (and a 401 surfaces normally). +CLOUD_BEARER_ENV_VAR = "COMFY_CLOUD_AUTH_TOKEN" + Purpose = Literal["cloud", "partner"] # purpose → (env var, stored-key provider, strip ambient values?) @@ -117,6 +126,18 @@ def find_api_key(*, purpose: Purpose) -> Credential | None: return None +def cloud_bearer_env_token() -> str | None: + """Return a forwarded Comfy Cloud Bearer token from the environment, or None. + + Reads ``COMFY_CLOUD_AUTH_TOKEN`` (see :data:`CLOUD_BEARER_ENV_VAR`). Cloud-only + — it authenticates as the token's user via ``Authorization: Bearer``. + """ + import os + + tok = os.environ.get(CLOUD_BEARER_ENV_VAR) + return tok.strip() if tok and tok.strip() else None + + def resolve_cloud_credential( *, purpose: Purpose, @@ -135,8 +156,11 @@ def resolve_cloud_credential( ``base_url`` is given, a session minted for a *different* base URL is skipped (replay-guard: never send a token to a host the user didn't authenticate against). - 3. The purpose's env var (``COMFY_CLOUD_API_KEY`` / ``COMFY_API_KEY``). - 4. The stored ``comfy-cloud-api-key`` key (``comfy cloud set-key``). + 3. (cloud only) A forwarded Bearer token in ``COMFY_CLOUD_AUTH_TOKEN``, + sent as ``Authorization: Bearer`` — the trusted-caller path (see + :data:`CLOUD_BEARER_ENV_VAR`). + 4. The purpose's env var (``COMFY_CLOUD_API_KEY`` / ``COMFY_API_KEY``). + 5. The stored ``comfy-cloud-api-key`` key (``comfy cloud set-key``). """ explicit_key = explicit.strip() if isinstance(explicit, str) else "" if explicit_key: @@ -151,4 +175,9 @@ def resolve_cloud_credential( ): return Credential(kind="oauth", value=session.access_token, source="session") + if purpose == "cloud": + env_bearer = cloud_bearer_env_token() + if env_bearer: + return Credential(kind="oauth", value=env_bearer, source=f"env:{CLOUD_BEARER_ENV_VAR}") + return find_api_key(purpose=purpose) diff --git a/comfy_cli/discovery.py b/comfy_cli/discovery.py index f98e9493..dae397d9 100644 --- a/comfy_cli/discovery.py +++ b/comfy_cli/discovery.py @@ -53,6 +53,15 @@ "comfy workflow slots": "workflow", "comfy workflow set-slot": "workflow", "comfy workflow vary": "workflow", + # structured edit primitives + recipes (CRDT op-based authoring) + "comfy workflow add-node": "workflow", + "comfy workflow connect": "workflow", + "comfy workflow set-widget": "workflow", + "comfy workflow delete-node": "workflow", + "comfy workflow ls-nodes": "workflow", + "comfy workflow apply": "workflow", + "comfy workflow capture": "workflow", + "comfy workflow foreach": "workflow", # workflow cloud CRUD + fragment composition "comfy workflow list": "workflow", "comfy workflow get": "workflow", @@ -94,6 +103,8 @@ "comfy project init": "project", "comfy project status": "project", "comfy assets push": "assets", + "comfy assets library ls": "assets_library", + "comfy assets library ensure": "assets_library", # config "comfy set-default": "set_default", "comfy version": "version", diff --git a/comfy_cli/env_checker.py b/comfy_cli/env_checker.py index 2aea4cbd..485efc2e 100644 --- a/comfy_cli/env_checker.py +++ b/comfy_cli/env_checker.py @@ -5,7 +5,6 @@ import os import sys -import requests from rich.console import Console from comfy_cli.config_manager import ConfigManager @@ -42,6 +41,10 @@ def check_comfy_server_running(port=8188, host="localhost", timeout: float = 5.0 Returns: bool: True if the Comfy server is running, False otherwise. """ + # Imported lazily: requests costs ~30ms to import and is only needed when + # actually probing the server, not for every CLI invocation. + import requests + try: response = requests.get(f"http://{host}:{port}/history", timeout=timeout) return response.status_code == 200 diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index 4cba21d3..6b31f014 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -246,6 +246,11 @@ class ErrorCode: "`comfy models show` needs the cloud asset catalog; local servers don't have one.", "for local filename listing use `comfy models list-folder `", ), + ErrorCode( + "cloud_only_command", + "The command requires Comfy Cloud (e.g. `comfy assets library`); there is no local equivalent.", + "sign in with `comfy cloud login` and re-run with `--where cloud`", + ), ErrorCode( "template_fetch_failed", "Fetching the per-template workflow JSON from `Comfy-Org/workflow_templates` failed.", @@ -407,6 +412,18 @@ class ErrorCode: "A slot override failed validation (bad shape, unknown address, etc.).", "see `details` — addresses follow `.`", ), + ErrorCode( + "workflow_edit_invalid", + "A structured edit (add-node/connect/set-widget/delete-node) failed: " + "unknown class_type, missing node, bad slot/widget name, or malformed address.", + "run `comfy workflow slots ` for widget addresses or `comfy nodes types` for class_types", + ), + ErrorCode( + "normalized_value", + "Warning (not fatal): a set-widget value wasn't an exact COMBO option, so " + "the nearest matching option was used. Surfaced in the op's `warnings`.", + "see the warning's `from`/`to`; pass an exact option to avoid the fuzzy match", + ), # --- workflow fragments / compose --------------------------------------- ErrorCode( "fragment_invalid", diff --git a/comfy_cli/file_utils.py b/comfy_cli/file_utils.py index 3a84027c..148fee99 100644 --- a/comfy_cli/file_utils.py +++ b/comfy_cli/file_utils.py @@ -7,7 +7,6 @@ from http import HTTPStatus import httpx -import requests from pathspec import PathSpec from comfy_cli import constants, ui @@ -57,6 +56,10 @@ def check_unauthorized(url: str, headers: dict | None = None) -> bool: Returns: bool: True if the response status code is 401, False otherwise. """ + # Imported lazily: requests costs ~30ms to import and this module is on + # the import path of every CLI invocation. + import requests + try: response = requests.get(url, headers=headers, allow_redirects=True, stream=True) return response.status_code == 401 @@ -462,6 +465,8 @@ def should_ignore(rel_path: str) -> bool: def upload_file_to_signed_url(signed_url: str, file_path: str): + import requests # deferred; see check_unauthorized + with open(file_path, "rb") as f: headers = {"Content-Type": "application/zip"} response = requests.put(signed_url, data=f, headers=headers) diff --git a/comfy_cli/registry/api.py b/comfy_cli/registry/api.py index 928b3606..32874409 100644 --- a/comfy_cli/registry/api.py +++ b/comfy_cli/registry/api.py @@ -2,8 +2,6 @@ import logging import os -import requests - # Reduced global imports from comfy_cli.registry from comfy_cli.registry.types import ( License, @@ -79,6 +77,10 @@ def publish_node_version( headers = {"Content-Type": "application/json"} body = request_body + # Imported lazily: requests costs ~30ms to import and this module is + # on the import path of every CLI invocation. + import requests + response = requests.post(url, headers=headers, data=json.dumps(body)) if response.status_code == 201: @@ -97,6 +99,8 @@ def list_all_nodes(self): Returns: list: A list of Node instances. """ + import requests # deferred; see publish_node_version + url = f"{self.base_url}/nodes" response = requests.get(url) if response.status_code == 200: @@ -116,6 +120,8 @@ def install_node(self, node_id, version=None): Returns: NodeVersion: Node version data or error message. """ + import requests # deferred; see publish_node_version + if version is None: url = f"{self.base_url}/nodes/{node_id}/install" else: diff --git a/comfy_cli/schemas/assets_library.json b/comfy_cli/schemas/assets_library.json new file mode 100644 index 00000000..4c3aa529 --- /dev/null +++ b/comfy_cli/schemas/assets_library.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://comfy.org/schemas/assets_library.json", + "title": "comfy assets library --json data payload", + "description": "Union schema for `assets library ls` and `assets library ensure`.", + "type": "object", + "additionalProperties": true, + "properties": { + "count": { "type": "integer" }, + "assets": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { "type": ["string", "null"] }, + "name": { "type": ["string", "null"] }, + "hash": { "type": ["string", "null"] }, + "mime_type": { "type": ["string", "null"] }, + "size": { "type": ["integer", "null"] }, + "tags": { "type": ["array", "null"] }, + "preview_url": { "type": ["string", "null"] }, + "job_id": { "type": ["string", "null"] }, + "created_at": { "type": ["string", "null"] } + } + } + }, + "id": { "type": ["string", "null"] }, + "hash": { "type": ["string", "null"] }, + "created_new": { "type": ["boolean", "null"] } + } +} diff --git a/comfy_cli/skills/__init__.py b/comfy_cli/skills/__init__.py index 78f22c42..f2b022c5 100644 --- a/comfy_cli/skills/__init__.py +++ b/comfy_cli/skills/__init__.py @@ -18,8 +18,6 @@ contract, routing, discovery, execution, and all domain patterns: image, video, audio, cloud, edit, condition, pipeline) -- ``comfy-fragments``— typed reusable workflow fragments + YAML blueprint - composition (build large pipelines from small pieces) - ``comfy-debug`` — debugging skill for when workflows fail or jobs hang - ``comfy-relay`` — what to put in chat while driving the CLI """ @@ -49,7 +47,6 @@ # name). The bundled skills must satisfy their own convention. BUNDLED_SKILLS: tuple[tuple[str, str], ...] = ( ("comfy", "comfy"), - ("comfy-fragments", "comfy-fragments"), ("comfy-debug", "comfy-debug"), ("comfy-relay", "comfy-relay"), ("comfy-director", "comfy-director"), @@ -600,7 +597,6 @@ def _write_claude_skill(path: Path, content: str) -> None: def _cursor_description_for(skill_name: str) -> str: return { "comfy": "comfy CLI for ComfyUI workflows, models, node-graph queries, image/video/audio generation, cloud, and pipeline orchestration.", - "comfy-fragments": "Typed reusable workflow fragments + YAML blueprint composition: build large pipelines from small tested pieces via comfy CLI.", "comfy-debug": "Debugging skill for the comfy CLI: failed workflows, hung jobs, error envelopes.", "comfy-relay": "What to put in chat while driving the comfy CLI: show artifacts, surface results, truncation rules.", }.get(skill_name, f"comfy CLI skill: {skill_name}") diff --git a/comfy_cli/skills/comfy-director/SKILL.md b/comfy_cli/skills/comfy-director/SKILL.md index be627d65..b758cbf8 100644 --- a/comfy_cli/skills/comfy-director/SKILL.md +++ b/comfy_cli/skills/comfy-director/SKILL.md @@ -9,8 +9,8 @@ Story first, pictures second. Multi-shot AI films fail in the edit, not the render: beats that don't cause each other read as a montage wearing a story's clothes, no matter how good each clip looks. -**REQUIRED BACKGROUND:** the `comfy` skill (CLI mechanics, workflow hierarchy) -and `comfy-fragments` (composing multi-stage graphs). On any failed job, +**REQUIRED BACKGROUND:** the `comfy` skill (CLI mechanics + the recipe path — +`apply`/`capture`/`foreach` — for reusable multi-stage graphs). On any failed job, `comfy-debug`. ## Order of operations diff --git a/comfy_cli/skills/comfy-fragments/SKILL.md b/comfy_cli/skills/comfy-fragments/SKILL.md deleted file mode 100644 index afeece48..00000000 --- a/comfy_cli/skills/comfy-fragments/SKILL.md +++ /dev/null @@ -1,657 +0,0 @@ ---- -name: comfy-fragments -description: Compose large Comfy workflows from small reusable fragment pieces — each a self-contained workflow JSON with declared inputs, outputs, and parameters. Use when iterating on complex workflows, when patterns repeat, or when a workflow JSON grows past ~200 lines. ---- - -This skill is the **composition layer** on top of `comfy`. Where -the core skill teaches you to build large single-graph workflows that -ComfyUI can parallelize, this skill teaches you how to **assemble those -large graphs from smaller reusable pieces** — like functions in code. - -It assumes `comfy` (core CLI) is loaded. Pair it with the domain skills -(image, video, audio, editing domains in the core `comfy` skill). Fragments -are how you avoid rebuilding the same 8-node IPAdapter block five times. - ---- - -## When to use fragments - -Default to fragments + blueprints for workflows that may be extended. A small -workflow often becomes tomorrow's multi-shot, multi-seed, or multi-provider -pipeline; starting with a named fragment and a blueprint keeps that next step -cheap. - -Use fragments when **any of**: - -- A simple template or node chain might later need another shot, seed sweep, - provider swap, refiner, ControlNet, LoRA, or final save variant. -- A sub-region of a workflow (a ControlNet stack, an IPAdapter block, a - refiner pass, a save+thumbnail group) is reused across two or more - workflows — extract it once, instantiate twice. -- A workflow JSON is creeping past ~200 lines and you're losing the - mental model of which node ID does what. -- You're about to copy-paste a pattern from another workflow (text cards, - inpaint passes, upscale finishers, LLM-driven prompt direction). -- You're iterating on a piece and small edits keep cascading through the - whole JSON. -- Multiple model providers are chained (Ideogram + Reve + Flux + Magnific) - and the chain wiring is the hard part. - -Avoid fragments only when: - -- A workflow is a truly throwaway one-shot and the user explicitly values speed - over future extension. -- You only need to tweak values inside an existing workflow — use - `comfy workflow slots / set-slot / vary` instead. -- The whole graph is under ~10-15 nodes, will not be varied or reused, and has - no natural named sub-region. - ---- - -## The mental model - -A fragment is a **function with named inputs, outputs, and parameters**: - -``` -inpaint_region( - image: IMAGE, # input - mask: MASK, # input - prompt: str, # param - guidance: float = 30.0, # param with default - seed: int = 2000, # param with default -) -> IMAGE -``` - -Inside the fragment, the implementation can be 1 node or 30 — the caller -doesn't care. They pass arguments and get a typed result they can pipe -into the next step. - -A **blueprint** chains fragments end-to-end (typically YAML), and a small -**composer** tool emits the final monolithic workflow JSON ready for -`comfy run`. - ---- - -## 1. The fragment file format - -A fragment is one `.json` file in a `fragments/` directory. It has a -`_fragment` metadata header that declares the fragment's typed interface, -followed by the interior ComfyUI nodes (API-format, just like a workflow). - -```json -{ - "_fragment": { - "name": "image_blend", - "version": "1", - "description": "Blend two images with a configurable mode and factor.", - "terminal": false, - "inputs": { - "image1": {"type": "IMAGE", "binds": "10.image1"}, - "image2": {"type": "IMAGE", "binds": "10.image2"} - }, - "outputs": { - "image": {"type": "IMAGE", "from": "10", "port": 0} - }, - "params": { - "blend_factor": {"type": "FLOAT", "binds": "10.blend_factor", "default": 0.5}, - "blend_mode": {"type": "COMBO", "binds": "10.blend_mode", "default": "normal"} - } - }, - - "10": { - "class_type": "ImageBlend", - "_meta": {"title": "blend two passes"}, - "inputs": { - "image1": "PLACEHOLDER", - "image2": "PLACEHOLDER", - "blend_factor": 0.5, - "blend_mode": "normal" - } - } -} -``` - -### Metadata fields - -| field | required | meaning | -|---|---|---| -| `name` | yes | Stable identifier. Blueprints reference fragments by this name. | -| `version` | no (default `"1"`) | String version, semver-ish. Bump when the interface changes. | -| `description` | recommended | One-line human description. | -| `terminal` | optional (default `false`) | `true` if the fragment contains its own `SaveImage`/`SaveVideo`. Stops the composer from appending another save. | -| `inputs` | no (default `{}`) | Each input has a `type` — any UPPER_SNAKE_CASE socket type (`IMAGE`, `MASK`, `AUDIO`, `VIDEO`, `STRING`, `MODEL`, `CONDITIONING`, `LATENT`, `VAE`, `CLIP`, custom types…) — and a `binds: "."` pointing at the actual node-field this input feeds. Path-loadable types (`IMAGE`/`MASK`/`AUDIO`/`VIDEO`) accept file paths — the composer injects a loader node. All other socket types must be fed by a cross-step ref (`$alias.output`), never a path. | -| `outputs` | no (default `{}`) | Each output has a `type` and `from: ""` plus optional `port` (default `0`). | -| `params` | optional | Settable values (text, seed, strength, model name, etc.). Each has `type` ∈ {`STRING`, `INT`, `FLOAT`, `BOOLEAN`, `COMBO`} (the node-schema vocabulary, exactly as `nodes show` prints it), a `binds`, and optionally a `default`. | - -### Conventions for interior nodes - -- Use simple integer IDs (`"10"`, `"11"`, …). The composer remaps them - globally so collisions across fragments don't matter. -- Use the literal string `"PLACEHOLDER"` for any input that will be filled - by the composer at instantiation. (Defaults from `params` overwrite it.) -- Internal edges (`["10", 0]`) are preserved and renumbered automatically. - ---- - -## 2. The blueprint DSL - -A blueprint is a YAML file describing one composed workflow. The composer reads -the blueprint, instantiates each listed fragment, wires inputs/params, and -writes one API-format workflow JSON. - -```yaml -output_prefix: outputs/my_pipeline - -pipeline: - - fragment: text_card # name (looked up in ./fragments/) - alias: headline # unique handle for downstream refs - inputs: - destination_image: $asset.base.png # project asset → resolved via the push lock - source_mask: $asset.mask_top.png # type MASK → LoadImage + ImageToMask - params: - text_prompt: "BREAKING NEWS" - comp_x: 140 - comp_y: 30 - - - fragment: text_card - alias: subhead - inputs: - destination_image: $headline.image # ← previous step's output - source_mask: $asset.mask_sub.png - params: - text_prompt: "...details..." -``` - -### The `$`-reference algebra - -Four reference kinds, each with ONE resolution source, all resolved at -compose time. **Whole-value only**: a `$`-ref must be the ENTIRE string — -`"a $asset.x b"` is plain text, there is no interpolation/templating. - -| Reference | Resolves from | Where it works | -|---|---|---| -| `$alias.output` | a prior step's named output → `[node_id, port]` wire | inputs | -| `$item.field` | the current `foreach` item (see foreach below) | inputs + params | -| `$asset.` | the project push lock → server-side filename | inputs + params + item field values | -| `$var.` | the project comfy.yaml `vars:` block | inputs + params + item field values | - -- **`$asset.`** — a file under the governing project's - `assets/` dir (project/1 — see the core `comfy` skill), resolved through - the push lock (`comfy assets push`) to the server-side filename. On an - input it is then materialized like a path (loader injected); on a param - the resolved filename lands as the widget value. Compose fails closed - with `asset_not_pushed` / `asset_stale` when the file was never pushed or - changed since — the hint says exactly what to run. -- **`$var.`** — a project constant from a top-level `vars:` mapping - in `comfy.yaml` (scalars: str/int/float/bool). Resolves to the RAW scalar, - so an `INT` param fed `$var.steps` stays an int. Undefined name → - `var_not_defined` (add it under `vars:`). Referenced vars are snapshotted - into the compiled JSON's `_meta.vars` for provenance. Use it for the - style/prompt constants every scene shares: - - ```yaml - # comfy.yaml - vars: - house_style: ", golden hour" - # blueprint params — every scene appends the same style, edited in ONE place: - # params: {prompt: $var.house_style} - ``` - -- In a `foreach`, an item FIELD value may itself be a `$asset.`/`$var.` ref: - `$item.first` substitutes the field first, then the resulting whole-value - string resolves per item. - -Besides refs, an `inputs:` entry also accepts: - -- **A path string** — for `IMAGE`, `MASK`, `AUDIO`, `VIDEO` inputs the composer - injects the appropriate loader (`LoadImage` / `LoadAudio` / `LoadVideo`, - plus `ImageToMask` for `MASK`). The value must be a filename the *server* - can see in its input dir — in a project, prefer `$asset` so push and - resolution are handled for you. For `STRING` inputs the value passes through - as a literal. -- **A literal** — for `STRING` inputs only. Non-string literals for non-STRING - types are rejected. - -### Cross-step refs work across any output type - -`$alias.image`, `$alias.conditioning`, `$alias.mask`, `$alias.audio`, -`$alias.video` — whatever the fragment declared as outputs. The composer -errors clearly if the alias or output name doesn't exist. - -### Final save behavior - -If the **last** step's fragment has `terminal: true`, the composer leaves the -workflow alone (your fragment handles saving). Otherwise it appends a -`SaveImage` or `SaveVideo` (auto-detected from the final step's first -`IMAGE`/`VIDEO` output) using `output_prefix` as the filename prefix. - ---- - -## 3. The command surface - -Fragment composition is built into the `comfy` CLI: - -```bash -# Compose a blueprint into a single workflow JSON -comfy workflow compose blueprints/my_pipeline.yaml # → blueprints/my_pipeline.compiled.json - -# Specify a custom fragments directory (default: ./fragments) or output path -comfy workflow compose blueprints/my_pipeline.yaml --lib ./my_fragments -o pipeline.json - -# Project a workflow INTO a fragment — the inverse of compose -comfy workflow decompose ref.json --name restyle # → ./fragments/restyle.json - -# List fragments in a library -comfy --json workflow fragment ls [--lib DIR] - -# Show a fragment's metadata, ports, and interior node count -comfy --json workflow fragment show - -# Validate a fragment file is well-formed -comfy --json workflow fragment validate - -# Then submit the composed workflow -comfy run --workflow blueprints/my_pipeline.compiled.json --wait -``` - -`--lib` defaults to `./fragments` relative to cwd. Default output is -`.compiled.json`, next to the blueprint. - -### `decompose` — turn an existing workflow into source - -`compose` builds fragments → a workflow; `decompose` is the **inverse**: -it projects a workflow JSON (a fetched template, or any API/frontend graph) -back into a fragment so you edit *source*, never the compiled artifact. From -the graph alone (nothing hardcoded) it: - -- **strips each loader** (`LoadImage`/`LoadAudio`/`LoadVideo`) and exposes the - consumer input it fed as a typed **input** — so compose can re-inject a loader - for a path, or wire a `$alias.output` ref in its place (keeping the original - loader would double-load); -- **strips the terminal save** and exposes its producer as a typed **output**, - leaving a composable, non-terminal building block; -- surfaces every remaining **scalar widget** as a **named param** defaulting to - its current value — the buried prompt that needed `jq '…widgets_values[0]'` - becomes `params: {…_prompt: "…"}` you set in the blueprint. - -```bash -comfy workflow decompose workflows/restyle.json --name restyle # API format: no server needed -comfy workflow decompose template.json --name lulz --input object_info.json # frontend/subgraph: needs schema -``` - -Frontend-format (UI) and subgraph templates are flattened to API format first, -which needs `object_info` — from a running/cloud server, or an offline -`--input object_info.json` dump. Already-API workflows need neither. The result -always round-trips through `fragment validate`. - -**Use it — don't hand-edit.** When you fetch a template or have a workflow whose -values you need to change, `decompose` it and edit named params in a blueprint. -**Never** `jq`/`sed`/edit a workflow's `widgets_values`/`inputs` or hunt nodes by -id (`select(.id==128)`) — that's the anti-pattern decompose exists to kill. The -only exception is a throwaway run you won't reuse: `slots`/`set-slot`/`vary` then -`run`. - -### Self-documenting by construction - -Both sides of the compile carry their own provenance, so a future agent (or you, -later) can edit safely without re-deriving intent: - -- **A decomposed fragment** records `_fragment.source` (where it came from) and a - `_fragment.description` that says how to edit it ("…edit params in a blueprint - and rebuild with `comfy workflow compose` — do not hand-edit"). `comfy workflow - fragment show ` prints the description plus every param's `binds` + - default — so each value documents which node/field it controls. -- **A compiled workflow** embeds `_meta` (`schema: compose/1`) naming the - `blueprint` that produced it and, for `foreach`, an `item_map` of which nodes - belong to which item. `comfy run` strips `_meta` before submit. So the artifact - always points back at its source; to change it, edit that blueprint and - recompile — never the compiled JSON. - -Compose embeds `_meta` (`schema: compose/1`) provenance in the compiled -JSON — the blueprint path and, for `foreach`, which nodes belong to which -item (also `item_map` in the envelope). `comfy run` strips it before -submit (old servers unaffected) and uses the map to report -`outputs_by_item` and to name downloaded files `_.` — -never identify fan-out outputs by array order. - -With `chunk: N` in a `foreach` blueprint, compose splits items into -N-item batches and writes one numbered file per batch (`.000.json`, -`.001.json`, …). The envelope then reports `out: null` (there is no -single runnable file) plus `graphs` (count) and `written[]` (all paths) — -script against `data.written`, not `data.out`, and note any stale -unnumbered `.compiled.json` from a previous non-chunked compose is -deleted automatically. - -All commands emit JSON envelopes under `comfy --json`. The composer -exits non-zero on validation errors with structured error codes -(`fragment_invalid`, `blueprint_invalid`, `blueprint_not_found`, -`fragment_lib_not_found`) — caught at compose time, not after cloud spend. -`fragment_lib_not_found` is raised by `workflow fragment ls` when the -library directory (explicit `--lib`, or the default `./fragments`) -doesn't exist yet — create it when you author your first fragment. A -missing fragment during `compose` surfaces as `fragment_invalid` instead. - ---- - -## 4. End-to-end example - -Project layout (project/1 — `comfy project init`): - -``` -my-project/ - comfy.yaml # schema: project/1 + defaults.where - fragments/ - text_encode.json - sampler.json - save_still.json - blueprints/ - portrait.yaml - assets/ - seed_photo.png # referenced as $asset.seed_photo.png -``` - -Push, compose, submit: - -```bash -cd my-project -comfy --json assets push # upload changed assets, update the lock -comfy workflow compose blueprints/portrait.yaml -comfy run --workflow blueprints/portrait.compiled.json --wait -``` - -That's the full agent loop. The fragment library is reusable across blueprints; -blueprints are small and obvious; the composed workflow is a normal API JSON -that submits like any other. - ---- - -## 5. Real-world blueprint shape - -A typical production pipeline for a single piece: - -```yaml -pipeline: - - fragment: subject_generator # base photoreal scene - alias: subject - ... - - - fragment: text_card # branded text card 1 - alias: card_a - inputs: {destination_image: $subject.image, source_mask: ...} - ... - - - fragment: text_card # branded text card 2 - alias: card_b - inputs: {destination_image: $card_a.image, source_mask: ...} - ... - - - fragment: inpaint_region # surgical fix to a problem area - alias: fix_face - inputs: {image: $card_b.image, mask: ...} - ... - - - fragment: vision_verify # in-graph QA gate (optional) - alias: qa - inputs: {image: $fix_face.image} - - - fragment: magnific_finish # 4x upscale to print - alias: final - inputs: {image: $fix_face.image} -``` - -A 30-40 line blueprint expands to a 200-500 node workflow. Compose-time -validation catches the typical mistakes (missing inputs, bad alias -references, type mismatches) before you spend cloud compute on a -broken job. - ---- - -## 6. How to create a fragment - -**The typical flow** — discover the node, wrap it in a fragment, use it -from a blueprint: - -1. Discover the node: `comfy --json nodes show ` — check its - inputs, outputs, and valid parameter values -2. Write `fragments/.json` with: - - `_fragment` header (name, inputs, outputs, params with binds) - - Interior nodes (1-15) in standard API format - - `"PLACEHOLDER"` for inputs that the blueprint will supply - - Reasonable defaults for optional params -3. Validate: `comfy --json workflow fragment validate ` -4. Use from a blueprint and compose to verify it works end-to-end - -**Refactoring path** — if you already have a working raw JSON workflow -and want to extract reusable pieces: - -1. Identify the sub-region you'll reuse (5-15 nodes that form a logical unit) -2. Copy those nodes into `fragments/.json`, add a `_fragment` header -3. Replace concrete values with `"PLACEHOLDER"` -4. Validate + compose + test - -Always test a new fragment by composing a blueprint and submitting the -result before relying on it. - ---- - -## 7. Picking input types - -| Input type | Use for | The composer does | -|---|---|---| -| `IMAGE` | Photos, generated images, reference frames | Injects `LoadImage` when the blueprint value is a path; passes through when the value is `$alias.image` | -| `MASK` | Binary/alpha masks | Injects `LoadImage` + `ImageToMask` (channel: red) for paths | -| `AUDIO` | WAV/MP3/FLAC | Injects `LoadAudio` for paths | -| `VIDEO` | MP4/WebM | Injects `LoadVideo` for paths | -| `STRING` | Prompts, model names, captions, any literal | Pass-through. No loader injection. | - -Use the type that matches what the interior node actually consumes. -`CONDITIONING` (and `MODEL`, `CLIP`, `VAE`, `LATENT`) are first-class input -types — declare `type: CONDITIONING` and wire it with a cross-step ref like -`conditioning: $encode.conditioning`. Only path-loadable types (`IMAGE`, -`MASK`, `AUDIO`, `VIDEO`) accept file paths; all other socket types must -come from a prior step via `$alias.output_name`. - ---- - -## 8. Starter pattern library - -Build these once and reuse forever. - -### `subject_generator` — LLM-directed base generation - -`ClaudeNode` (positive) + `ClaudeNode` (negative) + `Flux Dev` + LoRA -stack → IMAGE. Sweep on the Claude seed for genuine interpretation -variance, not just noise variance. - -### `text_card` — typography card via Ideogram + composite - -`IdeogramV3` → `ImageScale` → `ImageCompositeMasked`. Use this whenever -brand text or specific phrases must be legible — Ideogram is reliable -at text where Flux is not. - -### `inpaint_region` — context-aware content replacement - -`FluxProFillNode` with detailed prompt. Use for replacing a masked -region with new content that integrates with scene lighting. **Note: -this is REPLACE-ONLY — see gotchas section.** - -### `magnific_finish` — production upscale - -`MagnificImageUpscalerCreativeNode` (Sparkle engine). Defaults to -preserve mode (`creativity=0`, `resemblance=10`) for final delivery. -Bump `creativity` to 2-3 for mild detail enhancement. - -### `vision_verify` — in-graph QA gate - -`ClaudeNode` with `images` input + a checklist system_prompt. Returns -a structured PASS/FAIL critique. (Note: capturing the TEXT output of -ClaudeNode for retrieval requires a `SaveString` node — its in-graph -output is consumable by downstream nodes but not always exposed by the -job-status API.) - ---- - -## 9. Gotchas baked into fragment defaults - -Each of these tripped someone up during real production. Encoding them -into the fragment defaults means they can't be forgotten: - -### `ImageCompositeMasked` — mask MUST match SOURCE size - -The mask input gets bilinear-upscaled to the source image's dimensions. -If you pass a destination-sized mask (e.g., 1536×1024) with a small -source (e.g., 330×180), the small white-rectangle inside the big mask -shrinks to almost-black and the composite renders almost nothing. - -The `text_card` fragment requires you to supply a mask **already sized -to your `scale_width × scale_height` params**. The composer documents -this expectation in error messages. - -### `COMFY_DYNAMICCOMBO_V3` inputs use dotted keys - -Nodes like `ClaudeNode`, `ReveImageCreateNode` declare a `model` input -with type `COMFY_DYNAMICCOMBO_V3` — when you select a model, that model -brings its own required sub-params (`max_tokens`, `temperature`, etc.). -In API workflow JSON these are flat dotted keys, NOT nested: - -```json -// ✅ correct -"inputs": { - "model": "Opus 4.6", - "model.max_tokens": 800, - "model.temperature": 0.95 -} - -// ❌ wrong — fails validation -"inputs": { - "model": ["Opus 4.6", {"max_tokens": 800, "temperature": 0.95}] -} -``` - -### `SAM3Grounding` outputs MASK directly - -Looks like a "find boxes" node but actually returns a `MASK`. Wire it -straight into mask-consuming nodes. `SAM3Segmentation` is only needed -when you've built boxes yourself via `SAM3CreateBox` + -`SAM3CombineBoxes`. - -### `FluxProFillNode` is REPLACE-ONLY - -It has no denoise / strength parameter. Whatever pixels are inside the -mask get fully regenerated from the prompt. **Never use it to "refine" -existing composited content** — it will overwrite that content. - -For true refinement (preserve most of the masked region, only smooth -edges and lighting), use **KSampler + VAEEncode + SetLatentNoiseMask** -with `denoise=0.15–0.25`, or run `MagnificImageUpscalerCreativeNode` -with `creativity=2-3` at upscale time. - -### `MagnificImageRelightNode` `style="smooth"` drains color - -Counter-intuitively, the "smooth" relight style produces a sepia / -monochromatic image. For warming light without color loss, use -`style="brighter"` or `style="clean"`. Always test on a small image -before committing it to a pipeline. - -### `MagnificImageUpscalerCreativeNode` parameter ranges - -| Param | Range | Note | -|---|---|---| -| `creativity` | 0–10 | 0 = preserve, 4+ = noticeable reinterpretation | -| `resemblance` | -10–10 | NOT 0–100. 10 = max preservation. | -| `hdr` | 0–10 | small values are fine for most scenes | - -### Text rendering: use Ideogram, not Flux - -Flux and SDXL/SD3 cannot reliably render specific text. If your piece -has brand wordmarks, specific phrases, or proper names that MUST be -spelled correctly, the right tool is: - -1. **Ideogram V3** in-graph (`IdeogramV3` node) — Ideogram is the - text-master model in this stack -2. PIL composite externally (post-processing) — guaranteed but layered -3. **Don't** trust Flux Pro Fill to render text inside an inpaint - — it will produce garbled glyphs every time - -The `text_card` fragment uses Ideogram for this reason. Don't substitute -Flux into it. - ---- - -## 10. When the pattern breaks down - -Honest limits: - -- **One-shot exploration is faster without the indirection** — if - you're just trying things, write raw workflow JSON or use - `comfy workflow vary`. -- **External (non-Comfy) steps don't compose as cleanly** — Python - post-processing like PIL composites or external file conversions - need a separate step type in the composer. Keep them outside the - Comfy graph. -- **Comfy version drift** — if ComfyUI's native subgraph support - (v0.3+) stabilizes for API workflow JSON export, eventually migrate - to native subgraphs. The JSON-composition approach is portable but - reinvents what Comfy itself wants to provide. -- **Debugging composed workflows** — when something fails, you're - looking at a generated workflow JSON, not your hand-written one. - Keep the composer's intermediate output (`blueprint.compiled.json`) - for inspection. Log the blueprint + fragment versions per run. - ---- - -## 11. What NOT to do - -- **Don't put model loading inside every fragment.** Load `CheckpointLoaderSimple` - once in the blueprint's first step and pass `model`/`clip`/`vae` outputs by - cross-step ref. Fragments are about reusable sub-regions; the shared model - state belongs at the top. -- **Don't author huge fragments.** If a fragment has more than ~15 interior - nodes, it's probably two fragments. Same for params — 15+ params means - you should split. -- **Don't hide critical model choices in defaults.** If swapping - `flux1-dev` for `sd3.5_large` would silently change the output - character, expose it as a required param. -- **Don't compose at runtime via shell scripts.** The Python composer - catches errors at compose time. Shell glue catches them after cloud spend. -- **Don't reach into another fragment's internals.** If you need access - to a node deep inside a fragment, that node should be promoted to - an output of the fragment's public interface, or the fragment should - be split. -- **Don't skip validation** before submitting a composed workflow that - uses a new fragment. Run `comfy workflow fragment validate ` - first — it catches missing `binds` targets, malformed metadata, and - orphan interior nodes locally. -- **Don't reuse aliases across steps.** Aliases must be unique within a - blueprint; the composer rejects duplicates. - ---- - -## 12. Failure modes and what they mean - -| code | what's wrong | what to fix | -|---|---|---| -| `fragment_invalid` | The fragment file itself is malformed (bad `_fragment` header, missing fields, dangling `binds`) | Read the error message; fix the fragment JSON | -| `fragment_lib_not_found` | The library directory passed to `fragment ls` (explicit `--lib`, or the default `./fragments`) doesn't exist | Create `./fragments/` and author a fragment, or pass a valid `--lib ` | -| `blueprint_not_found` | The blueprint YAML path doesn't exist | Check the path | -| `blueprint_invalid_yaml` | The blueprint file isn't valid YAML | Run it through `yamllint` | -| `blueprint_invalid` | The blueprint semantically fails (missing fragment, missing input, unknown input key, duplicate alias) | Read the error — it names the offending step alias | -| `asset_not_pushed` | A `$asset.` ref has no entry in `.comfy/assets.lock.json` (or the file vanished from `assets/`) | `comfy assets push`, then re-compose | -| `asset_stale` | The file under `assets/` changed since its last push (sha256 mismatch with the lock) | `comfy assets push`, then re-compose | -| `var_not_defined` | A `$var.` ref names nothing under `vars:` in the project's comfy.yaml | Add the name under `vars:`, then re-compose | - ---- - -## Summary - -| Without fragments | With fragments | -|---|---| -| 1500-line workflow JSON | 40-line blueprint + N small fragments | -| Edits hunt through node IDs | Edits change one blueprint param | -| Errors caught at cloud submission | Errors caught at compose time | -| Patterns get copy-pasted between projects | Patterns become reusable units | -| Gotchas re-discovered each project | Gotchas baked into fragment defaults | - -Fragments treat workflows the way good code treats logic: small named -units, typed interfaces, defaults that encode wisdom, and a composer -that wires them up. diff --git a/comfy_cli/skills/comfy-relay/SKILL.md b/comfy_cli/skills/comfy-relay/SKILL.md index 1469013c..26b7d21e 100644 --- a/comfy_cli/skills/comfy-relay/SKILL.md +++ b/comfy_cli/skills/comfy-relay/SKILL.md @@ -8,8 +8,8 @@ and passing tests. Creative work is different: the user steers by **seeing the work**. Your whole job in chat is to make them see it and react. **The image is the message — text about the image is not.** -This skill is the presentation/interaction layer. It is paired with `comfy` -(the surface) and `comfy-fragments` (the compile model). +This skill is the presentation/interaction layer. It is paired with the `comfy` +skill (the CLI surface + recipe authoring: `apply`/`capture`/`foreach`). --- @@ -32,15 +32,22 @@ You can't play a clip in chat. Surface it visually instead — **one command doe it:** ```bash -comfy --json preview clip.mp4 # → clip.preview.png (a contact sheet) + duration/fps/has_audio +comfy --json preview clip.mp4 # → clip.preview.png (a contact sheet) # then Read clip.preview.png ``` `comfy preview` handles all three modalities: **video → contact sheet** (a grid across the whole timeline, best for judging pacing/arc), **image → thumbnail**, -**audio → waveform** (so you can *see* the dynamics you can't hear). It also -reports the facts frames can't show — **duration, fps, and whether there's -audio** — in the envelope. +**audio → waveform** (so you can *see* the dynamics you can't hear). The +contact-sheet/thumbnail PNG **always renders** — the CLI ships a bundled ffmpeg, +so no system install is needed. + +It also *tries* to report the facts frames can't show — **duration, fps, has_audio** — +but those come from `ffprobe`, which is **not** bundled: without a system `ffprobe` +on PATH those envelope fields are `null` (and the contact sheet spans only the +first second, since it can't read the duration). Install system ffmpeg +(`brew install ffmpeg` / `apt install ffmpeg`) when you need accurate timeline +sampling or the metadata; the PNG itself works without it. Prefer it over hand-rolling ffmpeg. (If you need a custom grid: `--grid 6x4`; custom width: `--width 720`.) For a key-moments read instead of a grid, extract @@ -79,7 +86,7 @@ lot of wall-clock you should never spend blocked. Same for seed/variant sweeps (`workflow vary`). 3. **Dispatch a subagent per long, self-contained job.** When you have subagents - available, a whole shot/clip/pipeline — survey → compose → run → wait → + available, a whole shot/clip/pipeline — survey → apply → run → wait → assemble — is minutes of work and many steps. Hand each to a **background subagent**: it drives the CLI end-to-end and reports back the artifact path + a short build log, while the main session stays responsive and other @@ -87,7 +94,7 @@ lot of wall-clock you should never spend blocked. piece** — a shot, the music, the title card — then the orchestrator assembles the returned clips. - Brief it like a creative director: the concept/shot, the technique, what to - **reuse** (existing fragments/clips), and to report the path + any friction. + **reuse** (existing recipes/clips), and to report the path + any friction. - It returns the *conclusion* (the clip + log), not its 100-step transcript — so the orchestrator's context stays clean and it can run many in parallel. - Keep one piece = one subagent so a re-roll re-runs only that piece. @@ -110,17 +117,17 @@ questions. ## Rule 6 — Lead with the visual, then show the source -After the preview, show the **source that made it** — the blueprint YAML, the -prompt, the params — so the user can tweak the *inputs*, not hunt through -compiled JSON. The compiled workflow is a build artifact; keep it out of chat. +After the preview, show the **source that made it** — the recipe, the prompt, the +params — so the user can tweak the *inputs*, not hunt through the graph JSON. The +workflow file is a build artifact; keep it out of chat. | Moment | Lead with | Then show | |---|---|---| -| Generated an image | the image (`Read` it) | the prompt / blueprint that made it | -| Rendered a clip | a contact sheet + duration/audio | the blueprint | +| Generated an image | the image (`Read` it) | the prompt / recipe that made it | +| Rendered a clip | a contact sheet + duration/audio | the recipe | | A creative fork | the candidate frames | your recommendation | | Iterating one value | the new result | `param: old → new` (one line) | -| Composing a workflow | the blueprint YAML (10–30 lines) | the `compose` summary — never the 100-node JSON | +| Building a workflow | the recipe (params + ops) | the `apply` summary (`data.ops`) — never the 100-node JSON | | Editing one slot | the re-rendered result | `addr: old → new` (one line) | ## Rule 7 — Be honest about what you can't perceive diff --git a/comfy_cli/skills/comfy/SKILL.md b/comfy_cli/skills/comfy/SKILL.md index 6f9a3649..3e0e6257 100644 --- a/comfy_cli/skills/comfy/SKILL.md +++ b/comfy_cli/skills/comfy/SKILL.md @@ -17,7 +17,6 @@ halves are independent — you can scan only what's relevant to the task. **This is one of a skill family — skim the siblings before a big task so you know what exists, and reach for the right one rather than improvising its job:** -`comfy-fragments` (compose large graphs from reusable, validated pieces), `comfy-director` (multi-shot narrative video — story, continuity, conform), `comfy-debug` (any failed job: error code → fix), `comfy-relay` (surface a workflow/result in chat, never leave it in /tmp). When a task spans several, @@ -191,15 +190,12 @@ mechanism by complexity and reuse — not as a quality ranking: Re-typing a fragment from a node schema re-introduces those as transcription bugs you only discover when the cloud rejects the job. Start from what works. - Only author `./fragments/.json` by hand when there is **no** working - graph to project from (you're building a shape that doesn't exist yet): 1-15 - API-format nodes wrapped with a `_fragment` header declaring typed inputs, - outputs, and params (caller-supplied values marked `"PLACEHOLDER"`). Make - EVERY asset/model name a required param with no default. Load the - `comfy-fragments` skill for the format, then check your work: - ```bash - comfy --json workflow fragment validate - ``` + When there is **no** working graph to project from (you're building a shape + that doesn't exist yet), author a **recipe** instead (see the recipe section + above): build it once with the structured-edit primitives, `capture` it, and + lift the asset/model/prompt fields to `${param}`. Recipes are UI-format, + mergeable, and reusable — prefer them over the legacy fragment/blueprint + authoring described below. **d. Compose + run** — a YAML blueprint in `blueprints/.yaml` wires your fragments together; cross-step refs use `$alias.output_name`, @@ -256,7 +252,12 @@ mechanism by complexity and reuse — not as a quality ranking: blueprint.** Even for smaller workflows, prefer fragments if any part could be extended, repeated, or reused. -## The compile model — edit source, never the artifact (REQUIRED) +## The compile model — edit source, never the artifact (fragments; legacy) + +> **Legacy.** Fragments/blueprints/`compose` produce **API format**, which can't live +> on the canvas or merge (CRDT). For reuse/composition prefer **recipes** (`apply +> --param` + `capture`, above). This section remains for the existing project/blueprint +> convention and headless batch compiles; new authoring should use recipes. The folders are **source**; the workflow JSON is a **build artifact**. `fragments/` + `blueprints/` are what you edit; `compose` is the compiler; @@ -283,9 +284,122 @@ This is a hard contract, not a style preference: **Red flag — STOP:** you typed `jq`/`sed`/`Edit` against a workflow's `widgets_values` or `inputs`, or you're hunting for a node by numeric id -(`select(.id==128)`). That means the source representation failed. `decompose` -it and set a named param. (This rule exists because that exact jq-on-`id==128` -hand-edit is the anti-pattern `decompose` was built to kill.) +(`select(.id==128)`). That means the source representation failed. For **reusable** +work, `decompose` it and set a named param. For a **programmatic structured edit** +of a live graph, use the structured-edit primitives below — never raw `jq`/`sed`. +(This rule exists because that exact jq-on-`id==128` hand-edit is the anti-pattern +`decompose` — and these primitives — were built to kill.) + +## Structured graph edits — `add-node` / `connect` / `set-widget` / `delete-node` + +The **sanctioned** way to mutate a graph's *structure* from code — the +alternative to `jq`/`sed` on `nodes`/`links`/`widgets_values`. Each edit is +validated against `object_info` (node class, widget name, widget value **shape**, +and connection **type** are hard-checked; unknown COMBO values / out-of-range +numbers come back as soft `warnings`) and emits a replayable **operation** in +`data.op`. + +**When to use which editing path:** +- **Reusable / human-authored workflow** → fragments + blueprint (above). *Default.* +- **Throwaway value tweak on a template** → `slots` → `set-slot`/`vary`. +- **Programmatic structured edit of a live/draft graph** (add or wire or remove + nodes; the in-app agent's path; any edit that must merge with a concurrent + human editor) → the primitives here. + +> **Live co-editing / CRDT:** only the structured-edit primitives (`add-node`/ +> `connect`/`set-widget`/`delete-node`/`apply`) emit a mergeable **op** in +> `data.op`/`data.ops` (`op_id` + `actor` + `base_version` + `stamp`). Fragments + +> `compose` produce a **whole-document** graph — fine for authoring a *fresh* +> draft (the base), but it does **not** emit ops and will clobber a concurrent +> editor if used to re-generate an existing draft. **Any edit that must merge with +> a human's canvas MUST go through the primitives, not a recompose.** + +```bash +# Catalog source: `--where cloud|local` (default routing if omitted), or an +# offline `--input object_info.json` dump. `--where` and `--input` are the only +# catalog flags on these commands. +CAT="--where cloud" + +# Start from an existing graph, or an empty one: +echo '{"nodes":[],"links":[],"last_node_id":0,"last_link_id":0}' > wf.json + +comfy --json workflow add-node wf.json KSampler --at 400,200 $CAT # → data.op.node_id (minted) +comfy --json workflow connect wf.json 7.LATENT 3.samples $CAT # source out-slot → target in-slot +comfy --json workflow set-widget wf.json 3.steps 35 $CAT # widget by NAME; op carries {old,value} +comfy --json workflow delete-node wf.json 7 $CAT # removes node + its links +comfy --json workflow ls-nodes wf.json # id / type / title (no catalog needed) +``` + +**Building more than one or two nodes? Use `apply` — one batch, one catalog load, +and `as` aliases so you never capture a minted id by hand:** + +```bash +cat > ops.json <<'JSON' +[ {"op":"add_node","class_type":"CheckpointLoaderSimple","as":"ckpt"}, + {"op":"add_node","class_type":"KSampler","as":"ks"}, + {"op":"connect","from":"ckpt.MODEL","to":"ks.model"}, + {"op":"set_widget","node":"ks","widget":"steps","value":30} ] +JSON +comfy --json workflow apply wf.json --ops ops.json $CAT # or --ops - to read stdin +# → data.ops[] (all minted ids), data.aliases{ckpt,ks}. Atomic: nothing writes if any spec fails. +``` + +**Reusable recipes (the reuse path — prefer this over fragments/compose).** A recipe +is an ops file with a `params` header and `${param}` holes: + +```jsonc +{ "recipe":"t2i", + "params": { "positive": {"type":"string"}, // required (no default) + "steps": {"type":"int", "default":20} }, // type: string|int|float|bool + "ops": [ …, {"op":"set_widget","node":"ks","widget":"steps","value":"${steps}"} ] } +``` + +`apply --param k=v` fills the holes — **typed and strict**: a value exactly `${x}` +takes the param's real value; a missing required param, an unknown param, or a bad +type all error (never a silent blank). + +```bash +# capture a working graph into a recipe, PARAMETERIZING the fields you'll vary: +comfy --json workflow capture wf.json --name t2i --param 6.text=positive --param 3.seed=seed -o t2i.recipe.json $CAT +comfy --json workflow apply fresh.json --ops t2i.recipe.json --param positive="a fox" --param seed=42 $CAT +``` + +`capture --param .=` lifts that widget to a `${name}` hole +(current value becomes its default) — use it for the fields you want to vary, since +plain `capture` omits widgets left at their default. Recipes are UI-format op-batches +— mergeable and canvas-native. `compose`/`decompose` (the fragment/blueprint path) +are **legacy**: they emit API format and can't co-edit; use recipes for anything +you'll reuse or edit on the canvas. + +**Run it and get the image back.** Inside a project, outputs land in `outputs/`. +Outside one (a bare `wf.json`), submit and pipe the result into `download`: + +```bash +comfy --json run --workflow wf.json --where cloud --wait > run.json # blocks until done; data.prompt_id + output refs +comfy --json download --out-dir ./out < run.json # pull the produced image(s) to ./out +``` + +- **Addresses:** `.`. Connection slots accept a **name** + (source output like `LATENT`, target input like `samples`) or an index; widgets + are addressed **by name**. Discover them with `comfy --json nodes show ` + (input/output slot names) and `comfy --json workflow slots ` (widget + names — note `slots` lists **widgets only**, not connection slots). +- **Identity:** `add-node` mints a **large random integer** id (leaderless, + collision-free) and returns it in `data.op.node_id`; **capture it** to wire the + new node (e.g. `id=$(comfy --json workflow add-node … | jq -r .data.op.node_id)`). + Do not assume small/sequential ids. `add-node` fills widget defaults (COMBO → + first choice), so a new node is runtime-valid without extra `set-widget` calls. +- **The op** (`data.op`): `{op, op_id, node_id/link_id, actor, base_version, stamp}` + — a structured, idempotent, mergeable record of the change (`set_widget` also + carries `old`/`value`). `--actor ` and `--base-version ` stamp it for + concurrent/CRDT consumers; `--stdout` prints the new graph instead of writing + in place. +- **`delete-node` ≠ `delete`:** `delete-node` removes a *node from the graph file*; + `comfy workflow delete` deletes a *saved workflow from Comfy Cloud*. Do not confuse them. +- `add-node`/`connect`/`delete-node` operate on **top-level** nodes only. + `set-widget` additionally resolves values **inside a subgraph** directly — use + the flat promoted address `slots` advertises (e.g. `57.text`) or the nested + form (`57/27.text`); no decompose needed. --- @@ -345,6 +459,12 @@ comfy --json nodes ls --pack core --produces MASK --limit 5 If no local server is running and you're not signed into cloud, pass `--input ` to query against a saved dump. +**Dynamic-combo gotcha:** some partner nodes declare a `COMFY_DYNAMICCOMBO_V3` +widget (e.g. a Kling/Grok `model` or `model.resolution`) whose **`choices` come +back empty from `nodes show`** — the options are resolved at runtime. To learn the +valid values, `comfy templates fetch ` for that node and read the +widget values it ships (e.g. `model="kling-v3"`, `model.resolution="720p"`). + ## Models — find what's installed, with metadata On **cloud**, `comfy models search` hits the live asset catalog @@ -1004,8 +1124,8 @@ names files `_.`. Read outputs by item, never by array order. Avoid the old `PIDS=()` shell-loop pattern — it duplicates scheduling the engine already does and gives you N jobs to babysit instead of one. (For a pure prompt/seed sweep over the *same* graph, `comfy -workflow vary` is the right tool; see the `comfy-fragments` skill for the -full blueprint syntax.) +workflow vary` is the right tool; for reusable parameterized generation use a +recipe with `workflow foreach`.) **The exception — when fan-out across separate jobs IS right.** A multi-shot film built on **partner-API video/avatar nodes** (KlingAvatar, Kling i2v, Sora, diff --git a/comfy_cli/skills/command.py b/comfy_cli/skills/command.py index dd91ca21..edf9ceac 100644 --- a/comfy_cli/skills/command.py +++ b/comfy_cli/skills/command.py @@ -9,7 +9,6 @@ - ``comfy`` — the consolidated driver skill (command surface, output contract, routing, discovery, execution, image, video, audio, cloud, edit, condition, pipeline) - - ``comfy-fragments`` — typed reusable workflow fragments + YAML blueprint composition - ``comfy-debug`` — debugging when workflows fail or jobs hang - ``comfy-relay`` — what to put in chat while driving the CLI - ``comfy-director`` — narrative multi-shot video production (screenplay, diff --git a/comfy_cli/standalone.py b/comfy_cli/standalone.py index 9c89bde5..41b42fa8 100644 --- a/comfy_cli/standalone.py +++ b/comfy_cli/standalone.py @@ -4,8 +4,6 @@ import subprocess from pathlib import Path -import requests - from comfy_cli.constants import DEFAULT_STANDALONE_PYTHON_MINOR_VERSION, OS, PROC from comfy_cli.typing import PathLike from comfy_cli.utils import create_tarball, download_url, extract_tarball, get_os, get_proc @@ -34,6 +32,10 @@ def _resolve_python_version(asset_url_prefix: str, minor_version: str) -> str: Downloads the SHA256SUMS file (~45 KB) from the release and parses it to find the available patch version for the requested minor series (e.g. "3.12" -> "3.12.13"). """ + # Imported lazily: requests costs ~30ms to import and this module is on + # the import path of every CLI invocation. + import requests + sha256sums_url = f"{asset_url_prefix.rstrip('/')}/SHA256SUMS" response = requests.get(sha256sums_url) response.raise_for_status() @@ -67,6 +69,8 @@ def download_standalone_python( ) -> PathLike: """grab a pre-built distro from the python-build-standalone project. See https://gregoryszorc.com/docs/python-build-standalone/main/""" + import requests # deferred; see _resolve_python_version + platform = get_os() if platform is None else platform proc = get_proc() if proc is None else proc target = _platform_targets[(platform, proc)] diff --git a/comfy_cli/tracking.py b/comfy_cli/tracking.py index 580bceb1..aa1c0e25 100644 --- a/comfy_cli/tracking.py +++ b/comfy_cli/tracking.py @@ -10,10 +10,8 @@ from typing import Any, Protocol import typer -from mixpanel import Mixpanel -from posthog import Posthog -from comfy_cli import constants, logging, ui +from comfy_cli import constants, logging from comfy_cli.config_manager import ConfigManager from comfy_cli.workspace_manager import WorkspaceManager @@ -139,7 +137,15 @@ def flush(self) -> None: ... class MixpanelProvider: def __init__(self, token: str): - self.client = Mixpanel(token) if token else None + if token: + # Imported lazily: mixpanel (and its urllib3 dependency) is only + # needed once an event is actually sent, and importing it at + # module import slows down every CLI invocation. + from mixpanel import Mixpanel + + self.client = Mixpanel(token) + else: + self.client = None self.enabled = self.client is not None def track(self, event_name: str, distinct_id: str | None, properties: dict[str, Any]) -> None: @@ -165,8 +171,16 @@ def __init__(self, token: str, host: str): self.enabled = False if not token: return + # Imported lazily (see MixpanelProvider) — posthog costs ~100ms to import. + from posthog import Posthog + # disable_geoip=False lets PostHog enrich events with IP-derived location. - self.client = Posthog(project_api_key=token, host=host, disable_geoip=False) + # flush_interval is passed explicitly because the client's atexit join + # waits out the full interval even on an empty queue; the library + # default varies by version (0.5s in 7.18, 5.0s in 7.21) and would add + # that much dead time to every CLI exit. 0.2s bounds the exit cost while + # the explicit flush() in _flush_all_providers still drains real events. + self.client = Posthog(project_api_key=token, host=host, disable_geoip=False, flush_interval=0.2) self.enabled = True def track(self, event_name: str, distinct_id: str | None, properties: dict[str, Any]) -> None: @@ -186,10 +200,24 @@ def flush(self) -> None: self.client.flush() -PROVIDERS: list[TelemetryProvider] = [ - MixpanelProvider(MIXPANEL_TOKEN), - PostHogProvider(POSTHOG_TOKEN, POSTHOG_HOST), -] +# Providers are constructed lazily on the first event dispatch, NOT at module +# import. Building them eagerly costs ~100ms of import time and starts +# PostHog's consumer thread, whose atexit join stalls every CLI exit — even +# for fully opted-out invocations that never send anything. ``None`` means +# "not constructed yet"; tests may patch in a ready-made list. +PROVIDERS: list[TelemetryProvider] | None = None + + +def _get_providers() -> list[TelemetryProvider]: + """Return the telemetry providers, constructing them on first use.""" + global PROVIDERS + if PROVIDERS is None: + PROVIDERS = [ + MixpanelProvider(MIXPANEL_TOKEN), + PostHogProvider(POSTHOG_TOKEN, POSTHOG_HOST), + ] + return PROVIDERS + app = typer.Typer() @@ -215,7 +243,7 @@ def _dispatch( passive telemetry, env-only for feedback). """ properties = {**properties, "cli_version": cli_version, "tracing_id": tracing_id} - for provider in PROVIDERS: + for provider in _get_providers(): provider_event_name = ( mixpanel_name if (mixpanel_name is not None and isinstance(provider, MixpanelProvider)) else event_name ) @@ -376,6 +404,10 @@ def prompt_tracking_consent(skip_prompt: bool = False, default_value: bool = Fal pass return + # Imported lazily: ui pulls in questionary/prompt_toolkit (~50ms) and is + # only needed on this interactive consent path. + from comfy_cli import ui + enable_tracking = ui.prompt_confirm_action("Do you agree to enable tracking to improve the application?", False) init_tracking(enable_tracking) @@ -408,6 +440,10 @@ def init_tracking(enable_tracking: bool): def _flush_all_providers() -> None: + # Never construct providers here: if none were built, no event was ever + # dispatched this process, so there is nothing to flush. + if PROVIDERS is None: + return for provider in PROVIDERS: try: provider.flush() diff --git a/comfy_cli/ui.py b/comfy_cli/ui.py index 74087776..5e0f025d 100644 --- a/comfy_cli/ui.py +++ b/comfy_cli/ui.py @@ -1,15 +1,22 @@ +from __future__ import annotations + from enum import Enum -from typing import Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar -import questionary import typer -from questionary import Choice from rich.console import Console from rich.progress import Progress from rich.table import Table from comfy_cli.workspace_manager import WorkspaceManager +# questionary pulls in prompt_toolkit (~50ms of import time), so it is +# imported lazily inside each prompting function rather than at module import. +if TYPE_CHECKING: + from questionary import Choice + + ChoiceType = str | Choice | dict[str, Any] + console = Console() workspace_manager = WorkspaceManager() @@ -35,9 +42,6 @@ def show_progress(iterable, total, description="Downloading..."): progress.update(task, advance=len(chunk)) -ChoiceType = str | Choice | dict[str, Any] - - def prompt_autocomplete( question: str, choices: list[ChoiceType], default: ChoiceType = "", force_prompting: bool = False ) -> ChoiceType | None: @@ -55,6 +59,8 @@ def prompt_autocomplete( """ if workspace_manager.skip_prompting and not force_prompting: return None + import questionary + return questionary.autocomplete(question, choices=choices, default=default).ask() @@ -75,6 +81,8 @@ def prompt_select( """ if workspace_manager.skip_prompting and not force_prompting: return None + import questionary + return questionary.select(question, choices=choices, default=default).ask() @@ -96,6 +104,8 @@ def prompt_select_enum(question: str, choices: list[E], force_prompting: bool = if workspace_manager.skip_prompting and not force_prompting: return None + import questionary + choice_map = {choice.value: choice for choice in choices} display_choices = list(choice_map.keys()) @@ -120,6 +130,8 @@ def prompt_input(question: str, default: str = "", force_prompting: bool = False """ if workspace_manager.skip_prompting and not force_prompting: return default + import questionary + return questionary.text(question, default=default).ask() @@ -134,6 +146,8 @@ def prompt_multi_select(prompt: str, choices: list[str]) -> list[str]: Returns: List[str]: A list of the selected items. """ + import questionary + selections = questionary.checkbox(prompt, choices=choices).ask() # returns list of selected items return selections if selections else [] diff --git a/comfy_cli/utils.py b/comfy_cli/utils.py index e5c582e2..03f518ad 100644 --- a/comfy_cli/utils.py +++ b/comfy_cli/utils.py @@ -12,7 +12,6 @@ from typing import BinaryIO, cast import psutil -import requests import typer from rich import progress from rich.live import Live @@ -118,6 +117,10 @@ def download_url( ) -> PathLike: """download url to local file fname and show a progress bar. See https://stackoverflow.com/q/37573483""" + # Imported lazily: requests costs ~30ms to import and utils is on the + # import path of every CLI invocation; only downloads need it. + import requests + cwd = Path(cwd).expanduser().resolve() fpath = cwd / fname diff --git a/comfy_cli/where.py b/comfy_cli/where.py index cb2c87d2..963734b0 100644 --- a/comfy_cli/where.py +++ b/comfy_cli/where.py @@ -124,8 +124,10 @@ def _has_cloud_credentials() -> bool: is clearly the configured backend; preflight surfaces the expiry), so this deliberately doesn't use ``resolve_cloud_credential``. """ - from comfy_cli.credentials import find_api_key, get_session + from comfy_cli.credentials import cloud_bearer_env_token, find_api_key, get_session + if cloud_bearer_env_token() is not None: + return True if find_api_key(purpose="cloud") is not None: return True return get_session(refresh=False) is not None @@ -154,6 +156,7 @@ def cloud_preflight() -> CloudError | None: """Return an error envelope payload if the cloud path can't proceed. Accepts either auth path: + - ``COMFY_CLOUD_AUTH_TOKEN`` env var (forwarded Bearer token), OR - ``COMFY_CLOUD_API_KEY`` env var, OR - persisted ``comfy-cloud-api-key`` provider record, OR - active OAuth session (valid + non-expired). @@ -162,10 +165,13 @@ def cloud_preflight() -> CloudError | None: - Nothing configured → ``cloud_not_configured`` - OAuth session expired → ``cloud_unauthorized`` """ - from comfy_cli.credentials import find_api_key, get_session + from comfy_cli.credentials import cloud_bearer_env_token, find_api_key, get_session - # API key path — no expiry check, key is either valid or it isn't (server - # tells us at request time). + # Forwarded Bearer token (trusted-caller path) or ambient API key — no + # expiry check, the value is either valid or it isn't (server tells us at + # request time). + if cloud_bearer_env_token() is not None: + return None if find_api_key(purpose="cloud") is not None: return None diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py new file mode 100644 index 00000000..808b3d43 --- /dev/null +++ b/comfy_cli/workflow_ops.py @@ -0,0 +1,1141 @@ +"""CRDT-ready structured edit operations over frontend-format ComfyUI graphs. + +This is the op-model the agent (and a human via the CLI) uses to mutate a +workflow. Every primitive returns ``(workflow, op)`` where ``op`` is a +self-describing, replayable operation. The same op stream feeds both a +single-writer file edit (locally) and a merge consumer (cloud) — the CLI never +merges; it emits ops that *converge under replay* and, for the residual cases a +leaderless writer cannot decide alone, flags a conflict rather than silently +diverging. + +Design (settled by the identity spike): + +* **Identity is leaderless & collision-free.** New node/link ids are random + 53-bit integers (``mint_id``): no shared counter, no coordination, and still + ``int``-typed so the API converter (which gates link ids on ``isinstance(int)``) + and an int-keyed frontend keep working. ``last_node_id``/``last_link_id`` are + kept only as advisory high-water marks, never as allocators. +* **Widgets are name-addressed, never index-addressed.** ``set_widget`` carries + the widget *name*; ``apply_op`` resolves name → ``widgets_values`` index against + the live schema at apply time, so an op survives widget-layout drift. + +Convergence guarantees ``apply_op`` upholds, so any replay order reaches the same +``canonical`` graph (proved by the P8..P11 order-independence tests): + +* **Idempotent** — an op whose ``op_id`` was already applied is a no-op. +* **Total** — a write (``set_widget``/``connect``) to a node that was + concurrently deleted is a no-op: delete wins. Apply never raises on a + since-removed target, so a merge consumer can replay a delete and an edge/edit + in either order. +* **Last-writer-wins on widgets** — two concurrent writes to the same widget + converge on the value with the higher causal ``stamp`` ``[base_version, actor]`` + (``op_id`` breaks exact ties into a total order), independent of apply order. + The winning stamp per target is tracked in ``_widget_stamps`` (apply-only + bookkeeping, stripped before serialization). +* **Deterministic structure** — forking a shared subgraph definition mints an id + derived from ``(definition, instance)`` (not a random UUID), so two replicas + replaying the same ops produce byte-identical graphs. +* **Non-clobbering autogrow** — a ``COMFY_AUTOGROW_V3`` connect never overwrites a + slot already wired to another link; it grows a fresh slot keyed by ``grow_id`` + (the link id). Two concurrent autogrow connects both survive and ``canonical`` + compares grown slots by ``grow_id``, not by list position. + +The one thing a leaderless writer genuinely *cannot* converge is a *sequence +decision*: the human-visible ordering/numbering of concurrently-grown autogrow +slots (a batch's element order) and of concurrent interior writes to the same +shared subgraph definition. Those are surfaced by :func:`detect_conflict` for the +merge consumer / ask-to-merge to resolve — the ops still never lose data, and +``canonical`` treats the order as immaterial, so the semantic graph converges even +while the display order does not. +""" + +from __future__ import annotations + +import copy +import json +import random +import re +import uuid +from typing import Any + +# New ids live in [2**40, 2**53): always large (never collides with small +# frontend counter ids), always inside JS Number.MAX_SAFE_INTEGER. +_ID_FLOOR = 1 << 40 + + +def mint_id() -> int: + """A leaderless, collision-free, int-typed identity for a node or link.""" + return _ID_FLOOR | random.getrandbits(52) + + +def _new_op(kind: str, actor: str, base_version: int, **fields: Any) -> dict[str, Any]: + return { + "op": kind, + "op_id": uuid.uuid4().hex, + "actor": actor, + "base_version": base_version, + "stamp": [base_version, actor], + **fields, + } + + +def _find(workflow: dict, node_id: Any) -> dict | None: + for n in workflow.get("nodes") or []: + if isinstance(n, dict) and n.get("id") == node_id: + return n + return None + + +def _require(workflow: dict, node_id: Any) -> dict: + n = _find(workflow, node_id) + if n is None: + raise ValueError(f"node {node_id} not found in workflow") + return n + + +def _available_nodes_hint(workflow: dict, *, limit: int = 12) -> str: + """Compact ``id (type)`` list of nodes that DO exist — to correct a + mistargeted node id.""" + out: list[str] = [] + for n in workflow.get("nodes") or []: + if isinstance(n, dict) and n.get("id") is not None: + out.append(f"{n.get('id')} ({n.get('type', '?')})") + if len(out) >= limit: + out.append("…") + break + return ", ".join(out) + + +def _enrich_resolution_error(e: ValueError, workflow: dict, graph, *, widget: Any = None) -> ValueError: + """Turn a *not-found* edit error into an actionable one. + + An LLM editing a graph tends to rebuild an identifier from memory instead of + copying it from ``comfy workflow slots`` — and a wrong id often lands on a + real *sibling* (e.g. ``285/288.vae_name`` hits a CLIPLoader when the VAELoader + is ``285/29``), so the edit fails or, worse, silently mis-targets. When the + widget name is known we scan the workflow for the address that actually + carries it; otherwise we list the node ids that exist. Shape/enum/type + errors (the target resolved fine) pass through unchanged. + """ + msg = str(e) + if "not found" not in msg: + return e + if widget: + from comfy_cli.cql.engine import _suggest_slots_for_input + + addrs = _suggest_slots_for_input(workflow, str(widget), graph) + if addrs: + return ValueError( + f"{msg}. Did you mean: {'; '.join(addrs)}? " + "Copy the address verbatim from `comfy workflow slots` — never rebuild it." + ) + nodes_hint = _available_nodes_hint(workflow) + if nodes_hint: + return ValueError( + f"{msg}. Nodes in this workflow: {nodes_hint}. " + "Use an id from `comfy workflow slots` / `ls-nodes` — never rebuild it." + ) + return e + + +def _find_by_str(workflow: dict, node_id: Any) -> dict | None: + """Locate a node comparing ids as strings — subgraph op paths carry string + ids while top-level node ids are ints.""" + s = str(node_id) + for n in workflow.get("nodes") or []: + if isinstance(n, dict) and str(n.get("id", "")) == s: + return n + return None + + +def _stamp_key(op: dict) -> list: + """A total causal order for last-writer-wins: higher ``base_version`` wins, + ties broken by ``actor`` then the unique ``op_id`` (so no two distinct ops + ever compare equal).""" + stamp = op.get("stamp") or [op.get("base_version", 0), op.get("actor", "")] + return [stamp[0], stamp[1], op["op_id"]] + + +def _lww_gate(workflow: dict, op: dict) -> bool: + """True iff this ``set_widget`` should apply under last-writer-wins. A write + to a target already claimed by a higher-or-equal stamp is dropped, making the + surviving value independent of apply order.""" + prior = workflow.get("_widget_stamps", {}).get(json.dumps(_write_target(op), default=str)) + return prior is None or _stamp_key(op) > list(prior) + + +def _lww_commit(workflow: dict, op: dict) -> None: + """Record this op's stamp as the winner for its target.""" + workflow.setdefault("_widget_stamps", {})[json.dumps(_write_target(op), default=str)] = _stamp_key(op) + + +def _next_autogrow_name(ins: list, requested: str) -> str: + """A free autogrow slot name. Prefer the op's requested name; if a concurrent + connect already took it, grow the next sequential ``{base}.{elem}{N}`` so no + slot is ever clobbered (the server convention stays sequential).""" + taken = {i.get("name") for i in ins} + if requested not in taken: + return requested + base, _, stem = requested.partition(".") + elem = stem.rstrip("0123456789") or "slot" + n = 0 + while f"{base}.{elem}{n}" in taken: + n += 1 + return f"{base}.{elem}{n}" + + +# --------------------------------------------------------------------------- +# primitives — each returns (workflow, op); the op is applied via apply_op so +# apply(base, op) == primitive(base) holds by construction (P1 fidelity). +# --------------------------------------------------------------------------- + + +def add_node( + workflow: dict, + graph, + class_type: str, + *, + pos: list | None = None, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + m = graph.node(class_type) + if m is None: + raise ValueError(f"unknown node type {class_type!r}") + node = _build_node(mint_id(), class_type, m, graph, pos) + op = _new_op( + "add_node", + actor, + base_version, + node_id=node["id"], + class_type=class_type, + pos=node["pos"], + node=node, + ) + return apply_op(workflow, op, graph), op + + +def set_widget( + workflow: dict, + graph, + node_id: Any, + widget: str, + value: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + """Set a widget, enriching a not-found node/widget error with the real + address that carries ``widget`` so a mistargeted edit self-corrects in one + step (see :func:`_enrich_resolution_error`).""" + try: + return _set_widget_impl(workflow, graph, node_id, widget, value, actor=actor, base_version=base_version) + except ValueError as e: + raise _enrich_resolution_error(e, workflow, graph, widget=widget) from e + + +def _normalize_combo(graph, class_type: str, widget: str, value: Any) -> tuple[Any, dict | None]: + """Rewrite a mangled model/COMBO value to the real option it means so the + model actually loads (e.g. ``checkpoints/wai-illustrious-sdxl.safetensors`` → + ``wai-illustrious-sdxl.safetensors``). Returns ``(value, note)`` — ``note`` is + an informational warning when a rewrite happened, else ``None``. Only an + UNAMBIGUOUS match is rewritten; anything else is left untouched so validate's + ``unknown_enum_value`` (with ``did_you_mean``) still fires. + """ + m = graph.node(class_type) + if m is None: + return value, None + port = next((p for p in m.inputs if p.name == widget), None) + if port is None: + return value, None + canon = port.canonical_combo(value) + if canon is None or canon == value: + return value, None + return canon, { + "code": "normalized_value", + "field": widget, + "message": f"{value!r} is not an exact option; using the matching model {canon!r}", + "from": str(value), + "to": canon, + } + + +def _set_widget_impl( + workflow: dict, + graph, + node_id: Any, + widget: str, + value: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + # Subgraph-aware: a subgraph instance's *promoted* input (flat ``57.text`` — + # exactly what ``comfy workflow slots`` advertises) or an interior node + # (nested ``57/27.text``) resolves INTO the subgraph definition. Both forms + # reuse the CQL engine's slot resolver so set-widget and slots agree. The op + # carries the resolved interior ``path`` + ``inner_widget`` so apply/replay is + # deterministic and writes back into the definition (the change persists). + sub = _subgraph_write_target(workflow, node_id, widget) + if sub is not None: + segments, inner_widget = sub + target = _navigate_subgraph_path(workflow, segments) # read-only: current value + schema + inner_type = target.get("type", "") + value, norm_note = _normalize_combo(graph, inner_type, inner_widget, value) + cur = target.get("widgets_values") or [] + order = graph.widget_order_for_node(inner_type, cur) + old = None + if inner_widget in order: + i = order.index(inner_widget) + old = cur[i] if i < len(cur) else None + warnings = _validate_widget(graph, inner_type, inner_widget, value) # raises on shape mismatch + if norm_note: + warnings = [norm_note, *warnings] + op = _new_op( + "set_widget", + actor, + base_version, + node_id=node_id, + widget=widget, + value=value, + old=old, + path=[str(s) for s in segments], + inner_widget=inner_widget, + ) + if warnings: + op["warnings"] = warnings + return apply_op(workflow, op, graph), op + + node = _require(workflow, node_id) + class_type = node.get("type", "") + widgets = node.get("widgets_values") or [] + idx = _widget_index(graph, class_type, widget, widgets) # raises on unknown widget name + value, norm_note = _normalize_combo(graph, class_type, widget, value) + old = widgets[idx] if idx < len(widgets) else None + warnings = _validate_widget(graph, class_type, widget, value) # raises on shape mismatch + if norm_note: + warnings = [norm_note, *warnings] + op = _new_op( + "set_widget", + actor, + base_version, + node_id=node_id, + widget=widget, + value=value, + old=old, + ) + if warnings: + op["warnings"] = warnings + return apply_op(workflow, op, graph), op + + +def _subgraph_write_target(workflow: dict, node_id: Any, widget: str) -> tuple[list[str], str] | None: + """Resolve a subgraph promoted/interior widget address to ``(node_path, inner_widget)``. + + Returns ``None`` when ``node_id`` is an ordinary top-level node (the caller + uses the direct widget path). Two address forms resolve here — the SAME ones + ``comfy workflow slots`` advertises for a subgraph instance: + + * FLAT promoted input (``57.text``): ``node_id`` is a subgraph *instance* + and ``widget`` names one of its promoted proxy inputs; we follow the + instance's ``proxyWidgets`` to the interior node that backs it. + * NESTED interior (``57/27.text``): ``node_id`` already carries the + ``/`` path; its segments pass straight through. + + Raises ``ValueError`` (with a slots-consistent hint) when the node is a + subgraph instance but ``widget`` is not one of its promoted inputs. + """ + from comfy_cli.cql import engine as _engine + + node_str = str(node_id) + # Nested interior form: the interior path is explicit. + if _engine._SUBGRAPH_PATH_SEP in node_str: + return node_str.split(_engine._SUBGRAPH_PATH_SEP), widget + + defs_by_id = _engine._subgraph_defs_by_id(workflow) + if not defs_by_id: + return None + instance = _find(workflow, node_id) + if instance is None: + return None # let the direct path raise the canonical "node not found" + if defs_by_id.get(instance.get("type", "")) is None: + return None # ordinary top-level node + # Subgraph instance: map the promoted input name → interior node via proxyWidgets. + proxy = (instance.get("properties") or {}).get("proxyWidgets") or [] + proxied: list[str] = [] + for entry in proxy: + if not (isinstance(entry, list) and len(entry) >= 2): + continue + name = entry[1] if isinstance(entry[1], str) else str(entry[1]) + proxied.append(name) + if name == widget: + return [node_str, str(entry[0])], widget + raise ValueError( + f"promoted input {widget!r} not found on subgraph node {node_id}; " + f"available: {', '.join(proxied) if proxied else '(none)'} " + f"(or address an interior widget directly, e.g. {node_str}/.)" + ) + + +def _navigate_subgraph_path(workflow: dict, segments: list[str]) -> dict: + """Read-only walk of a ``/``-separated node path into subgraph definitions. + + Unlike the engine's apply-time resolver this does NOT fork shared definitions + (a read must not mutate); the forking happens at apply time. Raises + ``ValueError`` describing the first hop that couldn't be found. + """ + from comfy_cli.cql import engine as _engine + + defs_by_id = _engine._subgraph_defs_by_id(workflow) + node = next( + (n for n in workflow.get("nodes") or [] if isinstance(n, dict) and str(n.get("id", "")) == str(segments[0])), + None, + ) + if node is None: + raise ValueError(f"node {segments[0]} not found in workflow") + for seg in segments[1:]: + sg = defs_by_id.get(node.get("type", "")) + if sg is None: + raise ValueError(f"node {node.get('id')} is not a subgraph; cannot descend to {seg!r}") + node = next( + (n for n in (sg.get("nodes") or []) if isinstance(n, dict) and str(n.get("id", "")) == str(seg)), + None, + ) + if node is None: + raise ValueError(f"interior node {seg} not found in subgraph {sg.get('id')}") + return node + + +def connect( + workflow: dict, + graph, + from_node: Any, + from_slot: Any, + to_node: Any, + to_slot: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + """Wire two nodes, enriching a not-found endpoint error with the list of + node ids that exist (see :func:`_enrich_resolution_error`).""" + try: + return _connect_impl( + workflow, graph, from_node, from_slot, to_node, to_slot, actor=actor, base_version=base_version + ) + except ValueError as e: + raise _enrich_resolution_error(e, workflow, graph) from e + + +def _connect_impl( + workflow: dict, + graph, + from_node: Any, + from_slot: Any, + to_node: Any, + to_slot: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + src = _require(workflow, from_node) + dst = _require(workflow, to_node) + out_idx, link_type = _resolve_output_slot(src, graph, from_slot) + in_idx, grow = _resolve_input_target(dst, graph, to_slot, link_type) + # Type-check concrete slots: an output only connects to an input of the same + # type (or a wildcard "*"). Autogrow slots are minted with the source type, + # so they need no check. Without this, a mis-wire silently clobbers a link. + if in_idx is not None: + dst_type = (dst.get("inputs") or [])[in_idx].get("type") + if link_type and dst_type and link_type != dst_type and "*" not in (link_type, dst_type): + raise ValueError( + f"type mismatch: {link_type} output of node {from_node} cannot connect to " + f"{dst_type} input {(dst.get('inputs') or [])[in_idx].get('name')!r} of node {to_node}" + ) + op = _new_op( + "connect", + actor, + base_version, + link_id=mint_id(), + from_node=from_node, + from_slot=out_idx, + to_node=to_node, + to_slot=in_idx, + link_type=link_type, + ) + if grow is not None: + op["grow"] = grow # autogrow: apply appends this input slot, then wires it + return apply_op(workflow, op, graph), op + + +def delete_node( + workflow: dict, + graph, + node_id: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + """Delete a node, enriching a not-found error with the list of node ids that + exist (see :func:`_enrich_resolution_error`).""" + try: + return _delete_node_impl(workflow, graph, node_id, actor=actor, base_version=base_version) + except ValueError as e: + raise _enrich_resolution_error(e, workflow, graph) from e + + +def _delete_node_impl( + workflow: dict, + graph, + node_id: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + _require(workflow, node_id) + removed = [ln[0] for ln in workflow.get("links") or [] if ln[1] == node_id or ln[3] == node_id] + op = _new_op("delete_node", actor, base_version, node_id=node_id, removed_links=removed) + return apply_op(workflow, op, graph), op + + +# --------------------------------------------------------------------------- +# recipes — a parameterized op-batch. A recipe is `{params?, ops:[...]}`; a bare +# list is a param-less batch. `${name}` placeholders in op values are filled from +# `--param`. Validation is strict: a required param with no value, an unknown +# param, or a `${name}` the recipe didn't declare all fail — never a silent blank. +# --------------------------------------------------------------------------- + +_PARAM_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + + +class RecipeError(ValueError): + """A recipe or its parameters are malformed.""" + + +def parse_recipe(doc: Any) -> tuple[list, dict]: + """Split a recipe document into (ops, params_decl). Accepts a bare op list.""" + if isinstance(doc, list): + return doc, {} + if isinstance(doc, dict) and isinstance(doc.get("ops"), list): + return doc["ops"], (doc.get("params") or {}) + raise RecipeError("recipe must be a JSON array of ops, or an object with an `ops` array") + + +def resolve_params(params_decl: dict, provided: dict[str, str]) -> dict[str, Any]: + """Type-coerce provided values against the declared params. Errors on an + unknown param, or a declared param with neither a value nor a default.""" + unknown = sorted(set(provided) - set(params_decl)) + if unknown: + raise RecipeError(f"unknown --param {unknown}; recipe declares {sorted(params_decl)}") + out: dict[str, Any] = {} + for name, decl in params_decl.items(): + decl = decl if isinstance(decl, dict) else {} + if name in provided: + out[name] = _coerce_param(provided[name], decl.get("type", "string"), name) + elif "default" in decl: + out[name] = decl["default"] + else: + raise RecipeError(f"missing required --param {name!r}") + return out + + +def _coerce_param(raw: str, type_name: str, name: str) -> Any: + if type_name == "int": + try: + return int(raw) + except ValueError as e: + raise RecipeError(f"--param {name}: expected int, got {raw!r}") from e + if type_name in ("float", "number"): + try: + return float(raw) + except ValueError as e: + raise RecipeError(f"--param {name}: expected number, got {raw!r}") from e + if type_name in ("bool", "boolean"): + low = raw.strip().lower() + if low in ("true", "1", "yes"): + return True + if low in ("false", "0", "no"): + return False + raise RecipeError(f"--param {name}: expected bool, got {raw!r}") + return raw # string (the default) + + +def substitute_params(ops: list, params: dict[str, Any]) -> list: + """Replace `${name}` in op values. A value that is exactly `${name}` takes the + param's real (typed) value; embedded refs interpolate as text. An undeclared + `${name}` is an error, not a blank.""" + + def sub(value: Any) -> Any: + if isinstance(value, str): + whole = _PARAM_REF.fullmatch(value) + if whole: + return _param(whole.group(1), params) + return _PARAM_REF.sub(lambda m: str(_param(m.group(1), params)), value) + if isinstance(value, list): + return [sub(v) for v in value] + if isinstance(value, dict): + return {k: sub(v) for k, v in value.items()} + return value + + return [sub(op) for op in ops] + + +def _param(name: str, params: dict[str, Any]) -> Any: + if name not in params: + raise RecipeError(f"recipe references undeclared param ${{{name}}}") + return params[name] + + +def capture_recipe(workflow: dict, graph, name: str = "captured", lift: dict | None = None) -> dict: + """Project a UI-format graph into a recipe — the op-batch that rebuilds it + (add_node + non-default set_widget + connect). The inverse of `apply`: + `apply(empty, capture(wf))` reproduces `wf`. Top-level nodes only. + + `lift` maps `(node_id, widget_name) -> param_name`: those widgets become + `${param_name}` holes (with a `params` header entry defaulting to the current + value) even if the value equals the node default — so the fields you want to + vary are actually parameterizable. No auto-parameterization otherwise.""" + if (workflow.get("definitions") or {}).get("subgraphs"): + raise RecipeError("capture does not support subgraphs yet — edit/flatten top-level nodes first") + lift = lift or {} + nodes = [n for n in (workflow.get("nodes") or []) if isinstance(n, dict) and "id" in n] + by_id = {n["id"]: n for n in nodes} + + # Validate lift targets up front — no silently-ignored typos. + for (node_id, widget), _pname in lift.items(): + node = by_id.get(node_id) + if node is None: + raise RecipeError(f"--param target node {node_id!r} not in workflow") + if widget not in graph.widget_order(node.get("type", "")): + raise RecipeError(f"--param target {node_id}.{widget!r}: not a widget on {node.get('type')}") + + alias_by_id: dict[Any, str] = {} + counts: dict[str, int] = {} + for n in nodes: + slug = re.sub(r"[^a-z0-9]+", "_", str(n.get("type", "node")).lower()).strip("_") or "node" + counts[slug] = counts.get(slug, 0) + 1 + alias_by_id[n["id"]] = slug if counts[slug] == 1 else f"{slug}_{counts[slug]}" + + ops: list[dict] = [] + params_header: dict[str, Any] = {} + for n in nodes: + alias = alias_by_id[n["id"]] + class_type = n.get("type") + add: dict[str, Any] = {"op": "add_node", "class_type": class_type, "as": alias} + if n.get("pos"): + add["at"] = n["pos"] + ops.append(add) + widgets = n.get("widgets_values") or [] + order = graph.widget_order_for_node(class_type, widgets) + defaults = graph.widget_defaults(class_type) + for i, wname in enumerate(order): + if i >= len(widgets): + break + pname = lift.get((n["id"], wname)) + if pname is not None: + # Explicitly lifted → a ${param} hole, current value as its default. + ops.append({"op": "set_widget", "node": alias, "widget": wname, "value": f"${{{pname}}}"}) + params_header[pname] = {"type": _widget_param_type(graph, class_type, wname), "default": widgets[i]} + elif widgets[i] != defaults.get(wname): + # Only widgets that differ from the fresh-node default — add_node fills the rest. + ops.append({"op": "set_widget", "node": alias, "widget": wname, "value": widgets[i]}) + + node_by_id = {n["id"]: n for n in nodes} + for ln in workflow.get("links") or []: + if not (isinstance(ln, list) and len(ln) >= 5): + continue + _lid, from_id, from_slot, to_id, to_slot = ln[0], ln[1], ln[2], ln[3], ln[4] + if from_id not in alias_by_id or to_id not in alias_by_id: + continue + out_name = _slot_name(node_by_id[from_id].get("outputs"), from_slot) + in_name = _slot_name(node_by_id[to_id].get("inputs"), to_slot) + ops.append( + {"op": "connect", "from": f"{alias_by_id[from_id]}.{out_name}", "to": f"{alias_by_id[to_id]}.{in_name}"} + ) + + return {"recipe": name, "params": params_header, "ops": ops} + + +def _widget_param_type(graph, class_type: str, widget: str) -> str: + """Recipe param type for a widget, from its schema port type.""" + m = graph.node(class_type) + port = next((p for p in (m.inputs if m else []) if p.name == widget), None) + t = (port.type if port else "").upper() + if t == "INT": + return "int" + if t in ("FLOAT", "NUMBER"): + return "float" + if t == "BOOLEAN": + return "bool" + return "string" + + +def _slot_name(slots: Any, idx: Any) -> Any: + """A link's slot addressed by name where the node declares one, else by index + (both are valid connect targets).""" + if isinstance(slots, list) and isinstance(idx, int) and 0 <= idx < len(slots): + nm = slots[idx].get("name") if isinstance(slots[idx], dict) else None + if nm: + return nm + return idx + + +# --------------------------------------------------------------------------- +# apply_specs — run a batch of edit specs (add_node/connect/set_widget/delete_node) +# with `as` aliases so later specs reference just-minted nodes. Shared by the +# `apply` and `foreach` commands. Raises on a malformed spec so the caller can keep +# the batch atomic (write nothing on failure). +# --------------------------------------------------------------------------- + + +def resolve_ref(ref: Any, aliases: dict[str, Any]) -> Any: + """Map an alias to its minted id; pass ints/unknown strings through.""" + if isinstance(ref, str): + if ref in aliases: + return aliases[ref] + if ref.lstrip("-").isdigit(): + return int(ref) + return ref + + +def _split_ref_slot(spec_val: str, aliases: dict[str, Any]) -> tuple[Any, Any]: + """Split `.` and resolve the node part.""" + node_part, _, slot = str(spec_val).partition(".") + return resolve_ref(node_part, aliases), slot + + +def apply_specs( + workflow: dict, graph, specs: list, *, actor: str = "cli", base_version: int = 0 +) -> tuple[dict, list, dict]: + """Apply edit specs to ``workflow`` in order. Returns (workflow, ops, aliases).""" + aliases: dict[str, Any] = {} + ops: list[dict] = [] + for i, spec in enumerate(specs): + if not isinstance(spec, dict) or "op" not in spec: + raise ValueError(f"spec #{i} must be an object with an 'op' field") + kind = spec["op"] + # A missing required field surfaces as a bare KeyError (just the key name); + # wrap it so the batch/recipe caller learns WHICH spec and op are malformed. + try: + if kind == "add_node": + workflow, op = add_node( + workflow, graph, spec["class_type"], pos=spec.get("at"), actor=actor, base_version=base_version + ) + alias = spec.get("as") + if alias: + # A duplicate alias would silently clobber the earlier node, so a + # later `${alias}` reference resolves to the wrong node. Recipes + # are generated/templated, so an accidental repeat is plausible — + # fail loudly instead. + if alias in aliases: + raise ValueError(f"spec #{i}: alias {alias!r} is already defined by an earlier spec") + aliases[alias] = op["node_id"] + elif kind == "connect": + fn, fs = _split_ref_slot(spec["from"], aliases) + tn, ts = _split_ref_slot(spec["to"], aliases) + workflow, op = connect(workflow, graph, fn, fs, tn, ts, actor=actor, base_version=base_version) + elif kind == "set_widget": + workflow, op = set_widget( + workflow, + graph, + resolve_ref(spec["node"], aliases), + spec["widget"], + spec["value"], + actor=actor, + base_version=base_version, + ) + elif kind == "delete_node": + workflow, op = delete_node( + workflow, graph, resolve_ref(spec["node"], aliases), actor=actor, base_version=base_version + ) + else: + raise ValueError(f"spec #{i}: unknown op {kind!r}") + except KeyError as e: + raise ValueError(f"spec #{i} ({kind}) is missing required field {e}") from e + ops.append(op) + return workflow, ops, aliases + + +# --------------------------------------------------------------------------- +# apply — the deterministic, idempotent replay used by every consumer +# --------------------------------------------------------------------------- + + +def apply_op(workflow: dict, op: dict, graph) -> dict: + """Replay one op onto ``workflow`` in place and return it. Idempotent: an + op whose ``op_id`` was already applied is a no-op.""" + applied = workflow.setdefault("_applied_ops", []) + if op["op_id"] in applied: + return workflow + kind = op["op"] + if kind == "add_node": + _apply_add_node(workflow, op) + elif kind == "set_widget": + _apply_set_widget(workflow, op, graph) + elif kind == "connect": + _apply_connect(workflow, op) + elif kind == "delete_node": + _apply_delete_node(workflow, op) + else: + raise ValueError(f"unknown op {kind!r}") + applied.append(op["op_id"]) + return workflow + + +def _apply_add_node(workflow: dict, op: dict) -> None: + nodes = workflow.setdefault("nodes", []) + if any(n.get("id") == op["node_id"] for n in nodes): + return + nodes.append(copy.deepcopy(op["node"])) + workflow["last_node_id"] = max(workflow.get("last_node_id") or 0, op["node_id"]) + + +def _apply_set_widget(workflow: dict, op: dict, graph) -> None: + # Last-writer-wins: a lower-stamped concurrent write to this target is + # dropped, so the surviving value is the same in any apply order. + if not _lww_gate(workflow, op): + return + path = op.get("path") + if path: + # Subgraph interior write. A concurrently-deleted instance => no-op + # (delete wins). Otherwise descend the resolved node path (forking any + # shared definition en route so a sibling instance can't alias this + # write) and set the interior widget — the reference resolver the + # ``slots``/``set-slot`` surface uses, so the two agree by construction. + if _find_by_str(workflow, path[0]) is None: + return + from comfy_cli.cql import engine as _engine + + defs_by_id = _engine._subgraph_defs_by_id(workflow) + target = _engine._resolve_node_path(workflow, [str(s) for s in path], defs_by_id) + _engine._write_widget(target, op["inner_widget"], op["value"], graph, extend=False) + _lww_commit(workflow, op) + return + node = _find(workflow, op["node_id"]) + if node is None: + return # target concurrently deleted => no-op (delete wins). + widgets = node.setdefault("widgets_values", []) + idx = _widget_index(graph, node.get("type", ""), op["widget"], widgets) + if idx >= len(widgets): + widgets.extend([None] * (idx + 1 - len(widgets))) + widgets[idx] = op["value"] + _lww_commit(workflow, op) + + +def _apply_connect(workflow: dict, op: dict) -> None: + # Totality: either endpoint concurrently deleted => no-op (delete wins), so a + # merge consumer can replay a connect and a delete in either order without a + # crash or a dangling link. Resolve both before mutating anything. + dst = _find(workflow, op["to_node"]) + src = _find(workflow, op["from_node"]) + if dst is None or src is None: + return + grow = op.get("grow") + if grow is not None: + # Autogrow: grow a concrete slot and wire it. Keyed by ``grow_id`` (the + # link id) so replay is idempotent AND non-clobbering — a concurrent + # autogrow that minted the same requested name gets its own fresh slot + # instead of overwriting this one, so neither connection is lost. The + # slot's convergence identity is ``grow_id``; its display name stays + # sequential per the server's ``images.imageN`` convention. + ins = dst.setdefault("inputs", []) + to_idx = next((k for k, i in enumerate(ins) if i.get("grow_id") == op["link_id"]), None) + if to_idx is None: + entry = { + "name": _next_autogrow_name(ins, grow["name"]), + "type": grow["type"], + "link": None, + "grow_id": op["link_id"], + } + if grow.get("widget"): + # Mark as a converted widget (ComfyUI's widget→input); value stays + # in widgets_values for positional alignment, converter uses the link. + entry["widget"] = {"name": grow["widget"]} + ins.append(entry) + to_idx = len(ins) - 1 + else: + to_idx = op["to_slot"] + # A concrete input holds at most one link. Replacing it must fully retire + # the old link (drop the tuple + scrub the old source's out-links). + prev = dst["inputs"][to_idx].get("link") + if prev is not None and prev != op["link_id"]: + _remove_link(workflow, prev) + link = [op["link_id"], op["from_node"], op["from_slot"], op["to_node"], to_idx, op["link_type"]] + links = workflow.setdefault("links", []) + if not any(ln[0] == op["link_id"] for ln in links): + links.append(link) + dst["inputs"][to_idx]["link"] = op["link_id"] + out_links = src["outputs"][op["from_slot"]].setdefault("links", []) + if op["link_id"] not in out_links: + out_links.append(op["link_id"]) + + +def _remove_link(workflow: dict, link_id: Any) -> None: + """Drop a link tuple and scrub every input/output reference to it.""" + workflow["links"] = [ln for ln in workflow.get("links") or [] if ln[0] != link_id] + for n in workflow.get("nodes") or []: + for inp in n.get("inputs") or []: + if inp.get("link") == link_id: + inp["link"] = None + for out in n.get("outputs") or []: + if link_id in (out.get("links") or []): + out["links"] = [lid for lid in out["links"] if lid != link_id] + + +def _apply_delete_node(workflow: dict, op: dict) -> None: + node_id = op["node_id"] + workflow["nodes"] = [n for n in workflow.get("nodes") or [] if n.get("id") != node_id] + removed = set(op.get("removed_links") or []) + kept = [ln for ln in workflow.get("links") or [] if ln[0] not in removed and ln[1] != node_id and ln[3] != node_id] + workflow["links"] = kept + kept_ids = {ln[0] for ln in kept} + # Scrub dangling references so no input/output points at a gone link. + for n in workflow.get("nodes") or []: + for inp in n.get("inputs") or []: + if inp.get("link") is not None and inp["link"] not in kept_ids: + inp["link"] = None + for out in n.get("outputs") or []: + out["links"] = [lid for lid in (out.get("links") or []) if lid in kept_ids] + + +# --------------------------------------------------------------------------- +# conflict detection + canonicalization (for ask-to-merge / convergence checks) +# --------------------------------------------------------------------------- + + +def _write_target(op: dict) -> tuple: + kind = op["op"] + if kind == "set_widget": + # Subgraph writes target the resolved interior path so the flat promoted + # form (``57.text``) and the nested form (``57/27.text``) that land on the + # same interior widget share one write target (converge, not clobber). + if op.get("path"): + return ("widget", tuple(str(s) for s in op["path"]), op["inner_widget"]) + return ("widget", op["node_id"], op["widget"]) + if kind in ("add_node", "delete_node"): + return ("node", op["node_id"]) + if kind == "connect": + grow = op.get("grow") + if grow is not None: + # Two autogrow connects onto the same base share a target (their + # relative order in the batch is the sequence decision the merge + # consumer must make); distinct bases don't collide. + return ("input", op["to_node"], "grow", str(grow["name"]).split(".", 1)[0]) + return ("input", op["to_node"], op["to_slot"]) + return (kind,) + + +def detect_conflict(a: dict, b: dict) -> bool: + """True iff two ops write the same target incompatibly — the signal V0's + ask-to-merge raises instead of silently clobbering. Two autogrow connects to + the same base conflict here (their batch order is undecidable leaderlessly) + even though :func:`apply_op` keeps both connections and ``canonical`` treats + their order as immaterial.""" + if _write_target(a) != _write_target(b): + return False + if a["op"] == "set_widget" and b["op"] == "set_widget": + return a.get("value") != b.get("value") + return True + + +def _slot_identity(inp: dict) -> tuple: + """A position-independent identity for an input slot: an autogrown slot is + keyed by its ``grow_id`` (stable across apply order), a fixed slot by name.""" + if isinstance(inp, dict) and inp.get("grow_id") is not None: + return ("grow", inp["grow_id"]) + return ("name", inp.get("name") if isinstance(inp, dict) else None) + + +def canonical(workflow: dict) -> dict: + """A comparison-stable view: strip apply bookkeeping and normalize every + order-dependent-but-semantically-immaterial detail away. Two graphs that + converged are ``canonical``-equal regardless of the order ops were applied in. + + Normalizations: nodes ordered by id; links ordered by id AND their target + slot resolved from a raw list index to a position-independent identity (so a + concurrently-grown slot landing at a different index still matches); + autogrown input slots ordered by ``grow_id`` with their order-dependent + display name folded out; subgraph definitions ordered by id. + """ + w = copy.deepcopy(workflow) + w.pop("_applied_ops", None) + w.pop("_widget_stamps", None) + nodes = w.get("nodes") + # Capture each node's original index -> slot identity BEFORE reordering + # inputs, so links (which reference the raw index) can be rewritten. + slot_identity: dict[Any, dict[int, tuple]] = {} + if isinstance(nodes, list): + for n in nodes: + if not isinstance(n, dict): + continue + slot_identity[n.get("id")] = {i: _slot_identity(inp) for i, inp in enumerate(n.get("inputs") or [])} + # Reorder each node's grown slots deterministically (by grow_id) and drop + # their display name, which is order-dependent (image0 vs image1). + for n in nodes: + if not isinstance(n, dict) or not isinstance(n.get("inputs"), list): + continue + fixed = [i for i in n["inputs"] if not (isinstance(i, dict) and i.get("grow_id") is not None)] + grown = sorted( + (i for i in n["inputs"] if isinstance(i, dict) and i.get("grow_id") is not None), + key=lambda i: i["grow_id"], + ) + for i in grown: + i["name"] = "\x00grow" + n["inputs"] = fixed + grown + w["nodes"] = sorted(nodes, key=lambda n: n.get("id")) + links = w.get("links") + if isinstance(links, list): + canon = [] + for ln in links: + ln = list(ln) + if len(ln) >= 5: + ident = slot_identity.get(ln[3], {}).get(ln[4]) + if ident is not None: + ln[4] = ident + canon.append(ln) + w["links"] = sorted(canon, key=lambda ln: ln[0]) + defs = (w.get("definitions") or {}).get("subgraphs") + if isinstance(defs, list): + w["definitions"]["subgraphs"] = sorted(defs, key=lambda sg: str(sg.get("id", ""))) + return w + + +def strip_internal(workflow: dict) -> dict: + """Remove apply-only bookkeeping before serializing to disk.""" + workflow.pop("_applied_ops", None) + workflow.pop("_widget_stamps", None) + return workflow + + +# --------------------------------------------------------------------------- +# schema helpers +# --------------------------------------------------------------------------- + + +def _build_node(node_id: int, class_type: str, m, graph, pos: list | None) -> dict: + inputs = [{"name": p.name, "type": p.type, "link": None} for p in m.inputs if p.is_link] + outputs = [{"name": p.name, "type": p.type, "links": []} for p in m.outputs] + # Widget values in positional order, including dynamic-combo selectors and + # their sub-widgets — sourced from the engine so add-node matches the converter. + defaults = graph.widget_defaults(class_type) + widgets = [defaults.get(name) for name in graph.widget_order(class_type)] + return { + "id": node_id, + "type": class_type, + "pos": list(pos) if pos else [0, 0], + "size": [210, 100], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": inputs, + "outputs": outputs, + "properties": {}, + "widgets_values": widgets, + } + + +def _widget_index(graph, class_type: str, widget: str, widgets_values=None) -> int: + # Node-aware: expand a dynamic combo's sub-widgets by this node's actual + # selected key (from ``widgets_values``), not the schema's first key, so the + # index stays aligned to ``widgets_values`` for the node's real selection. + order = graph.widget_order_for_node(class_type, widgets_values) + if widget not in order: + avail = [w for w in order if w != "control_after_generate"] + raise ValueError( + f"widget {widget!r} not found on {class_type}; " + f"available: {', '.join(avail) if avail else '(none — all inputs are links)'}" + ) + return order.index(widget) + + +def _validate_widget(graph, class_type: str, widget: str, value: Any) -> list[dict]: + """Shape-validate a widget value (hard error) and collect catalog warnings + (soft — e.g. unknown COMBO option, out-of-range number).""" + m = graph.node(class_type) + if m is None: + return [] + port = next((p for p in m.inputs if p.name == widget), None) + if port is None: + return [] + err = port.validate_shape(value) + if err: + raise ValueError(err) + return port.validate_catalog(value) + + +def _resolve_output_slot(node: dict, graph, slot: Any) -> tuple[int, str]: + outs = node.get("outputs") or [] + if isinstance(slot, int) or (isinstance(slot, str) and slot.lstrip("-").isdigit()): + i = int(slot) + if not (0 <= i < len(outs)): + raise ValueError(f"output slot {i} out of range for node {node.get('id')}") + return i, outs[i].get("type", "*") + for i, o in enumerate(outs): + if o.get("name") == slot: + return i, o.get("type", "*") + names = [o.get("name") for o in outs] + raise ValueError(f"output {slot!r} not found on node {node.get('id')}; outputs: {names}") + + +def _resolve_input_slot(node: dict, graph, slot: Any) -> int: + ins = node.get("inputs") or [] + if isinstance(slot, int) or (isinstance(slot, str) and slot.lstrip("-").isdigit()): + i = int(slot) + if not (0 <= i < len(ins)): + raise ValueError(f"input slot {i} out of range for node {node.get('id')}") + return i + for i, inp in enumerate(ins): + if inp.get("name") == slot: + return i + names = [inp.get("name") for inp in ins] + raise ValueError(f"input {slot!r} not found on node {node.get('id')}; inputs: {names}") + + +def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) -> tuple[int | None, dict | None]: + """Resolve a connect target. Returns ``(index, None)`` for a concrete input, + or ``(None, grow)`` where ``grow`` is the input slot to append (autogrow slot, + or a widget converted to an input). + + - **Autogrow** (``COMFY_AUTOGROW_V3``, e.g. ``BatchImagesNode.images``) declares + one base input but the server wants one slot key per connection + (``images.image0``, …). Addressing the base or a dotted ``images.imageN`` key + grows a concrete slot minted with the source type. + - **Widget → input** (e.g. ``CreateVideo.fps``): a widget-backed input isn't a + link slot, so it's converted — a linked input carrying a ``widget`` marker is + appended. Its value stays in ``widgets_values`` (positional alignment holds); + the API converter reads the link and skips the widget by name. + """ + ins = node.get("inputs") or [] + # Concrete slot (index or exact name) that is NOT an autogrow base. + try: + idx = _resolve_input_slot(node, None, slot) + if str(ins[idx].get("type", "")).startswith("COMFY_AUTOGROW"): + base = ins[idx].get("name") + return None, _plan_autogrow(ins, base, elem_type) + return idx, None + except ValueError: + pass + # Dotted autogrow key (images.image0) or a base that has no concrete slot yet. + if isinstance(slot, str): + base = slot.split(".", 1)[0] + ag = next( + (i for i in ins if i.get("name") == base and str(i.get("type", "")).startswith("COMFY_AUTOGROW")), None + ) + if ag is not None: + requested = slot if "." in slot else None + return None, _plan_autogrow(ins, base, elem_type, requested=requested) + # Widget-backed input: convert the widget to a linked input. + if graph is not None and isinstance(slot, str) and slot in graph.widget_order(node.get("type", "")): + return None, {"name": slot, "type": elem_type or "*", "widget": slot} + names = [i.get("name") for i in ins] + raise ValueError(f"input {slot!r} not found on node {node.get('id')}; inputs: {names}") + + +def _plan_autogrow(ins: list, base: str, elem_type: str | None, requested: str | None = None) -> dict: + existing = [i for i in ins if str(i.get("name", "")).startswith(base + ".")] + if requested and not any(i.get("name") == requested for i in ins): + name = requested + else: + elem = base[:-1] if base.endswith("s") else base + name = f"{base}.{elem}{len(existing)}" + return {"name": name, "type": elem_type or "*"} diff --git a/comfy_cli/workflow_to_api.py b/comfy_cli/workflow_to_api.py index 5f094cab..5320bd5a 100644 --- a/comfy_cli/workflow_to_api.py +++ b/comfy_cli/workflow_to_api.py @@ -1065,8 +1065,19 @@ def _dynamic_combo_sub_inputs( names: list[str] = [] for section in ("required", "optional"): section_def = sub_def.get(section) or {} - if isinstance(section_def, dict): - names.extend(f"{input_name}.{sub_name}" for sub_name in section_def.keys()) + if not isinstance(section_def, dict): + continue + for sub_name, sub_spec in section_def.items(): + # Only widget sub-inputs occupy a slot in ``widgets_values``. + # Connection-only sub-inputs (IMAGE, autogrow templates, + # ``forceInput`` widgets, ...) are wired via links and carry no + # saved value, so counting them here over-reports the combo's + # span and shifts every widget after it — including a seed's + # control_after_generate marker that then survives into the + # next input (e.g. GeminiNanoBanana2V2's ``response_modalities``). + is_widget, _is_dynamic = _is_widget_input(sub_spec) + if is_widget: + names.append(f"{input_name}.{sub_name}") return names return [] @@ -1177,49 +1188,115 @@ def is_control(v: Any) -> bool: out = [] vidx = 0 input_def = _schema_input_def(schema) + # Flatten to the ordered widget inputs so the seed companion check can peek at + # the NEXT widget input (a legitimate COMBO value that equals a control + # keyword must not be mistaken for a control_after_generate marker). + widget_inputs: list[tuple[str, Any]] = [] for section in ("required", "optional"): section_def = input_def.get(section) or {} if not isinstance(section_def, dict): continue for input_name, input_spec in section_def.items(): - if vidx >= len(widget_values): - break - is_widget, _is_dynamic = _is_widget_input(input_spec) - if not is_widget: - continue - out.append(widget_values[vidx]) + if _is_widget_input(input_spec)[0]: + widget_inputs.append((input_name, input_spec)) + for i, (input_name, input_spec) in enumerate(widget_inputs): + if vidx >= len(widget_values): + break + _is_widget, is_dynamic = _is_widget_input(input_spec) + if is_dynamic: + # A V3 dynamic combo (``COMFY_*COMBO*``) occupies its selector + # slot plus a variable number of sub-input slots chosen by the + # selected option. Copy the whole span through untouched and + # advance ``vidx`` in lockstep with ``_get_widget_name_order`` + # (which expands the same sub-inputs). Otherwise the walk + # treats the combo as a single slot, reaches a later seed input + # too early, checks the wrong slot for its control_after_generate + # marker, and leaves the marker in place — shifting every widget + # after the seed by one (e.g. GeminiNanoBanana2V2 / Nano Banana 2, + # whose dynamic ``model`` precedes the seed and whose + # ``response_modalities`` sits right after it, so the stray + # ``"fixed"`` lands on ``response_modalities``). + subs = _dynamic_combo_sub_inputs(input_name, input_spec, widget_values, vidx) + span = min(1 + len(subs), len(widget_values) - vidx) + out.extend(widget_values[vidx : vidx + span]) + vidx += span + continue + out.append(widget_values[vidx]) + vidx += 1 + next_input_spec = widget_inputs[i + 1][1] if i + 1 < len(widget_inputs) else None + if vidx < len(widget_values) and _has_control_after_generate_companion( + input_name, input_spec, widget_values[vidx], next_input_spec + ): vidx += 1 - if vidx < len(widget_values) and _has_control_after_generate_companion( - input_name, input_spec, widget_values[vidx] - ): - vidx += 1 while vidx < len(widget_values): out.append(widget_values[vidx]) vidx += 1 return out -def _has_control_after_generate_companion(input_name: str, input_spec: Any, next_value: Any) -> bool: +def _combo_lists_option(input_spec: Any, value: Any) -> bool: + """True if ``input_spec`` is a COMBO that declares ``value`` as one of its options. + + Covers both the classic list-form combo (``[["a", "b", ...]]`` — the type IS + the option list) and the dict-form combo (``["COMBO", {"options": [...]}]``).""" + if not isinstance(input_spec, (list, tuple)) or not input_spec: + return False + type_field = input_spec[0] + if isinstance(type_field, list): + return value in type_field + if len(input_spec) >= 2 and isinstance(input_spec[1], dict): + opts = input_spec[1].get("options") + if isinstance(opts, list): + return value in opts + return False + + +def _has_control_after_generate_companion( + input_name: str, input_spec: Any, next_value: Any, next_input_spec: Any = None +) -> bool: """True if ``next_value`` should be consumed as a control_after_generate marker. Two ways the frontend adds the companion widget: * Explicit: the input spec sets ``control_after_generate: True``. - * Implicit: the input is named ``seed`` or ``noise_seed`` and is INT-typed. - The frontend's ``useIntWidget`` composable adds the companion in that case - regardless of the schema flag. - - For the implicit path we peek at the next value: older workflows saved - before the companion existed don't have the marker string, so we must - verify the slot really is a control keyword before consuming it. + * Implicit: a seed-like INT widget. The frontend's ``useIntWidget`` + composable appends the companion after seed-like INT inputs even when + the schema omits the flag. + + The implicit path is *value-gated and node-agnostic*: we only consume the + next slot when it is literally one of the control keywords + (``"fixed"``/``"increment"``/``"decrement"``/``"randomize"``). That string + is only ever present when the frontend really did append the companion, so + it is a reliable signal regardless of schema flags or the exact input name. + + We still require the input to be a seed-like INT (name contains ``seed``, + case-insensitive) rather than *any* INT. Partner/API nodes name the widget + every which way -- ``seed``/``noise_seed`` (Bria/Kling/Vidu/Wan2), + ``image_seed``/``model_seed``/``texture_seed`` (Tripo), ``Seed`` (Rodin3D), + ``rand_seed``, ``noise_seed_sde``, ``variation_seed`` -- and several ship + the input *unflagged*, so the old exact ``seed``/``noise_seed`` match let + their companion survive and shifted every later widget by one. Keeping the + ``seed`` substring guard preserves the schema-aware path's protection + against a legitimate non-seed INT (e.g. ``steps``) that merely happens to + precede a COMBO/STRING widget whose value equals a control keyword. + + ``next_input_spec`` is the schema of the *next* widget input (when known). On + the implicit seed path we refuse to consume ``next_value`` when that next + widget is a COMBO that legitimately lists ``next_value`` as an option — there + the value is the combo's own saved selection, not a phantom companion, so + consuming it would drop a real widget value and shift every later widget. """ + if not (isinstance(next_value, str) and next_value in _CONTROL_AFTER_GENERATE_VALUES): + return False options = input_spec[1] if len(input_spec) >= 2 and isinstance(input_spec[1], dict) else {} if options.get("control_after_generate"): - return isinstance(next_value, str) and next_value in _CONTROL_AFTER_GENERATE_VALUES + return True input_type = input_spec[0] if input_spec else None - if input_type == "INT" and input_name in ("seed", "noise_seed"): - return isinstance(next_value, str) and next_value in _CONTROL_AFTER_GENERATE_VALUES - return False + if not (input_type == "INT" and "seed" in input_name.lower()): + return False + # Implicit seed path: don't steal a value that the next COMBO widget declares + # as one of its own options. + return not _combo_lists_option(next_input_spec, next_value) def _collect_widget_inputs( diff --git a/comfy_cli/workspace_manager.py b/comfy_cli/workspace_manager.py index 85809e55..954bd5e4 100644 --- a/comfy_cli/workspace_manager.py +++ b/comfy_cli/workspace_manager.py @@ -3,7 +3,6 @@ from datetime import datetime from enum import Enum -import git import typer import yaml @@ -86,6 +85,10 @@ def check_comfy_repo(path) -> tuple[bool, str | None]: """ if not os.path.exists(path): return False, None + # Imported lazily: GitPython costs ~90ms to import and is only needed on + # this detection path, not for every CLI invocation. + import git + try: repo = git.Repo(path, search_parent_directories=True) path_is_comfy_repo = any(remote.url in constants.COMFY_ORIGIN_URL_CHOICES for remote in repo.remotes) diff --git a/pyproject.toml b/pyproject.toml index f91d096e..dd76e6e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,10 @@ dependencies = [ "uv>=0.11.15", "websocket-client", + # Bundled static ffmpeg so `comfy preview` renders video/audio previews on + # machines without a system ffmpeg (the CLI-as-agent path). ffprobe metadata + # still needs system ffprobe; preview degrades to extension-based classification. + "imageio-ffmpeg", ] # `bench` — the BE-2302 arm-B micro-edit benchmark runner (comfy_cli/bench). Only a diff --git a/tests/comfy_cli/command/generate/fixtures/partner_nodes_object_info.json b/tests/comfy_cli/command/generate/fixtures/partner_nodes_object_info.json new file mode 100644 index 00000000..710069e3 --- /dev/null +++ b/tests/comfy_cli/command/generate/fixtures/partner_nodes_object_info.json @@ -0,0 +1,674 @@ +{ + "ByteDanceImageToVideoNode": { + "api_node": true, + "category": "partner/video/ByteDance", + "deprecated": false, + "description": "Generate video using ByteDance models via api based on image and prompt", + "dev_only": false, + "display_name": "ByteDance Image to Video", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "optional": { + "camera_fixed": [ + "BOOLEAN", + { + "advanced": true, + "default": false, + "tooltip": "Specifies whether to fix the camera. The platform appends an instruction to fix the camera to your prompt, but does not guarantee the actual effect." + } + ], + "generate_audio": [ + "BOOLEAN", + { + "advanced": true, + "default": false, + "tooltip": "This parameter is ignored for any model except seedance-1-5-pro." + } + ], + "seed": [ + "INT", + { + "control_after_generate": true, + "default": 0, + "display": "number", + "max": 2147483647, + "min": 0, + "step": 1, + "tooltip": "Seed to use for generation." + } + ], + "watermark": [ + "BOOLEAN", + { + "advanced": true, + "default": false, + "tooltip": "Whether to add an \"AI generated\" watermark to the video." + } + ] + }, + "required": { + "aspect_ratio": [ + "COMBO", + { + "multiselect": false, + "options": [ + "adaptive", + "16:9", + "4:3", + "1:1", + "3:4", + "9:16", + "21:9" + ], + "tooltip": "The aspect ratio of the output video." + } + ], + "duration": [ + "INT", + { + "default": 5, + "display": "slider", + "max": 12, + "min": 3, + "step": 1, + "tooltip": "The duration of the output video in seconds." + } + ], + "image": [ + "IMAGE", + { + "tooltip": "First frame to be used for the video." + } + ], + "model": [ + "COMBO", + { + "default": "seedance-1-0-pro-fast-251015", + "multiselect": false, + "options": [ + "seedance-1-5-pro-251215", + "seedance-1-0-pro-250528", + "seedance-1-0-lite-i2v-250428", + "seedance-1-0-pro-fast-251015" + ] + } + ], + "prompt": [ + "STRING", + { + "multiline": true, + "tooltip": "The text prompt used to generate the video." + } + ], + "resolution": [ + "COMBO", + { + "multiselect": false, + "options": [ + "480p", + "720p", + "1080p" + ], + "tooltip": "The resolution of the output video." + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "optional": [ + "seed", + "camera_fixed", + "watermark", + "generate_audio" + ], + "required": [ + "model", + "prompt", + "image", + "resolution", + "aspect_ratio", + "duration" + ] + }, + "is_input_list": false, + "name": "ByteDanceImageToVideoNode", + "output": [ + "VIDEO" + ], + "output_is_list": [ + false + ], + "output_matchtypes": null, + "output_name": [ + "VIDEO" + ], + "output_node": false, + "output_tooltips": [ + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [], + "widgets": [ + { + "name": "model", + "type": "COMBO" + }, + { + "name": "duration", + "type": "INT" + }, + { + "name": "resolution", + "type": "COMBO" + }, + { + "name": "generate_audio", + "type": "BOOLEAN" + } + ] + }, + "engine": "jsonata", + "expr": "\n (\n $priceByModel := {\n \"seedance-1-5-pro\": {\n \"480p\":[0.12,0.12],\n \"720p\":[0.26,0.26],\n \"1080p\":[0.58,0.59]\n },\n \"seedance-1-0-pro\": {\n \"480p\":[0.23,0.24],\n \"720p\":[0.51,0.56],\n \"1080p\":[1.18,1.22]\n },\n \"seedance-1-0-pro-fast\": {\n \"480p\":[0.09,0.1],\n \"720p\":[0.21,0.23],\n \"1080p\":[0.47,0.49]\n },\n \"seedance-1-0-lite\": {\n \"480p\":[0.17,0.18],\n \"720p\":[0.37,0.41],\n \"1080p\":[0.85,0.88]\n }\n };\n $model := widgets.model;\n $modelKey :=\n $contains($model, \"seedance-1-5-pro\") ? \"seedance-1-5-pro\" :\n $contains($model, \"seedance-1-0-pro-fast\") ? \"seedance-1-0-pro-fast\" :\n $contains($model, \"seedance-1-0-pro\") ? \"seedance-1-0-pro\" :\n \"seedance-1-0-lite\";\n $resolution := widgets.resolution;\n $resKey :=\n $contains($resolution, \"1080\") ? \"1080p\" :\n $contains($resolution, \"720\") ? \"720p\" :\n \"480p\";\n $modelPrices := $lookup($priceByModel, $modelKey);\n $baseRange := $lookup($modelPrices, $resKey);\n $min10s := $baseRange[0];\n $max10s := $baseRange[1];\n $scale := widgets.duration / 10;\n $audioMultiplier := ($modelKey = \"seedance-1-5-pro\" and widgets.generate_audio) ? 2 : 1;\n $minCost := $min10s * $scale * $audioMultiplier;\n $maxCost := $max10s * $scale * $audioMultiplier;\n ($minCost = $maxCost)\n ? {\"type\":\"usd\",\"usd\": $minCost, \"format\": { \"approximate\": true }}\n : {\"type\":\"range_usd\",\"min_usd\": $minCost, \"max_usd\": $maxCost, \"format\": { \"approximate\": true }}\n )\n " + }, + "python_module": "comfy_api_nodes.nodes_bytedance", + "search_aliases": null + }, + "Flux2ProImageNode": { + "api_node": true, + "category": "partner/image/BFL", + "deprecated": true, + "description": "Generates images synchronously based on prompt and resolution.", + "dev_only": false, + "display_name": "Flux.2 [pro] Image", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "optional": { + "images": [ + "IMAGE", + { + "tooltip": "Up to 9 images to be used as references." + } + ] + }, + "required": { + "height": [ + "INT", + { + "default": 768, + "max": 2048, + "min": 256, + "step": 32 + } + ], + "prompt": [ + "STRING", + { + "default": "", + "multiline": true, + "tooltip": "Prompt for the image generation or edit" + } + ], + "prompt_upsampling": [ + "BOOLEAN", + { + "advanced": true, + "default": true, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation." + } + ], + "seed": [ + "INT", + { + "control_after_generate": true, + "default": 0, + "max": 18446744073709551615, + "min": 0, + "tooltip": "The random seed used for creating the noise." + } + ], + "width": [ + "INT", + { + "default": 1024, + "max": 2048, + "min": 256, + "step": 32 + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "optional": [ + "images" + ], + "required": [ + "prompt", + "width", + "height", + "seed", + "prompt_upsampling" + ] + }, + "is_input_list": false, + "name": "Flux2ProImageNode", + "output": [ + "IMAGE" + ], + "output_is_list": [ + false + ], + "output_matchtypes": null, + "output_name": [ + "IMAGE" + ], + "output_node": false, + "output_tooltips": [ + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [ + "images" + ], + "widgets": [ + { + "name": "width", + "type": "INT" + }, + { + "name": "height", + "type": "INT" + } + ] + }, + "engine": "jsonata", + "expr": "\n (\n $MP := 1024 * 1024;\n $outMP := $max([1, $floor(((widgets.width * widgets.height) + $MP - 1) / $MP)]);\n $outputCost := 0.03 + 0.015 * ($outMP - 1);\n inputs.images.connected\n ? {\n \"type\":\"range_usd\",\n \"min_usd\": $outputCost + 0.015,\n \"max_usd\": $outputCost + 0.12,\n \"format\": { \"approximate\": true }\n }\n : {\"type\":\"usd\",\"usd\": $outputCost}\n )\n " + }, + "python_module": "comfy_api_nodes.nodes_bfl", + "search_aliases": null + }, + "GeminiImageNode": { + "api_node": true, + "category": "partner/image/Gemini", + "deprecated": false, + "description": "Edit images synchronously via Google API.", + "dev_only": false, + "display_name": "Nano Banana (Google Gemini Image)", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "optional": { + "aspect_ratio": [ + "COMBO", + { + "default": "auto", + "multiselect": false, + "options": [ + "auto", + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9" + ], + "tooltip": "Defaults to matching the output image size to that of your input image, or otherwise generates 1:1 squares." + } + ], + "files": [ + "GEMINI_INPUT_FILES", + { + "tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the Gemini Generate Content Input Files node." + } + ], + "images": [ + "IMAGE", + { + "tooltip": "Optional image(s) to use as context for the model. To include multiple images, you can use the Batch Images node." + } + ], + "response_modalities": [ + "COMBO", + { + "advanced": true, + "multiselect": false, + "options": [ + "IMAGE+TEXT", + "IMAGE" + ], + "tooltip": "Choose 'IMAGE' for image-only output, or 'IMAGE+TEXT' to return both the generated image and a text response." + } + ], + "system_prompt": [ + "STRING", + { + "advanced": true, + "default": "You are an expert image-generation engine. You must ALWAYS produce an image.\nInterpret all user input\u2014regardless of format, intent, or abstraction\u2014as literal visual directives for image composition.\nIf a prompt is conversational or lacks specific visual details, you must creatively invent a concrete visual scenario that depicts the concept.\nPrioritize generating the visual representation above any text, formatting, or conversational requests.", + "multiline": true, + "tooltip": "Foundational instructions that dictate an AI's behavior." + } + ] + }, + "required": { + "model": [ + "COMBO", + { + "multiselect": false, + "options": [ + "gemini-2.5-flash-image" + ], + "tooltip": "The Gemini model to use for generating responses." + } + ], + "prompt": [ + "STRING", + { + "default": "", + "multiline": true, + "tooltip": "Text prompt for generation" + } + ], + "seed": [ + "INT", + { + "control_after_generate": true, + "default": 42, + "max": 18446744073709551615, + "min": 0, + "tooltip": "When seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used." + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "optional": [ + "images", + "files", + "aspect_ratio", + "response_modalities", + "system_prompt" + ], + "required": [ + "prompt", + "model", + "seed" + ] + }, + "is_input_list": false, + "name": "GeminiImageNode", + "output": [ + "IMAGE", + "STRING" + ], + "output_is_list": [ + false, + false + ], + "output_matchtypes": null, + "output_name": [ + "IMAGE", + "STRING" + ], + "output_node": false, + "output_tooltips": [ + null, + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [], + "widgets": [] + }, + "engine": "jsonata", + "expr": "{\"type\":\"usd\",\"usd\":0.039,\"format\":{\"suffix\":\"/Image (1K)\",\"approximate\":true}}" + }, + "python_module": "comfy_api_nodes.nodes_gemini", + "search_aliases": null + }, + "KlingImage2VideoNode": { + "api_node": true, + "category": "partner/video/Kling", + "deprecated": false, + "description": "", + "dev_only": false, + "display_name": "Kling Image(First Frame) to Video", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "required": { + "aspect_ratio": [ + "COMBO", + { + "default": "16:9", + "multiselect": false, + "options": [ + "16:9", + "9:16", + "1:1" + ] + } + ], + "cfg_scale": [ + "FLOAT", + { + "default": 0.8, + "max": 1.0, + "min": 0.0 + } + ], + "duration": [ + "COMBO", + { + "default": "5", + "multiselect": false, + "options": [ + "5", + "10" + ] + } + ], + "mode": [ + "COMBO", + { + "default": "std", + "multiselect": false, + "options": [ + "std", + "pro" + ] + } + ], + "model_name": [ + "COMBO", + { + "default": "kling-v2-master", + "multiselect": false, + "options": [ + "kling-v1", + "kling-v1-5", + "kling-v1-6", + "kling-v2-master", + "kling-v2-1", + "kling-v2-1-master", + "kling-v2-5-turbo" + ] + } + ], + "negative_prompt": [ + "STRING", + { + "multiline": true, + "tooltip": "Negative text prompt" + } + ], + "prompt": [ + "STRING", + { + "multiline": true, + "tooltip": "Positive text prompt" + } + ], + "start_frame": [ + "IMAGE", + { + "tooltip": "The reference image used to generate the video." + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "required": [ + "start_frame", + "prompt", + "negative_prompt", + "model_name", + "cfg_scale", + "mode", + "aspect_ratio", + "duration" + ] + }, + "is_input_list": false, + "name": "KlingImage2VideoNode", + "output": [ + "VIDEO", + "STRING", + "STRING" + ], + "output_is_list": [ + false, + false, + false + ], + "output_matchtypes": null, + "output_name": [ + "VIDEO", + "video_id", + "duration" + ], + "output_node": false, + "output_tooltips": [ + null, + null, + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [], + "widgets": [ + { + "name": "mode", + "type": "COMBO" + }, + { + "name": "model_name", + "type": "COMBO" + }, + { + "name": "duration", + "type": "COMBO" + } + ] + }, + "engine": "jsonata", + "expr": "\n (\n $mode := widgets.mode;\n $model := widgets.model_name;\n $dur := widgets.duration;\n $contains($model,\"v2-5-turbo\")\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.7} : {\"type\":\"usd\",\"usd\":0.35})\n : ($contains($model,\"v2-1-master\") or $contains($model,\"v2-master\"))\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":2.8} : {\"type\":\"usd\",\"usd\":1.4})\n : ($contains($model,\"v2-1\") or $contains($model,\"v1-6\") or $contains($model,\"v1-5\"))\n ? (\n $contains($mode,\"pro\")\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.98} : {\"type\":\"usd\",\"usd\":0.49})\n : ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.56} : {\"type\":\"usd\",\"usd\":0.28})\n )\n : $contains($model,\"v1\")\n ? (\n $contains($mode,\"pro\")\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.98} : {\"type\":\"usd\",\"usd\":0.49})\n : ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.28} : {\"type\":\"usd\",\"usd\":0.14})\n )\n : {\"type\":\"usd\",\"usd\":0.14}\n )\n " + }, + "python_module": "comfy_api_nodes.nodes_kling", + "search_aliases": null + } +} diff --git a/tests/comfy_cli/command/generate/test_emit.py b/tests/comfy_cli/command/generate/test_emit.py index 1d060d6c..003e00e4 100644 --- a/tests/comfy_cli/command/generate/test_emit.py +++ b/tests/comfy_cli/command/generate/test_emit.py @@ -4,12 +4,22 @@ """ import json +from pathlib import Path import pytest from typer.testing import CliRunner from comfy_cli.cmdline import app as cli_app from comfy_cli.command.generate import emit +from comfy_cli.cql.engine import Graph + +# Recorded object_info for the partner nodes MODEL_NODE_MAP targets, snapshotted +# from the cloud catalog. Used to enforce NodeSpec's completeness contract: the +# emitted node must carry EVERY widget input, optional section included (a +# schema-`optional` input may still be positionally required by execute()). +PARTNER_OBJECT_INFO = json.loads( + (Path(__file__).parent / "fixtures" / "partner_nodes_object_info.json").read_text(encoding="utf-8") +) @pytest.fixture(autouse=True) @@ -67,6 +77,66 @@ def test_build_kling_i2v_class_and_start_frame(): assert wf["1"]["inputs"]["start_frame"] == [loader_id, 0] +def test_build_seedance_fills_execute_required_defaults(): + """Regression: ByteDanceImageToVideoNode declares seed/camera_fixed/watermark + `optional=True` in its schema but its execute() takes them WITHOUT Python + defaults — omitting them validates cleanly and then fails the run with + "missing 3 required positional arguments" (observed live on cloud). The + emitter must always write them.""" + wf = emit.build_workflow( + "seedance", + {"prompt": "drift", "image": "frame.png", "model": "seedance-1-0-lite-i2v-250428"}, + ) + inputs = wf["1"]["inputs"] + assert inputs["seed"] == 0 + assert inputs["camera_fixed"] is False + assert inputs["watermark"] is False + assert inputs["generate_audio"] is False + + +def test_build_seedance_proxy_flag_spellings_reach_node_inputs(): + """The generate proxy flags are --ratio/--camerafixed; the node inputs are + aspect_ratio/camera_fixed. User-passed values must not be dropped.""" + wf = emit.build_workflow( + "seedance", + {"prompt": "drift", "image": "frame.png", "ratio": "9:16", "camerafixed": True, "watermark": True}, + ) + inputs = wf["1"]["inputs"] + assert inputs["aspect_ratio"] == "9:16" + assert inputs["camera_fixed"] is True + assert inputs["watermark"] is True + + +@pytest.mark.parametrize("model", sorted(emit.MODEL_NODE_MAP)) +def test_emitted_node_covers_every_widget_input(model): + """Completeness contract (see NodeSpec docstring): for every model the + emitter supports, the emitted partner node must contain ALL of the node's + widget (non-link) inputs — required AND optional — plus every required link + input. Schema-`optional` does not imply optional-at-execute for V3 nodes, + so any absent widget input is a potential run-time crash.""" + ns = emit.MODEL_NODE_MAP[model] + graph = Graph.from_object_info(PARTNER_OBJECT_INFO) + meta = graph.node(ns.node_class) + assert meta is not None, f"{ns.node_class} missing from the fixture snapshot — refresh it" + + values = {"prompt": "p"} + if ns.image_params: + values[next(iter(ns.image_params))] = "img.png" + wf = emit.build_workflow(model, values) + inputs = wf["1"]["inputs"] + + for port in meta.inputs: + if port.is_link: + if port.required: + assert port.name in inputs, f"{model}: required link input {port.name!r} not wired" + continue + assert port.name in inputs, ( + f"{model}: widget input {port.name!r} missing from the emitted node — " + f"schema-optional inputs may still be positionally required at execute() " + f"time; add a default to MODEL_NODE_MAP[{model!r}].fixed" + ) + + def test_unknown_model_lists_supported(): with pytest.raises(emit.EmitError) as ei: emit.build_workflow("dalle", {"prompt": "x"}) diff --git a/tests/comfy_cli/command/github/test_pr.py b/tests/comfy_cli/command/github/test_pr.py index 3b7e93e4..6c129044 100644 --- a/tests/comfy_cli/command/github/test_pr.py +++ b/tests/comfy_cli/command/github/test_pr.py @@ -914,7 +914,7 @@ def test_specific_version_with_v_prefix_passes_through(self, mock_local, mock_ap mock_api.assert_not_called() mock_co.assert_called_once_with("/repo", "v0.20.1") - @patch("comfy_cli.command.install.requests.get") + @patch("requests.get") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) def test_latest_with_rate_limited_api_when_no_local_tags(self, mock_co, mock_get, tmp_path): """End-to-end repro of issue #440: empty local clone + 60/hr exhausted IP. @@ -936,7 +936,7 @@ def test_latest_with_rate_limited_api_when_no_local_tags(self, mock_co, mock_get mock_co.assert_not_called() - @patch("comfy_cli.command.install.requests.get") + @patch("requests.get") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) def test_latest_with_local_tags_no_network_at_all(self, mock_co, mock_get, tmp_path): """The pre-fix repro of issue #440: with local tags present, no @@ -998,7 +998,7 @@ def crash_on_api(*args, **kwargs): with ( patch.dict("os.environ", {}, clear=True), - patch("comfy_cli.command.install.requests.get", side_effect=crash_on_api), + patch("requests.get", side_effect=crash_on_api), patch("comfy_cli.command.install.clone_comfyui") as mock_clone, patch("comfy_cli.command.install.ensure_workspace_python", return_value=sys.executable), patch("comfy_cli.command.install.pip_install_comfyui_dependencies"), @@ -1042,7 +1042,7 @@ def test_full_execute_with_specific_version_no_api_no_resolver(self, tmp_path): with ( patch.dict("os.environ", {}, clear=True), patch( - "comfy_cli.command.install.requests.get", + "requests.get", side_effect=AssertionError("API must not be called for specific versions"), ), patch( diff --git a/tests/comfy_cli/command/test_code_search.py b/tests/comfy_cli/command/test_code_search.py index 56d628a4..63e0fcad 100644 --- a/tests/comfy_cli/command/test_code_search.py +++ b/tests/comfy_cli/command/test_code_search.py @@ -258,7 +258,7 @@ def test_limit_hit(self, limit_hit_search): class TestFetchResults: - @patch("comfy_cli.command.code_search.requests.get") + @patch("requests.get") def test_successful_fetch(self, mock_get, raw_api_response): mock_response = MagicMock() mock_response.json.return_value = raw_api_response @@ -270,7 +270,7 @@ def test_successful_fetch(self, mock_get, raw_api_response): mock_get.assert_called_once_with(API_URL, params={"query": "LoadImage"}, timeout=REQUEST_TIMEOUT) assert result == raw_api_response - @patch("comfy_cli.command.code_search.requests.get") + @patch("requests.get") def test_http_error_propagates(self, mock_get): mock_response = MagicMock() mock_response.raise_for_status.side_effect = requests.HTTPError(response=MagicMock(status_code=500)) @@ -279,14 +279,14 @@ def test_http_error_propagates(self, mock_get): with pytest.raises(requests.HTTPError): _fetch_results("LoadImage") - @patch("comfy_cli.command.code_search.requests.get") + @patch("requests.get") def test_timeout_propagates(self, mock_get): mock_get.side_effect = requests.Timeout("timed out") with pytest.raises(requests.Timeout): _fetch_results("LoadImage") - @patch("comfy_cli.command.code_search.requests.get") + @patch("requests.get") def test_connection_error_propagates(self, mock_get): mock_get.side_effect = requests.ConnectionError("no connection") diff --git a/tests/comfy_cli/command/test_preview.py b/tests/comfy_cli/command/test_preview.py index 303711aa..74756065 100644 --- a/tests/comfy_cli/command/test_preview.py +++ b/tests/comfy_cli/command/test_preview.py @@ -109,3 +109,19 @@ def test_preview_missing_file_errors(tmp_path, monkeypatch): monkeypatch.setattr("comfy_cli.tracking.track_event", lambda *a, **kw: None) with pytest.raises(typer.Exit): preview_cmd(tmp_path / "nope.png") + + +def test_classify_by_ext_unknown_for_non_media(): + """The no-ffprobe fallback must return 'unknown' for a non-media extension so + preview_cmd emits preview_unsupported_media instead of handing a .txt to ffmpeg. + Regression: it previously defaulted every unrecognized extension to 'video'.""" + from pathlib import Path + + from comfy_cli.command.preview import _classify_by_ext + + assert _classify_by_ext(Path("notes.txt"))["kind"] == "unknown" + assert _classify_by_ext(Path("archive.zip"))["kind"] == "unknown" + assert _classify_by_ext(Path("clip.mp4"))["kind"] == "video" + assert _classify_by_ext(Path("clip.mov"))["kind"] == "video" + assert _classify_by_ext(Path("pic.png"))["kind"] == "image" + assert _classify_by_ext(Path("sound.wav"))["kind"] == "audio" diff --git a/tests/comfy_cli/command/test_run.py b/tests/comfy_cli/command/test_run.py index d1729aa1..b42e6f70 100644 --- a/tests/comfy_cli/command/test_run.py +++ b/tests/comfy_cli/command/test_run.py @@ -1059,6 +1059,39 @@ def test_ui_workflow_converts_and_submits(self, ui_workflow_file, fake_target): submitted_args, _ = mock_client.submit_prompt.call_args assert submitted_args[0] == self.CONVERTED + def test_ui_workflow_conversion_honors_object_info_file_env( + self, ui_workflow_file, fake_target, tmp_path, monkeypatch + ): + """Both cloud object_info loads on this path (UI→API conversion, then + preflight-validate) are routed through resilient_load_object_info, so + COMFY_OBJECT_INFO_FILE — a pre-warmed/baked catalog an agent host + provides — must be read with NO live /object_info fetch at all.""" + from comfy_cli.comfy_client import SubmitResult + from comfy_cli.command.run import execute_cloud + + dump_path = tmp_path / "object_info.json" + dump_path.write_text(json.dumps({"KSampler": {}})) + monkeypatch.setenv("COMFY_OBJECT_INFO_FILE", str(dump_path)) + + mock_client = MagicMock() + mock_client.submit_prompt.return_value = SubmitResult(prompt_id="prompt-env", number=1, node_errors={}) + + def _network_fetch_should_not_run(**_kwargs): + raise AssertionError("network object_info fetch should not run with COMFY_OBJECT_INFO_FILE set") + + with ( + patch("comfy_cli.target.resolve_target", return_value=fake_target), + patch("comfy_cli.command.run.convert_ui_to_api", return_value=self.CONVERTED) as mock_convert, + patch("comfy_cli.cql.engine._load_from_target", side_effect=_network_fetch_should_not_run), + patch("comfy_cli.comfy_client.Client", return_value=mock_client), + patch("comfy_cli.command.run._spawn_watcher"), + ): + execute_cloud(ui_workflow_file, wait=False) + + assert mock_convert.called + submitted_args, _ = mock_client.submit_prompt.call_args + assert submitted_args[0] == self.CONVERTED + def test_ui_workflow_conversion_failure_surfaces_conversion_error(self, ui_workflow_file, fake_target): from comfy_cli.command.run import execute_cloud from comfy_cli.workflow_to_api import WorkflowConversionError diff --git a/tests/comfy_cli/command/test_run_watcher.py b/tests/comfy_cli/command/test_run_watcher.py new file mode 100644 index 00000000..30504cd7 --- /dev/null +++ b/tests/comfy_cli/command/test_run_watcher.py @@ -0,0 +1,67 @@ +"""``COMFY_NO_WATCH`` — the env kill switch that suppresses the detached +watcher subprocess for agentic callers. + +The non-wait run path (both local and cloud) spawns a detached, credential- +inheriting watcher via ``subprocess.Popen(..., start_new_session=True)`` that +survives the parent process and polls the jobs API for up to 6h. Agents that +already have their own job-wait loop (e.g. the cloud agent's Redis pub/sub + +reconcile GET) have no use for it — it's a pure-waste orphan process holding +onto COMFY_CLOUD_AUTH_TOKEN / COMFY_CLOUD_API_KEY after the parent exits. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from comfy_cli.command.run.watcher import _no_watch_requested, _spawn_watcher + + +class TestNoWatchRequested: + def test_unset_is_false(self, monkeypatch): + monkeypatch.delenv("COMFY_NO_WATCH", raising=False) + assert _no_watch_requested() is False + + def test_one_is_true(self, monkeypatch): + monkeypatch.setenv("COMFY_NO_WATCH", "1") + assert _no_watch_requested() is True + + def test_false_like_values_are_false(self, monkeypatch): + for v in ("0", "false", "False", "no", "off", ""): + monkeypatch.setenv("COMFY_NO_WATCH", v) + assert _no_watch_requested() is False, f"{v!r} should not suppress the watcher" + + def test_other_truthy_values_are_true(self, monkeypatch): + for v in ("true", "TRUE", "yes", "1", "on"): + monkeypatch.setenv("COMFY_NO_WATCH", v) + assert _no_watch_requested() is True, f"{v!r} should suppress the watcher" + + +class TestSpawnWatcherHonorsKillSwitch: + def test_no_watch_env_suppresses_spawn(self, monkeypatch): + monkeypatch.setenv("COMFY_NO_WATCH", "1") + with patch("comfy_cli.command.run.watcher.subprocess.Popen") as mock_popen: + result = _spawn_watcher("prompt-123", where="cloud", notify=False) + + mock_popen.assert_not_called() + assert result is False + + def test_without_env_spawn_still_happens(self, monkeypatch): + # Control: with the kill switch unset, the existing spawn behavior is + # unchanged — Popen IS called. + monkeypatch.delenv("COMFY_NO_WATCH", raising=False) + with patch("comfy_cli.command.run.watcher.subprocess.Popen") as mock_popen: + result = _spawn_watcher("prompt-123", where="cloud", notify=False) + + mock_popen.assert_called_once() + assert result is True + + def test_no_watch_env_suppresses_local_spawn_too(self, monkeypatch): + # The same env check gates the local (non-cloud) watcher spawn site + # in run/__init__.py's execute(), since both route through + # _spawn_watcher. + monkeypatch.setenv("COMFY_NO_WATCH", "1") + with patch("comfy_cli.command.run.watcher.subprocess.Popen") as mock_popen: + result = _spawn_watcher("prompt-456", where="local", host="127.0.0.1", port=8188, notify=True) + + mock_popen.assert_not_called() + assert result is False diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py new file mode 100644 index 00000000..014774e6 --- /dev/null +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -0,0 +1,1520 @@ +"""Scenarios for `comfy workflow add-node/connect/set-widget/delete` — the +CRDT-ready structured-edit primitives. + +These are the observation layer for the red→green loop. Each command must: + * mutate a frontend-format workflow file (or --stdout), and + * emit a structured, CRDT-mergeable op in the envelope's `data.op`. + +The op-model correctness properties (fidelity / idempotency / convergence / +conflict-detection / name-safety / api-validity) are exercised directly against +`comfy_cli.workflow_ops`, which the commands wrap. +""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner + +from comfy_cli import workflow_ops +from comfy_cli.caller import Caller +from comfy_cli.command import workflow as workflow_cmd +from comfy_cli.command import workflow_edit +from comfy_cli.cql.engine import Graph +from comfy_cli.output.renderer import ( + OutputMode, + Renderer, + reset_renderer_for_testing, + set_renderer, +) + + +@pytest.fixture(autouse=True) +def reset_singleton(): + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +def _force_json_renderer(): + r = Renderer.resolve( + is_stdout_tty=False, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + json_flag=True, + ) + r.mode = OutputMode.JSON + set_renderer(r) + return r + + +def _object_info() -> dict[str, Any]: + return { + "CheckpointLoaderSimple": { + "input": {"required": {"ckpt_name": [["a.safetensors", "b.safetensors"]]}}, + "input_order": {"required": ["ckpt_name"]}, + "output": ["MODEL", "CLIP", "VAE"], + "output_name": ["MODEL", "CLIP", "VAE"], + "category": "loaders", + "display_name": "Load Checkpoint", + "python_module": "nodes", + }, + "CLIPTextEncode": { + "input": {"required": {"text": ["STRING", {"multiline": True}], "clip": "CLIP"}}, + "input_order": {"required": ["clip", "text"]}, + "output": ["CONDITIONING"], + "output_name": ["CONDITIONING"], + "category": "conditioning", + "display_name": "CLIP Text Encode", + "python_module": "nodes", + }, + "KSampler": { + "input": { + "required": { + "model": "MODEL", + "positive": "CONDITIONING", + "negative": "CONDITIONING", + "latent_image": "LATENT", + "seed": ["INT", {"default": 0, "min": 0, "max": 2**32, "control_after_generate": True}], + "steps": ["INT", {"default": 20, "min": 1, "max": 10000}], + "cfg": ["FLOAT", {"default": 8.0}], + "sampler_name": [["euler", "euler_ancestral"]], + "scheduler": [["normal", "karras"]], + "denoise": ["FLOAT", {"default": 1.0}], + }, + }, + "input_order": { + "required": [ + "model", + "positive", + "negative", + "latent_image", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + ] + }, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "sampling", + "display_name": "KSampler", + "python_module": "nodes", + }, + "EmptyLatentImage": { + "input": { + "required": { + "width": ["INT", {"default": 512}], + "height": ["INT", {"default": 512}], + "batch_size": ["INT", {"default": 1}], + } + }, + "input_order": {"required": ["width", "height", "batch_size"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "latent", + "display_name": "Empty Latent Image", + "python_module": "nodes", + }, + "VAEDecode": { + "input": {"required": {"samples": "LATENT", "vae": "VAE"}}, + "input_order": {"required": ["samples", "vae"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "latent", + "display_name": "VAE Decode", + "python_module": "nodes", + }, + "KlingFLFTest": { + "input": { + "required": { + "first_frame": "IMAGE", + "last_frame": "IMAGE", + "prompt": ["STRING", {"default": ""}], + "model": [ + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + { + "key": "kling-v3", + "inputs": { + "required": { + "resolution": [ + "COMBO", + {"default": "1080p", "options": ["4k", "1080p", "720p"]}, + ] + } + }, + } + ] + }, + ], + } + }, + "input_order": {"required": ["first_frame", "last_frame", "prompt", "model"]}, + "output": ["VIDEO"], + "output_name": ["VIDEO"], + "category": "video", + "display_name": "Kling FLF (test)", + "python_module": "nodes", + }, + "PrimitiveFloat": { + "input": {"required": {"value": ["FLOAT", {"default": 1.0}]}}, + "input_order": {"required": ["value"]}, + "output": ["FLOAT"], + "output_name": ["FLOAT"], + "category": "primitive", + "display_name": "Float", + "python_module": "nodes", + }, + "BatchImagesNode": { + "input": {"required": {"images": "COMFY_AUTOGROW_V3"}}, + "input_order": {"required": ["images"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "image/batch", + "display_name": "Batch Images", + "python_module": "nodes", + }, + } + + +def _graph() -> Graph: + return Graph.from_object_info(_object_info()) + + +@pytest.fixture +def patched_graph(monkeypatch): + monkeypatch.setattr(workflow_edit, "_get_graph", lambda *a, **kw: _graph()) + + +def _base_workflow() -> dict: + """A minimal but wired frontend-format graph: EmptyLatentImage -> KSampler.""" + return { + "last_node_id": 7, + "last_link_id": 1, + "nodes": [ + { + "id": 3, + "type": "KSampler", + "pos": [100, 100], + "inputs": [ + {"name": "model", "type": "MODEL", "link": None}, + {"name": "positive", "type": "CONDITIONING", "link": None}, + {"name": "negative", "type": "CONDITIONING", "link": None}, + {"name": "latent_image", "type": "LATENT", "link": 1}, + ], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": []}], + "widgets_values": [42, "fixed", 20, 8.0, "euler", "normal", 1.0], + }, + { + "id": 7, + "type": "EmptyLatentImage", + "pos": [0, 0], + "inputs": [], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": [1]}], + "widgets_values": [512, 512, 1], + }, + ], + "links": [[1, 7, 0, 3, 3, "LATENT"]], + } + + +def _autogrow_workflow() -> dict: + """A BatchImagesNode (autogrow `images` input) plus two IMAGE sources, so + two connects can race onto the same autogrow base.""" + return { + "last_node_id": 21, + "last_link_id": 0, + "nodes": [ + { + "id": 10, + "type": "BatchImagesNode", + "pos": [200, 0], + "inputs": [{"name": "images", "type": "COMFY_AUTOGROW_V3", "link": None}], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + }, + { + "id": 20, + "type": "VAEDecode", + "pos": [0, 0], + "inputs": [ + {"name": "samples", "type": "LATENT", "link": None}, + {"name": "vae", "type": "VAE", "link": None}, + ], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + }, + { + "id": 21, + "type": "VAEDecode", + "pos": [0, 100], + "inputs": [ + {"name": "samples", "type": "LATENT", "link": None}, + {"name": "vae", "type": "VAE", "link": None}, + ], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + }, + ], + "links": [], + } + + +def _convergence_base() -> dict: + """A graph rich enough to exercise every op kind concurrently: two widget + nodes (KSampler 3, EmptyLatentImage 7), an autogrow sink (BatchImagesNode 10), + and two IMAGE sources (20, 21).""" + wf = _base_workflow() + wf["nodes"].append( + { + "id": 10, + "type": "BatchImagesNode", + "pos": [300, 0], + "inputs": [{"name": "images", "type": "COMFY_AUTOGROW_V3", "link": None}], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + } + ) + for nid, y in ((20, 0), (21, 120)): + wf["nodes"].append( + { + "id": nid, + "type": "VAEDecode", + "pos": [150, y], + "inputs": [ + {"name": "samples", "type": "LATENT", "link": None}, + {"name": "vae", "type": "VAE", "link": None}, + ], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + } + ) + wf["last_node_id"] = 21 + return wf + + +# Each spec mints one well-formed op against a fresh _convergence_base(); all are +# causally independent (they reference only base nodes), so any subset is a valid +# concurrent batch off the same base_version. +_CONVERGENCE_OP_SPECS = [ + "set_steps", + "set_cfg", + "set_width", + "set_steps2", # a second write to steps => LWW race with set_steps + "del_ksampler", + "del_latent", + "connect_latent", + "connect_latent2", # a second link into the same concrete slot => a conflict + "autogrow_20", + "autogrow_21", # a second autogrow onto the same base + "add_vae", +] + + +def _make_convergence_op(spec: str, rng, g) -> dict: + b = _convergence_base() + a = rng.choice("abc") + v = rng.randint(0, 3) + if spec == "set_steps": + _, op = workflow_ops.set_widget(b, g, 3, "steps", rng.randint(1, 40), actor=a, base_version=v) + elif spec == "set_cfg": + _, op = workflow_ops.set_widget(b, g, 3, "cfg", float(rng.randint(1, 15)), actor=a, base_version=v) + elif spec == "set_width": + _, op = workflow_ops.set_widget(b, g, 7, "width", rng.choice([256, 512, 768, 1024]), actor=a, base_version=v) + elif spec == "set_steps2": + _, op = workflow_ops.set_widget(b, g, 3, "steps", rng.randint(41, 80), actor=a, base_version=v) + elif spec == "del_ksampler": + _, op = workflow_ops.delete_node(b, g, 3, actor=a) + elif spec == "del_latent": + _, op = workflow_ops.delete_node(b, g, 7, actor=a) + elif spec == "connect_latent": + _, op = workflow_ops.connect(b, g, 7, "LATENT", 3, "latent_image", actor=a, base_version=v) + elif spec == "connect_latent2": + _, op = workflow_ops.connect(b, g, 7, "LATENT", 3, "latent_image", actor=a, base_version=v) + elif spec == "autogrow_20": + _, op = workflow_ops.connect(b, g, 20, "IMAGE", 10, "images", actor=a, base_version=v) + elif spec == "autogrow_21": + _, op = workflow_ops.connect(b, g, 21, "IMAGE", 10, "images", actor=a, base_version=v) + elif spec == "add_vae": + _, op = workflow_ops.add_node(b, g, "VAEDecode", actor=a) + else: # pragma: no cover - guard against a typo in the spec list + raise AssertionError(f"unknown convergence op spec {spec!r}") + return op + + +def _two_instance_subgraph_workflow() -> dict: + """Two top-level instances (57, 58) of ONE shared subgraph definition, so an + interior write must fork the shared def before mutating it.""" + wf = _subgraph_workflow() + inst57 = next(n for n in wf["nodes"] if n["id"] == 57) + inst58 = copy.deepcopy(inst57) + inst58["id"] = 58 + inst58["pos"] = [400, 0] + wf["nodes"].append(inst58) + wf["last_node_id"] = 58 + return wf + + +def _write(tmp_path: Path, data: dict, name: str = "wf.json") -> Path: + p = tmp_path / name + p.write_text(json.dumps(data, indent=2), encoding="utf-8") + return p + + +def _run(args: list[str], capsys) -> dict[str, Any]: + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(workflow_cmd.app, args, standalone_mode=False) + captured = capsys.readouterr().out + if not captured.strip(): + captured = result.stdout or "" + lines = [ln for ln in captured.strip().splitlines() if ln.strip()] + for line in reversed(lines): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope (rc={result.exit_code}, exc={result.exception}, out={captured[:600]})") + + +# --------------------------------------------------------------------------- +# add-node +# --------------------------------------------------------------------------- + + +class TestAddNode: + def test_adds_node_and_emits_op(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["add-node", str(path), "VAEDecode"], capsys) + assert env["ok"] is True + op = env["data"]["op"] + assert op["op"] == "add_node" + assert op["class_type"] == "VAEDecode" + assert isinstance(op["op_id"], str) and op["op_id"] + nid = op["node_id"] + on_disk = json.loads(path.read_text()) + node = next(n for n in on_disk["nodes"] if n["id"] == nid) + assert node["type"] == "VAEDecode" + # inputs/outputs materialized from object_info so it is connectable + assert {i["name"] for i in node["inputs"]} == {"samples", "vae"} + assert [o["name"] for o in node["outputs"]] == ["IMAGE"] + + def test_combo_widgets_default_to_first_choice(self, patched_graph, tmp_path, capsys): + """A fresh node must not leave COMBO widgets null (would fail at runtime).""" + path = _write(tmp_path, _base_workflow()) + env = _run(["add-node", str(path), "KSampler"], capsys) + nid = env["data"]["op"]["node_id"] + node = next(n for n in json.loads(path.read_text())["nodes"] if n["id"] == nid) + assert None not in node["widgets_values"], node["widgets_values"] + assert "euler" in node["widgets_values"] # sampler_name first choice + assert "normal" in node["widgets_values"] # scheduler first choice + + def test_where_flag_threads_to_catalog_resolver(self, monkeypatch, tmp_path, capsys): + captured: dict = {} + + def fake_get_graph(input_path, host, port, where=None): + captured["where"] = where + return _graph() + + monkeypatch.setattr(workflow_edit, "_get_graph", fake_get_graph) + path = _write(tmp_path, _base_workflow()) + env = _run(["add-node", str(path), "VAEDecode", "--where", "cloud"], capsys) + assert env["ok"] is True + assert captured["where"] == "cloud" + + def test_ids_are_large_ints_and_collision_free(self, patched_graph, tmp_path, capsys): + """CRDT identity: leaderless, collision-free, int-typed (converter-safe).""" + path = _write(tmp_path, _base_workflow()) + env1 = _run(["add-node", str(path), "VAEDecode"], capsys) + env2 = _run(["add-node", str(path), "VAEDecode"], capsys) + id1, id2 = env1["data"]["op"]["node_id"], env2["data"]["op"]["node_id"] + assert isinstance(id1, int) and isinstance(id2, int) + assert id1 != id2 + # Not a small sequential counter value — minted from a large space. + assert id1 > 10_000 and id2 > 10_000 + + +# --------------------------------------------------------------------------- +# set-widget (name-addressed) +# --------------------------------------------------------------------------- + + +class TestSetWidget: + def test_sets_widget_by_name_and_records_old_new(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "3.steps", "35"], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["op"] == "set_widget" + assert op["node_id"] == 3 + assert op["widget"] == "steps" + assert op["value"] == 35 + assert op["old"] == 20 + on_disk = json.loads(path.read_text()) + ks = next(n for n in on_disk["nodes"] if n["id"] == 3) + # widget_order: seed, control_after_generate, steps -> index 2 + assert ks["widgets_values"][2] == 35 + + def test_unknown_widget_errors(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "3.nope", "1"], capsys) + assert env["ok"] is False + + def test_shape_mismatch_rejected(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "3.steps", "notanumber"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + + def test_error_envelope_command_is_qualified(self, patched_graph, tmp_path, capsys): + """The error envelope's `command` must match the success one (not bare `workflow`).""" + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "3.nope", "1"], capsys) + assert env["command"] == "workflow set-widget" + + +# --------------------------------------------------------------------------- +# set-widget on SUBGRAPH-based templates (the modern gallery templates) +# +# Modern ComfyUI templates wrap their real nodes inside a subgraph *instance* +# (a top-level node whose `type` is a subgraph UUID). `slots` advertises the +# instance's promoted inputs as flat `.` addresses (e.g. +# `57.text`). set-widget MUST accept the SAME address slots emits — descending +# into the subgraph definition and writing the interior node's widget — plus the +# nested `/.` form the skill documents. +# --------------------------------------------------------------------------- + + +_SG_UUID = "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1" + + +def _subgraph_workflow() -> dict: + """A minimal curated subgraph template (derived from a fetched gallery + template): a top-level subgraph instance `57` whose promoted `text`/`seed`/ + `steps` route through `proxyWidgets` to interior CLIPTextEncode `27` and + KSampler `3`, plus a plain top-level node `9` so we can prove top-level edits + still work alongside subgraph edits.""" + return { + "last_node_id": 60, + "last_link_id": 0, + "nodes": [ + { + "id": 57, + "type": _SG_UUID, + "pos": [0, 0], + "inputs": [], + "outputs": [], + "properties": {"proxyWidgets": [["27", "text"], ["3", "seed"], ["3", "steps"]]}, + }, + { + "id": 9, + "type": "EmptyLatentImage", + "pos": [10, 10], + "inputs": [], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": []}], + "widgets_values": [512, 512, 1], + }, + ], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": _SG_UUID, + "name": "Text to Image", + "inputs": [ + {"name": "text", "type": "STRING"}, + {"name": "seed", "type": "INT"}, + {"name": "steps", "type": "INT"}, + ], + "nodes": [ + {"id": 27, "type": "CLIPTextEncode", "widgets_values": ["old prompt"]}, + {"id": 3, "type": "KSampler", "widgets_values": [42, "fixed", 20, 8.0, "euler", "normal", 1.0]}, + ], + } + ] + }, + } + + +def _interior(wf: dict, inner_id) -> dict: + sg = wf["definitions"]["subgraphs"][0] + return next(n for n in sg["nodes"] if str(n["id"]) == str(inner_id)) + + +class TestSetWidgetSubgraph: + def test_flat_promoted_address_writes_interior_node(self, patched_graph, tmp_path, capsys): + """`57.text` — the exact address `slots` advertises — writes CLIPTextEncode 27.""" + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57.text", "a cat on a bicycle"], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["op"] == "set_widget" + assert op["node_id"] == 57 + assert op["value"] == "a cat on a bicycle" + assert op["old"] == "old prompt" + # op is self-describing + replayable: resolved interior path + widget. + assert op["path"] == ["57", "27"] + assert op["inner_widget"] == "text" + # CRDT stamping preserved. + assert isinstance(op["op_id"], str) and op["op_id"] + assert op["stamp"] == [0, "cli"] + # value landed on the interior node, in the definition (persists on disk). + wf = json.loads(path.read_text()) + assert _interior(wf, 27)["widgets_values"][0] == "a cat on a bicycle" + + def test_flat_promoted_int_input_writes_ksampler(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57.seed", "12345"], capsys) + assert env["ok"] is True, env + assert env["data"]["op"]["path"] == ["57", "3"] + wf = json.loads(path.read_text()) + assert _interior(wf, 3)["widgets_values"][0] == 12345 # seed is index 0 + + def test_nested_interior_address_writes_interior_node(self, patched_graph, tmp_path, capsys): + """`57/27.text` (the nested form the skill documents) hits the same widget.""" + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57/27.text", "a nested cat"], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["node_id"] == "57/27" + assert op["path"] == ["57", "27"] + assert op["inner_widget"] == "text" + wf = json.loads(path.read_text()) + assert _interior(wf, 27)["widgets_values"][0] == "a nested cat" + + def test_flat_and_nested_share_a_conflict_target(self): + """Flat `57.text` and nested `57/27.text` land on the same interior + widget, so their ops must resolve to the SAME CRDT write target (they + converge — one does not silently clobber the other undetected).""" + wf = _subgraph_workflow() + _, flat = workflow_ops.set_widget(copy.deepcopy(wf), _graph(), 57, "text", "A") + _, nested = workflow_ops.set_widget(copy.deepcopy(wf), _graph(), "57/27", "text", "B") + assert workflow_ops._write_target(flat) == workflow_ops._write_target(nested) + assert workflow_ops.detect_conflict(flat, nested) is True # different values, same target + + def test_slots_and_set_widget_agree(self, patched_graph, monkeypatch, tmp_path, capsys): + """The self-consistency the bug broke: every flat address `slots` emits + for the subgraph instance is accepted by set-widget.""" + # slots resolves its graph via workflow.py's _get_graph; set-widget via + # workflow_edit.py's. patched_graph covers the latter; patch the former too. + monkeypatch.setattr(workflow_cmd, "_get_graph", lambda *a, **kw: _graph()) + path = _write(tmp_path, _subgraph_workflow()) + slots_env = _run(["slots", str(path)], capsys) + addrs = [s["address"] for s in slots_env["data"]["slots"] if str(s["address"]).startswith("57.")] + assert addrs, slots_env # the instance's promoted inputs are advertised flat + for addr in ("57.text", "57.seed", "57.steps"): + assert addr in addrs + env = _run(["set-widget", str(path), addr, "3" if addr != "57.text" else "x"], capsys) + assert env["ok"] is True, (addr, env) + + def test_unknown_promoted_input_errors_cleanly(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57.nope", "1"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + assert "not found on subgraph node 57" in env["error"]["message"] + + def test_type_mismatch_on_promoted_int_rejected(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57.seed", '"notanumber"'], capsys) + assert env["ok"] is False + # unchanged on disk (edit rejected before write). + wf = json.loads(path.read_text()) + assert _interior(wf, 3)["widgets_values"][0] == 42 + + def test_top_level_edit_still_works_with_subgraphs_present(self, patched_graph, tmp_path, capsys): + """A plain top-level node in a workflow that also contains subgraphs is + still edited directly (no regression).""" + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "9.width", "768"], capsys) + assert env["ok"] is True, env + assert "path" not in env["data"]["op"] # direct top-level op, not a subgraph op + wf = json.loads(path.read_text()) + node9 = next(n for n in wf["nodes"] if n["id"] == 9) + assert node9["widgets_values"][0] == 768 + + +# --------------------------------------------------------------------------- +# connect +# --------------------------------------------------------------------------- + + +class TestConnect: + def test_connects_and_wires_slots(self, patched_graph, tmp_path, capsys): + wf = _base_workflow() + path = _write(tmp_path, wf) + # add a VAEDecode then connect KSampler.LATENT -> VAEDecode.samples + add = _run(["add-node", str(path), "VAEDecode"], capsys) + vae_id = add["data"]["op"]["node_id"] + env = _run(["connect", str(path), "3.LATENT", f"{vae_id}.samples"], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["op"] == "connect" + link_id = op["link_id"] + on_disk = json.loads(path.read_text()) + link = next(ln for ln in on_disk["links"] if ln[0] == link_id) + assert link[1] == 3 and link[3] == vae_id # from KSampler -> to VAEDecode + vae = next(n for n in on_disk["nodes"] if n["id"] == vae_id) + samples = next(i for i in vae["inputs"] if i["name"] == "samples") + assert samples["link"] == link_id + + def test_autogrow_input_grows_a_slot_per_connection(self, patched_graph, tmp_path, capsys): + """COMFY_AUTOGROW inputs (BatchImagesNode.images) grow images.image0/1… — the + assembly wiring the CRDT/apply path needs for video.""" + path = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + a = _run(["add-node", str(path), "VAEDecode"], capsys)["data"]["op"]["node_id"] + b = _run(["add-node", str(path), "VAEDecode"], capsys)["data"]["op"]["node_id"] + batch = _run(["add-node", str(path), "BatchImagesNode"], capsys)["data"]["op"]["node_id"] + e1 = _run(["connect", str(path), f"{a}.IMAGE", f"{batch}.images"], capsys) + e2 = _run(["connect", str(path), f"{b}.IMAGE", f"{batch}.images"], capsys) + assert e1["ok"] and e2["ok"], (e1, e2) + assert e1["data"]["op"]["grow"]["name"] == "images.image0" + assert e2["data"]["op"]["grow"]["name"] == "images.image1" + bn = next(n for n in json.loads(path.read_text())["nodes"] if n["id"] == batch) + grown = [i for i in bn["inputs"] if i["name"].startswith("images.image")] + assert {i["name"] for i in grown} == {"images.image0", "images.image1"} + assert all(i["link"] is not None and i["type"] == "IMAGE" for i in grown) + + def test_connect_converts_widget_to_input(self, patched_graph, tmp_path, capsys): + """connect onto a widget-backed input (KSampler.cfg) converts it to a link; + widgets_values stays intact and the converter uses the link (fps-style wiring).""" + path = _write(tmp_path, _base_workflow()) # KSampler id 3, widgets len 7 + src = _run(["add-node", str(path), "PrimitiveFloat"], capsys)["data"]["op"]["node_id"] + env = _run(["connect", str(path), f"{src}.FLOAT", "3.cfg"], capsys) + assert env["ok"] is True, env + assert env["data"]["op"]["grow"] == {"name": "cfg", "type": "FLOAT", "widget": "cfg"} + wf = json.loads(path.read_text()) + ks = next(n for n in wf["nodes"] if n["id"] == 3) + cfg_in = next(i for i in ks["inputs"] if i["name"] == "cfg") + assert cfg_in["link"] is not None and cfg_in["widget"] == {"name": "cfg"} + assert len(ks["widgets_values"]) == 7 # value kept → positional alignment holds + + from comfy_cli.workflow_to_api import convert_ui_to_api + + api = convert_ui_to_api(wf, _object_info()) + assert api["3"]["inputs"]["cfg"] == [str(src), 0] # cfg is a link now + assert api["3"]["inputs"]["steps"] == 20 # other widgets still aligned + + def test_type_mismatch_rejected(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + add = _run(["add-node", str(path), "VAEDecode"], capsys) + vae_id = add["data"]["op"]["node_id"] + # KSampler LATENT output cannot feed a VAE-typed input. + env = _run(["connect", str(path), "3.LATENT", f"{vae_id}.vae"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + + def test_replacing_input_link_scrubs_the_old_one(self, patched_graph, tmp_path, capsys): + """Re-wiring an occupied input must retire the previous link, not orphan it.""" + path = _write(tmp_path, _base_workflow()) + # KSampler.latent_image already holds link 1 (from EmptyLatentImage 7). + add = _run(["add-node", str(path), "EmptyLatentImage"], capsys) + new_src = add["data"]["op"]["node_id"] + env = _run(["connect", str(path), f"{new_src}.LATENT", "3.latent_image"], capsys) + assert env["ok"] is True, env + new_link = env["data"]["op"]["link_id"] + wf = json.loads(path.read_text()) + # old link 1 is gone entirely; only the new link references latent_image + assert all(ln[0] != 1 for ln in wf["links"]) + ks = next(n for n in wf["nodes"] if n["id"] == 3) + assert next(i for i in ks["inputs"] if i["name"] == "latent_image")["link"] == new_link + # old source's out-links no longer carry the retired link + old_src = next(n for n in wf["nodes"] if n["id"] == 7) + assert 1 not in (old_src["outputs"][0]["links"] or []) + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +class TestDelete: + def test_deletes_node_and_incident_links(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["delete-node", str(path), "7"], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["op"] == "delete_node" + assert op["node_id"] == 7 + on_disk = json.loads(path.read_text()) + assert all(n["id"] != 7 for n in on_disk["nodes"]) + # link 1 (7 -> 3) must be gone, and KSampler.latent_image cleared + assert all(ln[1] != 7 and ln[3] != 7 for ln in on_disk["links"]) + ks = next(n for n in on_disk["nodes"] if n["id"] == 3) + latent = next(i for i in ks["inputs"] if i["name"] == "latent_image") + assert latent["link"] is None + + def test_nested_subgraph_address_missing_interior_node_errors(self, patched_graph, tmp_path, capsys): + # A nested address into a graph with no such subgraph/interior node fails + # cleanly (the top-level workflow here has no subgraph instance 10). + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "10/9.prompt", "x"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + + +# --------------------------------------------------------------------------- +# invariant: the edit surface operates on UI (frontend) format ONLY +# (API format is a throwaway produced only at `run`) +# --------------------------------------------------------------------------- + + +class TestUiFormatOnly: + _API = {"3": {"class_type": "KSampler", "inputs": {}}} # API format: dict keyed by ids + + def test_add_node_rejects_api_format(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, self._API) + env = _run(["add-node", str(path), "VAEDecode"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_not_frontend_format" + + def test_apply_rejects_api_format(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, self._API) + ops = tmp_path / "ops.json" + ops.write_text("[]", encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(ops)], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_not_frontend_format" + + def test_set_widget_rejects_api_format(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, self._API) + env = _run(["set-widget", str(path), "3.steps", "20"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_not_frontend_format" + + +# --------------------------------------------------------------------------- +# ls-nodes +# --------------------------------------------------------------------------- + + +class TestLsNodes: + def test_lists_nodes(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["ls-nodes", str(path)], capsys) + assert env["ok"] is True + ids = {n["id"] for n in env["data"]["nodes"]} + assert ids == {3, 7} + types = {n["type"] for n in env["data"]["nodes"]} + assert types == {"KSampler", "EmptyLatentImage"} + + +# --------------------------------------------------------------------------- +# apply — batch with aliases +# --------------------------------------------------------------------------- + + +class TestApplyBatch: + def _empty(self, tmp_path): + return _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + + def test_builds_graph_in_one_pass_with_aliases(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + specs = [ + {"op": "add_node", "class_type": "CheckpointLoaderSimple", "as": "ckpt"}, + {"op": "add_node", "class_type": "CLIPTextEncode", "as": "pos"}, + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "add_node", "class_type": "EmptyLatentImage", "as": "lat"}, + {"op": "connect", "from": "ckpt.MODEL", "to": "ks.model"}, + {"op": "connect", "from": "ckpt.CLIP", "to": "pos.clip"}, + {"op": "connect", "from": "pos.CONDITIONING", "to": "ks.positive"}, + {"op": "connect", "from": "lat.LATENT", "to": "ks.latent_image"}, + {"op": "set_widget", "node": "pos", "widget": "text", "value": "a cat"}, + {"op": "set_widget", "node": "ks", "widget": "steps", "value": 30}, + ] + ops_path = tmp_path / "ops.json" + ops_path.write_text(json.dumps(specs), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(ops_path)], capsys) + assert env["ok"] is True, env + assert env["data"]["count"] == 10 + assert set(env["data"]["aliases"]) == {"ckpt", "pos", "ks", "lat"} + # the aliased KSampler really got wired + from comfy_cli.workflow_to_api import convert_ui_to_api + + api = convert_ui_to_api(json.loads(path.read_text()), _object_info()) + ks_id = str(env["data"]["aliases"]["ks"]) + assert ks_id in api + assert api[ks_id]["inputs"]["model"][0] == str(env["data"]["aliases"]["ckpt"]) + + def test_batch_is_atomic_on_failure(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + before = path.read_text() + specs = [ + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "add_node", "class_type": "NoSuchNode"}, # fails + ] + ops_path = tmp_path / "ops.json" + ops_path.write_text(json.dumps(specs), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(ops_path)], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + assert path.read_text() == before, "failed batch must not write a partial graph" + + def test_duplicate_alias_is_rejected(self, patched_graph, tmp_path, capsys): + """A repeated `as` name would silently clobber the earlier node — reject it.""" + path = self._empty(tmp_path) + before = path.read_text() + specs = [ + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, # duplicate alias + ] + ops_path = tmp_path / "ops.json" + ops_path.write_text(json.dumps(specs), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(ops_path)], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + assert "ks" in env["error"]["message"] and "already defined" in env["error"]["message"] + assert path.read_text() == before + + def test_missing_field_names_the_spec(self, patched_graph, tmp_path, capsys): + """A bare KeyError becomes an actionable `spec #i (...) is missing ...`.""" + path = self._empty(tmp_path) + specs = [{"op": "add_node", "class_type": "KSampler", "as": "ks"}, {"op": "set_widget", "node": "ks"}] + ops_path = tmp_path / "ops.json" + ops_path.write_text(json.dumps(specs), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(ops_path)], capsys) + assert env["ok"] is False + assert "spec #1" in env["error"]["message"] and "missing required field" in env["error"]["message"] + + +# --------------------------------------------------------------------------- +# dynamic combo (COMFY_DYNAMICCOMBO_V3) — set_widget on model + model.resolution +# --------------------------------------------------------------------------- + + +class TestDynamicCombo: + def test_add_node_fills_dynamiccombo_defaults(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + nid = _run(["add-node", str(path), "KlingFLFTest"], capsys)["data"]["op"]["node_id"] + g = _graph() + node = next(n for n in json.loads(path.read_text())["nodes"] if n["id"] == nid) + order = g.widget_order("KlingFLFTest") + assert "model" in order and "model.resolution" in order + wv = node["widgets_values"] + assert wv[order.index("model")] == "kling-v3" # first key + assert wv[order.index("model.resolution")] == "1080p" # sub default + + def test_set_widget_dynamiccombo_selector_and_sub(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + nid = _run(["add-node", str(path), "KlingFLFTest"], capsys)["data"]["op"]["node_id"] + e1 = _run(["set-widget", str(path), f"{nid}.model", "kling-v3"], capsys) + e2 = _run(["set-widget", str(path), f"{nid}.model.resolution", "720p"], capsys) + assert e1["ok"] and e2["ok"], (e1, e2) + g = _graph() + order = g.widget_order("KlingFLFTest") + wv = next(n for n in json.loads(path.read_text())["nodes"] if n["id"] == nid)["widgets_values"] + assert wv[order.index("model.resolution")] == "720p" + + from comfy_cli.workflow_to_api import convert_ui_to_api + + api = convert_ui_to_api(json.loads(path.read_text()), _object_info()) + assert api[str(nid)]["inputs"]["model"] == "kling-v3" + assert api[str(nid)]["inputs"]["model.resolution"] == "720p" + + +# --------------------------------------------------------------------------- +# recipes — parameterized op-batches (apply --param) +# --------------------------------------------------------------------------- + + +class TestRecipes: + def _recipe(self): + return { + "recipe": "t2i", + "params": {"positive": {"type": "string"}, "steps": {"type": "int", "default": 20}}, + "ops": [ + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "set_widget", "node": "ks", "widget": "steps", "value": "${steps}"}, + {"op": "add_node", "class_type": "CLIPTextEncode", "as": "pos"}, + {"op": "set_widget", "node": "pos", "widget": "text", "value": "a ${positive} scene"}, + ], + } + + def _empty(self, tmp_path): + return _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + + def test_param_substitution_is_typed(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + rp = tmp_path / "r.json" + rp.write_text(json.dumps(self._recipe()), encoding="utf-8") + env = _run( + ["apply", str(path), "--ops", str(rp), "--param", "positive=quiet forest", "--param", "steps=35"], capsys + ) + assert env["ok"] is True, env + wf = json.loads(path.read_text()) + g = _graph() + ks = next(n for n in wf["nodes"] if n["type"] == "KSampler") + pos = next(n for n in wf["nodes"] if n["type"] == "CLIPTextEncode") + assert ks["widgets_values"][g.widget_order("KSampler").index("steps")] == 35 # int, not "35" + assert pos["widgets_values"][g.widget_order("CLIPTextEncode").index("text")] == "a quiet forest scene" + + def test_default_used_when_param_omitted(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + rp = tmp_path / "r.json" + rp.write_text(json.dumps(self._recipe()), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(rp), "--param", "positive=x"], capsys) + assert env["ok"] is True + wf = json.loads(path.read_text()) + g = _graph() + ks = next(n for n in wf["nodes"] if n["type"] == "KSampler") + assert ks["widgets_values"][g.widget_order("KSampler").index("steps")] == 20 # declared default + + def test_missing_required_param_errors(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + rp = tmp_path / "r.json" + rp.write_text(json.dumps(self._recipe()), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(rp)], capsys) # positive omitted, no default + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + assert "positive" in env["error"]["message"] + + def test_unknown_param_errors(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + rp = tmp_path / "r.json" + rp.write_text(json.dumps(self._recipe()), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(rp), "--param", "positive=x", "--param", "nope=1"], capsys) + assert env["ok"] is False + assert "nope" in env["error"]["message"] + + def test_bad_type_errors(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + rp = tmp_path / "r.json" + rp.write_text(json.dumps(self._recipe()), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(rp), "--param", "positive=x", "--param", "steps=notanint"], capsys) + assert env["ok"] is False + assert "int" in env["error"]["message"] + + +# --------------------------------------------------------------------------- +# foreach — bulk-instantiate a recipe over N param-sets +# --------------------------------------------------------------------------- + + +class TestForeach: + def _recipe(self, tmp_path): + rp = tmp_path / "r.json" + rp.write_text( + json.dumps( + { + "recipe": "t2i", + "params": {"positive": {"type": "string"}, "steps": {"type": "int", "default": 20}}, + "ops": [ + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "set_widget", "node": "ks", "widget": "steps", "value": "${steps}"}, + {"op": "add_node", "class_type": "CLIPTextEncode", "as": "pos"}, + {"op": "set_widget", "node": "pos", "widget": "text", "value": "${positive}"}, + ], + } + ), + encoding="utf-8", + ) + return rp + + def test_foreach_materializes_one_workflow_per_param_set(self, patched_graph, tmp_path, capsys): + rp = self._recipe(tmp_path) + params = tmp_path / "sets.jsonl" + params.write_text('{"positive":"a cat","steps":10}\n{"positive":"a dog","steps":30}\n', encoding="utf-8") + out = tmp_path / "out" + env = _run(["foreach", str(rp), "--params", str(params), "--out-dir", str(out)], capsys) + assert env["ok"] is True, env + assert env["data"]["count"] == 2 + files = sorted(out.glob("*.json")) + assert len(files) == 2 + g = _graph() + seen = [] + for f in files: + wf = json.loads(f.read_text()) + pos = next(n for n in wf["nodes"] if n["type"] == "CLIPTextEncode") + ks = next(n for n in wf["nodes"] if n["type"] == "KSampler") + seen.append( + ( + pos["widgets_values"][g.widget_order("CLIPTextEncode").index("text")], + ks["widgets_values"][g.widget_order("KSampler").index("steps")], + ) + ) + assert seen == [("a cat", 10), ("a dog", 30)] # each param-set → its own workflow + + def test_foreach_accepts_json_array(self, patched_graph, tmp_path, capsys): + rp = self._recipe(tmp_path) + params = tmp_path / "sets.json" + params.write_text(json.dumps([{"positive": "x"}, {"positive": "y"}, {"positive": "z"}]), encoding="utf-8") + out = tmp_path / "out" + env = _run(["foreach", str(rp), "--params", str(params), "--out-dir", str(out)], capsys) + assert env["ok"] is True + assert env["data"]["count"] == 3 # steps uses the default + + def test_foreach_bad_param_set_fails(self, patched_graph, tmp_path, capsys): + rp = self._recipe(tmp_path) + params = tmp_path / "sets.jsonl" + params.write_text('{"steps":10}\n', encoding="utf-8") # missing required positive + out = tmp_path / "out" + env = _run(["foreach", str(rp), "--params", str(params), "--out-dir", str(out)], capsys) + assert env["ok"] is False + assert "positive" in env["error"]["message"] + + def test_foreach_surfaces_partial_writes_on_mid_batch_failure(self, patched_graph, tmp_path, capsys): + """foreach writes per param-set; a mid-batch failure leaves earlier files + on disk, so the error must surface them (not leave the caller blind).""" + rp = self._recipe(tmp_path) + params = tmp_path / "sets.jsonl" + # #0 valid → written; #1 missing required `positive` → fails. + params.write_text('{"positive":"a cat"}\n{"steps":10}\n', encoding="utf-8") + out = tmp_path / "out" + env = _run(["foreach", str(rp), "--params", str(params), "--out-dir", str(out)], capsys) + assert env["ok"] is False + written = env["error"]["details"]["written"] + assert len(written) == 1 and written[0].endswith("_000.json") + assert list(out.glob("*.json")) # the partial file really is on disk + assert "before the failure" in env["error"]["hint"] + + +# --------------------------------------------------------------------------- +# capture — project a graph into a recipe; round-trips through apply +# --------------------------------------------------------------------------- + + +class TestCapture: + def test_capture_roundtrips_through_apply(self, patched_graph, tmp_path, capsys): + src = _write(tmp_path, _base_workflow()) + cap = _run(["capture", str(src), "--name", "base"], capsys) + assert cap["ok"] is True, cap + recipe = cap["data"]["recipe_doc"] + # a non-default widget (KSampler seed=42) is captured; defaults are not + assert any(o["op"] == "set_widget" and o["widget"] == "seed" and o["value"] == 42 for o in recipe["ops"]) + assert not any(o.get("widget") == "steps" for o in recipe["ops"]) # steps=20 is the default + + empty = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}, "empty.json") + rp = tmp_path / "r.json" + rp.write_text(json.dumps(recipe), encoding="utf-8") + applied = _run(["apply", str(empty), "--ops", str(rp)], capsys) + assert applied["ok"] is True, applied + + rebuilt = json.loads(empty.read_text()) + orig = _base_workflow() + assert sorted(n["type"] for n in rebuilt["nodes"]) == sorted(n["type"] for n in orig["nodes"]) + assert len(rebuilt["links"]) == len(orig["links"]) + g = _graph() + ks = next(n for n in rebuilt["nodes"] if n["type"] == "KSampler") + assert ks["widgets_values"][g.widget_order("KSampler").index("seed")] == 42 # preserved + + from comfy_cli.workflow_to_api import convert_ui_to_api + + api = convert_ui_to_api(rebuilt, _object_info()) + ks_api = next(v for v in api.values() if v["class_type"] == "KSampler") + assert isinstance(ks_api["inputs"].get("latent_image"), list) # wiring preserved + + def test_capture_lifts_widget_to_param_even_at_default(self, patched_graph, tmp_path, capsys): + """`--param` promotes a widget to a ${param} hole even when its value is the + node default (the footgun: capture would otherwise drop it).""" + # EmptyLatentImage.width=512 IS the default → normally not captured. + src = _write(tmp_path, _base_workflow()) + lat_id = next(n["id"] for n in _base_workflow()["nodes"] if n["type"] == "EmptyLatentImage") + cap = _run(["capture", str(src), "--param", f"{lat_id}.width=w"], capsys) + assert cap["ok"] is True, cap + recipe = cap["data"]["recipe_doc"] + assert "w" in recipe["params"] and recipe["params"]["w"]["type"] == "int" + assert any(o["op"] == "set_widget" and o.get("value") == "${w}" for o in recipe["ops"]) + # and it applies with an override + empty = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}, "e.json") + rp = tmp_path / "r.json" + rp.write_text(json.dumps(recipe), encoding="utf-8") + env = _run(["apply", str(empty), "--ops", str(rp), "--param", "w=768"], capsys) + assert env["ok"] is True, env + g = _graph() + lat = next(n for n in json.loads(empty.read_text())["nodes"] if n["type"] == "EmptyLatentImage") + assert lat["widgets_values"][g.widget_order("EmptyLatentImage").index("width")] == 768 + + def test_capture_param_rejects_unknown_target(self, patched_graph, tmp_path, capsys): + src = _write(tmp_path, _base_workflow()) + env = _run(["capture", str(src), "--param", "3.nope=x"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + + def test_capture_rejects_subgraphs(self, patched_graph, tmp_path, capsys): + wf = _base_workflow() + wf["definitions"] = {"subgraphs": [{"id": "sg", "name": "x", "nodes": []}]} + path = _write(tmp_path, wf) + env = _run(["capture", str(path)], capsys) + assert env["ok"] is False + assert "subgraph" in env["error"]["message"].lower() + + +# --------------------------------------------------------------------------- +# op-model correctness — direct against workflow_ops (P1..P7) +# --------------------------------------------------------------------------- + + +class TestOpModel: + def _ops(self): + from comfy_cli import workflow_ops + + return workflow_ops + + def test_p1_fidelity_apply_equals_primitive(self): + ops = self._ops() + g = _graph() + base = _base_workflow() + for make in ( + lambda w: ops.add_node(w, g, "VAEDecode"), + lambda w: ops.set_widget(w, g, 3, "steps", 33), + lambda w: ops.delete_node(w, g, 7), + ): + direct, op = make(copy.deepcopy(base)) + replayed = ops.apply_op(copy.deepcopy(base), op, g) + assert ops.canonical(replayed) == ops.canonical(direct) + + def test_p2_idempotency(self): + ops = self._ops() + g = _graph() + base = _base_workflow() + _, op = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 33) + once = ops.apply_op(copy.deepcopy(base), op, g) + twice = ops.apply_op(ops.apply_op(copy.deepcopy(base), op, g), op, g) + assert ops.canonical(once) == ops.canonical(twice) + + def test_p3_convergence_nonoverlapping(self): + ops = self._ops() + g = _graph() + base = _base_workflow() + _, op_add = ops.add_node(copy.deepcopy(base), g, "VAEDecode", actor="agent") + _, op_set = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 50, actor="human") + ab = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_add, g), op_set, g) + ba = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_set, g), op_add, g) + assert ops.canonical(ab) == ops.canonical(ba) + + def test_p4_conflict_detection(self): + ops = self._ops() + g = _graph() + base = _base_workflow() + _, a = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 10, actor="human") + _, b = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 20, actor="agent") + _, c = ops.set_widget(copy.deepcopy(base), g, 3, "cfg", 7.0, actor="agent") + assert ops.detect_conflict(a, b) is True + assert ops.detect_conflict(a, c) is False + + def test_p5_widget_name_safe_under_layout_shift(self): + """Name-addressed op survives a concurrent widget-layout shift.""" + ops = self._ops() + g = _graph() + base = _base_workflow() + _, op = ops.set_widget(copy.deepcopy(base), g, 3, "denoise", 0.5, actor="agent") + # Simulate another peer prepending a widget slot on the same node + # (indices all shift by one); a name-keyed op must still hit denoise. + shifted = copy.deepcopy(base) + ks = next(n for n in shifted["nodes"] if n["id"] == 3) + ks["widgets_values"] = ["INJECTED", *ks["widgets_values"]] + # apply must re-resolve by name, not blindly by the original index + out = ops.apply_op(shifted, op, g) + ks_out = next(n for n in out["nodes"] if n["id"] == 3) + order = g.widget_order("KSampler") + assert ks_out["widgets_values"][order.index("denoise")] == 0.5 + assert ks_out["widgets_values"][0] == "INJECTED" + + def test_p7_api_convert_valid(self): + ops = self._ops() + from comfy_cli.workflow_to_api import convert_ui_to_api + + g = _graph() + wf = _base_workflow() + wf, _ = ops.add_node(wf, g, "VAEDecode") + vae_id = next(n["id"] for n in wf["nodes"] if n["type"] == "VAEDecode") + wf, _ = ops.connect(wf, g, 3, "LATENT", vae_id, "samples") + api = convert_ui_to_api(wf, _object_info()) + assert isinstance(api, dict) + # the added VAEDecode survives conversion with an int-keyed id + assert str(vae_id) in api + + # -- convergence properties (P8..P11): a merge consumer replays ops in any + # order; apply must be TOTAL (never crash) and ORDER-INDEPENDENT (both + # orders reach the same canonical graph). One property per convergence + # bug the deep review flagged. + + def test_p8_totality_delete_wins_over_concurrent_edit(self): + """A write to a concurrently-deleted node is a no-op (delete wins), + never a crash. {delete(3), set_widget(3)} converges in either order.""" + ops = self._ops() + g = _graph() + base = _base_workflow() + _, op_del = ops.delete_node(copy.deepcopy(base), g, 3, actor="human") + _, op_set = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 99, actor="agent") + # neither order may raise; both must converge to "node 3 gone". + del_then_set = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_del, g), op_set, g) + set_then_del = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_set, g), op_del, g) + assert ops.canonical(del_then_set) == ops.canonical(set_then_del) + assert all(n["id"] != 3 for n in del_then_set["nodes"]) + + def test_p8_totality_connect_to_deleted_node_is_noop(self): + """A connect whose endpoint was concurrently deleted no-ops without + crashing or leaving a dangling link.""" + ops = self._ops() + g = _graph() + base = _base_workflow() + # wire EmptyLatentImage(7).LATENT -> KSampler(3).latent_image, then race a + # delete of the source node 7. + _, op_conn = ops.connect(copy.deepcopy(base), g, 7, "LATENT", 3, "latent_image", actor="agent") + _, op_del = ops.delete_node(copy.deepcopy(base), g, 7, actor="human") + out = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_del, g), op_conn, g) + assert all(n["id"] != 7 for n in out["nodes"]) + # no link may reference the deleted node 7 (as source or target). + assert all(ln[1] != 7 and ln[3] != 7 for ln in out.get("links") or []) + + def test_p9_autogrow_connects_are_commutative(self): + """Two concurrent autogrow connects to the same base must both survive + (no clobber) and converge regardless of apply order.""" + ops = self._ops() + g = _graph() + base = _autogrow_workflow() + _, op1 = ops.connect(copy.deepcopy(base), g, 20, "IMAGE", 10, "images", actor="a") + _, op2 = ops.connect(copy.deepcopy(base), g, 21, "IMAGE", 10, "images", actor="b") + ab = ops.apply_op(ops.apply_op(copy.deepcopy(base), op1, g), op2, g) + ba = ops.apply_op(ops.apply_op(copy.deepcopy(base), op2, g), op1, g) + # both source links survive in either order (no silent connection loss). + for out in (ab, ba): + link_srcs = {ln[1] for ln in out.get("links") or []} + assert {20, 21} <= link_srcs, out.get("links") + # ...and the two orders converge. + assert ops.canonical(ab) == ops.canonical(ba) + + def test_p9_autogrow_grow_id_survives_api_conversion(self): + """The ``grow_id`` bookkeeping (persisted on grown slots as their + convergence identity) must not break API conversion — both wired sources + reach the flat API prompt.""" + ops = self._ops() + from comfy_cli.workflow_to_api import convert_ui_to_api + + g = _graph() + base = _autogrow_workflow() + _, op1 = ops.connect(copy.deepcopy(base), g, 20, "IMAGE", 10, "images", actor="a") + _, op2 = ops.connect(copy.deepcopy(base), g, 21, "IMAGE", 10, "images", actor="b") + wf = ops.apply_op(ops.apply_op(copy.deepcopy(base), op1, g), op2, g) + assert any(i.get("grow_id") for i in next(n for n in wf["nodes"] if n["id"] == 10)["inputs"]) + api = convert_ui_to_api(wf, _object_info()) + wired = list(api["10"]["inputs"].values()) + assert ["20", 0] in wired and ["21", 0] in wired, wired + + def test_p10_subgraph_fork_is_deterministic(self): + """Forking a shared subgraph definition must mint a deterministic id, so + two replicas replaying the same ops reach byte-identical graphs.""" + ops = self._ops() + g = _graph() + base = _two_instance_subgraph_workflow() + _, op_a = ops.set_widget(copy.deepcopy(base), g, "57/27", "text", "A", actor="a") + _, op_b = ops.set_widget(copy.deepcopy(base), g, "58/27", "text", "B", actor="b") + replica1 = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_a, g), op_b, g) + replica2 = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_a, g), op_b, g) + # deterministic fork ids => two independent replays are identical. + assert ops.canonical(replica1) == ops.canonical(replica2) + + def test_p11_concurrent_widget_writes_resolve_by_stamp(self): + """Two concurrent writes to the same widget converge on the higher-stamp + value regardless of apply order (last-writer-wins by causal stamp).""" + ops = self._ops() + g = _graph() + base = _base_workflow() + _, lo = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 10, actor="human", base_version=5) + _, hi = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 20, actor="agent", base_version=7) + lo_hi = ops.apply_op(ops.apply_op(copy.deepcopy(base), lo, g), hi, g) + hi_lo = ops.apply_op(ops.apply_op(copy.deepcopy(base), hi, g), lo, g) + assert ops.canonical(lo_hi) == ops.canonical(hi_lo) + order = g.widget_order("KSampler") + ks = next(n for n in lo_hi["nodes"] if n["id"] == 3) + assert ks["widgets_values"][order.index("steps")] == 20 # higher base_version wins + + # -- sufficiency (P12..P13): P8..P11 prove specific bugs are fixed; these + # prove the op model's CONTRACT holds across randomized inputs — every op + # pair either converges or is flagged (never silently diverges), and + # canonical() is a sound equality oracle (folds only immaterial detail). + + def test_p12_every_op_pair_converges_or_is_flagged(self): + """The load-bearing invariant: any two concurrent ops EITHER converge + under replay OR are reported by ``detect_conflict``. A silent divergence + (order matters, but nothing flagged it) is the failure this rules out. + Proved over randomized pairs with a fixed seed (reproducible).""" + import itertools + import random + + ops = self._ops() + g = _graph() + rng = random.Random(20260707) + checked = 0 + for _ in range(400): + specs = rng.sample(_CONVERGENCE_OP_SPECS, k=rng.randint(2, 4)) + pool = [_make_convergence_op(s, rng, g) for s in specs] + for a, b in itertools.combinations(pool, 2): + base = _convergence_base() + ab = ops.apply_op(ops.apply_op(copy.deepcopy(base), a, g), b, g) + ba = ops.apply_op(ops.apply_op(copy.deepcopy(base), b, g), a, g) + if ops.canonical(ab) != ops.canonical(ba): + assert ops.detect_conflict(a, b), ( + "SILENT DIVERGENCE", + (a["op"], a.get("widget") or a.get("to_node")), + (b["op"], b.get("widget") or b.get("to_node")), + ) + checked += 1 + assert checked > 1000 # the harness genuinely exercised many pairs + + def test_p12b_conflict_free_sets_fully_converge(self): + """Higher-order: a set of pairwise-non-conflicting ops converges across + ALL apply orders (not just pairs) — catches 3-way interactions.""" + import itertools + import random + + ops = self._ops() + g = _graph() + rng = random.Random(4242) + trials = 0 + for _ in range(300): + specs = rng.sample(_CONVERGENCE_OP_SPECS, k=rng.randint(2, 4)) + pool = [_make_convergence_op(s, rng, g) for s in specs] + free: list[dict] = [] + for op in pool: # greedily keep a maximal conflict-free subset + if all(not ops.detect_conflict(op, kept) for kept in free): + free.append(op) + if len(free) < 2: + continue + perms = list(itertools.permutations(free)) + if len(perms) > 24: + perms = rng.sample(perms, 24) + cans = [] + for perm in perms: + wf = _convergence_base() + for op in perm: + wf = ops.apply_op(wf, op, g) + cans.append(ops.canonical(wf)) + for c in cans[1:]: + assert c == cans[0], "conflict-free set diverged across apply orders" + trials += 1 + assert trials > 50 + + def test_p13_canonical_is_a_sound_equality_oracle(self): + """``canonical`` must fold away ONLY immaterial detail (apply + bookkeeping, node/link/def ordering, a grown slot's display name) and + must PRESERVE every material difference — otherwise a real divergence + could hide behind a false ``canonical`` match.""" + ops = self._ops() + g = _graph() + base = _convergence_base() + c0 = ops.canonical(base) + assert c0 == ops.canonical(copy.deepcopy(base)) # stable / reflexive + + # Immaterial differences must NOT change canonical. + immaterial = copy.deepcopy(base) + immaterial["nodes"] = list(reversed(immaterial["nodes"])) + immaterial["links"] = list(reversed(immaterial["links"])) + immaterial["_applied_ops"] = ["deadbeef"] + immaterial["_widget_stamps"] = {"('widget', 3, 'steps')": [9, "z", "op"]} + assert ops.canonical(immaterial) == c0 + + # Material differences MUST change canonical. + widget = copy.deepcopy(base) + next(n for n in widget["nodes"] if n["id"] == 3)["widgets_values"][2] = 999 + assert ops.canonical(widget) != c0 # a changed widget value + removed_node = copy.deepcopy(base) + removed_node["nodes"] = [n for n in removed_node["nodes"] if n["id"] != 7] + assert ops.canonical(removed_node) != c0 # a removed node + removed_link = copy.deepcopy(base) + removed_link["links"] = [] + assert ops.canonical(removed_link) != c0 # a removed link + + # Autogrow: the grown slot's DISPLAY NAME is immaterial (order-dependent), + # but WHICH SOURCE it wires is material. Prove canonical draws that line. + _, o20 = ops.connect(_convergence_base(), g, 20, "IMAGE", 10, "images", actor="a") + _, o21 = ops.connect(_convergence_base(), g, 21, "IMAGE", 10, "images", actor="b") + grown = ops.apply_op(ops.apply_op(_convergence_base(), o20, g), o21, g) + renamed = copy.deepcopy(grown) + for inp in next(n for n in renamed["nodes"] if n["id"] == 10)["inputs"]: + if inp.get("grow_id") is not None: + inp["name"] = f"images.renamed{inp['grow_id']}" # cosmetic only + assert ops.canonical(renamed) == ops.canonical(grown) # name is immaterial + rewired = copy.deepcopy(grown) + for ln in rewired["links"]: + if ln[1] == 21: + ln[1] = 20 # a grown slot now sourced from a different node + assert ops.canonical(rewired) != ops.canonical(grown) # source is material + + +class TestOpResolutionSuggestions: + """The edit ops enrich a *not-found* error with the real id/address, so an + agent that rebuilt an identifier from memory (hitting a wrong node, a real + sibling, or a nonexistent id) self-corrects in one step instead of looping. + Covers the whole edit surface: set_widget, connect, delete_node.""" + + def test_set_widget_wrong_node_suggests_the_widgets_real_address(self): + # 'steps' lives on KSampler (3), not EmptyLatentImage (7). + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError, match=r"Did you mean:.*3\.steps \(KSampler\)"): + workflow_ops.set_widget(wf, g, 7, "steps", 20) + + def test_set_widget_missing_node_suggests_the_widgets_real_address(self): + # Node 999 doesn't exist (mirrors a wrong id/separator); 'steps' is on 3. + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError, match=r"Did you mean:.*3\.steps \(KSampler\)"): + workflow_ops.set_widget(wf, g, 999, "steps", 20) + + def test_set_widget_unknown_widget_no_false_suggestion(self): + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError) as ei: + workflow_ops.set_widget(wf, g, 3, "no_such_widget", 1) + assert "Did you mean" not in str(ei.value) + + def test_set_widget_shape_error_is_not_enriched(self): + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError) as ei: + workflow_ops.set_widget(wf, g, 3, "steps", "not_an_int") + assert "Did you mean" not in str(ei.value) + + def test_connect_missing_node_lists_available_nodes(self): + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError, match=r"Nodes in this workflow:.*KSampler"): + workflow_ops.connect(wf, g, 999, "LATENT", 3, "latent_image") + + def test_delete_missing_node_lists_available_nodes(self): + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError, match=r"Nodes in this workflow:.*KSampler"): + workflow_ops.delete_node(wf, g, 999) + + +class TestSetWidgetModelNormalization: + """set_widget auto-corrects a mangled COMBO/model value to the real option so + the model actually loads even when the agent rebuilds the name from memory + (e.g. adds a directory prefix) — the reliable fix for 'model not found'.""" + + def test_prefixed_combo_value_is_normalized_in_the_op(self): + g, wf = _graph(), _base_workflow() + _, op = workflow_ops.set_widget(wf, g, 3, "sampler_name", "samplers/euler") + assert op["value"] == "euler" # the real option, prefix stripped + assert any(w.get("code") == "normalized_value" for w in op.get("warnings", [])) + + def test_exact_value_is_untouched_and_unwarned(self): + g, wf = _graph(), _base_workflow() + _, op = workflow_ops.set_widget(wf, g, 3, "sampler_name", "euler") + assert op["value"] == "euler" + assert not any(w.get("code") == "normalized_value" for w in op.get("warnings", [])) + + def test_unknown_value_is_left_for_validate_to_flag(self): + g, wf = _graph(), _base_workflow() + _, op = workflow_ops.set_widget(wf, g, 3, "sampler_name", "totally_made_up") + assert op["value"] == "totally_made_up" # not silently changed + assert any(w.get("code") == "unknown_enum_value" for w in op.get("warnings", [])) + + +class TestWhereInvalid: + """A bad --where surfaces the agent-first error envelope, not a raw traceback. + + Regression: _get_graph only caught LoadError, so resolve_default's ValueError + on an invalid --where escaped uncaught out of every edit command. + """ + + def test_set_widget_bad_where_emits_envelope(self, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "3.seed", "5", "--where", "clowd"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "where_invalid" diff --git a/tests/comfy_cli/command/test_workflow_edit_cloud.py b/tests/comfy_cli/command/test_workflow_edit_cloud.py new file mode 100644 index 00000000..c36b8a53 --- /dev/null +++ b/tests/comfy_cli/command/test_workflow_edit_cloud.py @@ -0,0 +1,153 @@ +"""Cloud red→green for the structured-edit primitives — the real-vendor gate. + +Unlike the offline unit tests (which stub `object_info`), these build a graph +against the **live Comfy Cloud node catalog** and prove it converts + submits. +This is the test that catches drift between our primitives and the real schemas. + +Gating (so the default suite stays offline + free): + * All tests skip unless `COMFY_CLOUD_E2E=1` AND a cloud session exists + (`comfy cloud login`). + * The submit test additionally needs `COMFY_CLOUD_E2E_RUN=1` — it spends credits. + +Run after login: + COMFY_CLOUD_E2E=1 uv run --extra dev pytest tests/comfy_cli/command/test_workflow_edit_cloud.py -v + # include a real job submission (spends credits): + COMFY_CLOUD_E2E=1 COMFY_CLOUD_E2E_RUN=1 uv run --extra dev pytest tests/comfy_cli/command/test_workflow_edit_cloud.py -v +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +def _cloud_ready() -> bool: + """True iff a cloud target with usable credentials is configured.""" + try: + from comfy_cli.target import resolve_target + + t = resolve_target(where="cloud") + except Exception: + return False + return bool(getattr(t, "api_key", None) or getattr(t, "auth_token", None)) + + +pytestmark = pytest.mark.skipif( + not (os.environ.get("COMFY_CLOUD_E2E") and _cloud_ready()), + reason="cloud e2e: set COMFY_CLOUD_E2E=1 and run `comfy cloud login` first", +) + + +def _cloud_object_info() -> dict: + from comfy_cli.cql.loader import resilient_load_object_info + + return resilient_load_object_info(mode="cloud", host="127.0.0.1", port=8188) + + +def _first_enum(graph, class_type: str, widget: str): + """A real, catalog-valid value for a COMBO widget (e.g. a checkpoint name).""" + m = graph.node(class_type) + if m is None: + pytest.skip(f"cloud catalog has no {class_type}") + port = next((p for p in m.inputs if p.name == widget), None) + if port is None or not port.enum_values: + pytest.skip(f"cloud catalog exposes no choices for {class_type}.{widget}") + return port.enum_values[0] + + +def _build_txt2img(graph): + """Build a minimal txt2img graph with the edit primitives, using real + catalog values. Returns (workflow, id_map).""" + from comfy_cli import workflow_ops as w + + wf = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + ids: dict[str, int] = {} + for key, ct in [ + ("ckpt", "CheckpointLoaderSimple"), + ("pos", "CLIPTextEncode"), + ("neg", "CLIPTextEncode"), + ("latent", "EmptyLatentImage"), + ("ks", "KSampler"), + ("vae", "VAEDecode"), + ("save", "SaveImage"), + ]: + wf, op = w.add_node(wf, graph, ct) + ids[key] = op["node_id"] + + def C(a, aslot, b, bslot): + nonlocal wf + wf, _ = w.connect(wf, graph, ids[a], aslot, ids[b], bslot) + + C("ckpt", "MODEL", "ks", "model") + C("ckpt", "CLIP", "pos", "clip") + C("ckpt", "CLIP", "neg", "clip") + C("pos", "CONDITIONING", "ks", "positive") + C("neg", "CONDITIONING", "ks", "negative") + C("latent", "LATENT", "ks", "latent_image") + C("ks", "LATENT", "vae", "samples") + C("ckpt", "VAE", "vae", "vae") + C("vae", "IMAGE", "save", "images") + + wf, _ = w.set_widget(wf, graph, ids["ckpt"], "ckpt_name", _first_enum(graph, "CheckpointLoaderSimple", "ckpt_name")) + wf, _ = w.set_widget(wf, graph, ids["pos"], "text", "a serene mountain lake at dawn") + wf, _ = w.set_widget(wf, graph, ids["neg"], "text", "blurry, low quality") + wf, _ = w.set_widget(wf, graph, ids["ks"], "steps", 8) + return wf, ids + + +def test_build_txt2img_against_live_cloud_catalog(): + """RED→GREEN: the primitives must produce a graph that converts cleanly + against the REAL cloud schemas (not a stub).""" + from comfy_cli.cql.engine import Graph + from comfy_cli.workflow_to_api import convert_ui_to_api + + oi = _cloud_object_info() + graph = Graph.from_object_info(oi) + wf, ids = _build_txt2img(graph) + + api = convert_ui_to_api(wf, oi) + # every node survived conversion + for key, nid in ids.items(): + assert str(nid) in api, f"{key} (node {nid}) dropped by converter" + # KSampler's model input resolved to the checkpoint node (link not dropped) + ks = api[str(ids["ks"])] + assert ks["inputs"]["model"][0] == str(ids["ckpt"]) + # no null required enum left behind (the add-node default-fill guarantee) + assert api[str(ids["ckpt"])]["inputs"]["ckpt_name"] is not None + + +@pytest.mark.skipif( + not os.environ.get("COMFY_CLOUD_E2E_RUN"), reason="submit spends credits: set COMFY_CLOUD_E2E_RUN=1" +) +def test_submit_built_graph_to_cloud(tmp_path): + """RED→GREEN (credit-gated): a primitive-built graph submits to cloud and + returns a prompt_id. Runs the real `comfy run --where cloud`.""" + from comfy_cli.cql.engine import Graph + + graph = Graph.from_object_info(_cloud_object_info()) + wf, _ = _build_txt2img(graph) + wf_path = tmp_path / "built.json" + wf_path.write_text(json.dumps(wf), encoding="utf-8") + + proc = subprocess.run( + [sys.executable, "-m", "comfy_cli", "--json", "run", "--workflow", str(wf_path), "--where", "cloud"], + capture_output=True, + text=True, + timeout=180, + cwd=str(Path(__file__).resolve().parents[3]), + ) + env = None + for line in reversed([ln for ln in proc.stdout.strip().splitlines() if ln.strip()]): + try: + env = json.loads(line) + break + except json.JSONDecodeError: + continue + assert env is not None, f"no envelope (rc={proc.returncode}, stderr={proc.stderr[:500]})" + assert env["ok"] is True, env.get("error") + assert env["data"].get("prompt_id"), f"expected a prompt_id, got {env['data']}" diff --git a/tests/comfy_cli/conftest.py b/tests/comfy_cli/conftest.py index ba177eb9..b38d1540 100644 --- a/tests/comfy_cli/conftest.py +++ b/tests/comfy_cli/conftest.py @@ -57,6 +57,23 @@ def _isolate_config_path(tmp_path, monkeypatch): yield fake_root +@pytest.fixture(autouse=True) +def _isolate_object_info_cache_dir(tmp_path, monkeypatch): + """Redirect the ``object_info`` disk cache to a per-test tmp dir. + + ``resilient_load_object_info`` (comfy_cli.cql.loader) reads/writes + ``~/.cache/comfy-cli/object_info-*.json`` (or ``$XDG_CACHE_HOME``) as a + side effect of every cache-first fetch. Now that `comfy run`'s UI-convert + and preflight-validate call sites route through it too, any test that + exercises those paths would otherwise read stale state from — or write + real dumps into — the developer's actual cache directory. + """ + fake = tmp_path / "comfy-cli-cache" + fake.mkdir(mode=0o700, parents=True, exist_ok=True) + monkeypatch.setenv("XDG_CACHE_HOME", str(fake)) + yield fake + + @pytest.fixture(autouse=True) def _isolate_jobs_state_dir(tmp_path, monkeypatch): """Redirect ``jobs_state.state_dir`` to a per-test tmp dir. diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index dc26e677..1a84792f 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -167,8 +167,11 @@ def _object_info() -> dict[str, Any]: "fps": [[25, 50], {"default": 25}], "resolution": ["COMBO", {"options": ["1920x1080", "2560x1440"], "default": "1920x1080"}], }, + "optional": { + "seed": ["INT", {"default": 0, "min": 0, "max": 2**31 - 1}], + }, }, - "input_order": {"required": ["prompt", "duration", "fps", "resolution"]}, + "input_order": {"required": ["prompt", "duration", "fps", "resolution"], "optional": ["seed"]}, "output": ["VIDEO"], "output_name": ["VIDEO"], "category": "partner/video/LTXV", @@ -294,6 +297,85 @@ def test_unknown_node_returns_empty(self, graph: Graph): assert order == [] +class TestWidgetOrderForNode: + """graph.widget_order_for_node — dynamic combos expand by the node's ACTUAL + selected key, not the schema's first key (regression for set-widget writing + into the wrong slot when the selection expands to a different sub-widget count).""" + + @staticmethod + def _dyn_graph() -> Graph: + # `model` is a dynamic combo: key "a" → 1 sub-widget, key "b" → 2. `seed` + # follows it, so its slot index depends on which key is selected. + return Graph.from_object_info( + { + "DynNode": { + "input": { + "required": { + "model": [ + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + { + "key": "a", + "inputs": { + "required": {"res": ["COMBO", {"options": ["x", "y"], "default": "x"}]} + }, + }, + { + "key": "b", + "inputs": { + "required": { + "res": ["COMBO", {"options": ["x", "y"], "default": "x"}], + "quality": ["COMBO", {"options": ["lo", "hi"], "default": "lo"}], + } + }, + }, + ] + }, + ], + "seed": ["INT", {"default": 0}], + } + }, + "input_order": {"required": ["model", "seed"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "test", + "display_name": "Dyn", + "python_module": "nodes", + } + } + ) + + def test_static_order_uses_first_key(self): + g = self._dyn_graph() + assert g.widget_order("DynNode") == ["model", "model.res", "seed"] + + def test_node_order_expands_selected_key(self): + g = self._dyn_graph() + # Selecting "b" adds model.quality, pushing seed to index 3. + order = g.widget_order_for_node("DynNode", ["b", "x", "hi", 12345]) + assert order == ["model", "model.res", "model.quality", "seed"] + assert order.index("seed") == 3 + + def test_node_order_first_key_matches_static(self): + g = self._dyn_graph() + assert g.widget_order_for_node("DynNode", ["a", "x", 999]) == ["model", "model.res", "seed"] + + def test_empty_widgets_falls_back_to_static(self): + g = self._dyn_graph() + assert g.widget_order_for_node("DynNode", []) == g.widget_order("DynNode") + + def test_set_widget_writes_seed_to_selected_slot(self): + """End-to-end: set-widget on a "b"-selected node must land seed at index 3, + not overwrite model.quality at index 2.""" + from comfy_cli import workflow_ops + + g = self._dyn_graph() + wf = {"nodes": [{"id": 3, "type": "DynNode", "widgets_values": ["b", "x", "hi", 111]}]} + workflow_ops.set_widget(wf, g, 3, "seed", 424242, actor="cli", base_version=0) + assert wf["nodes"][0]["widgets_values"] == ["b", "x", "hi", 424242] + + # =========================================================================== # TestTraversal # =========================================================================== @@ -490,7 +572,9 @@ def test_unknown_enum_value(self, graph: Graph): assert "euler" in errs[0]["suggestions"] def test_valid_edges_pass(self, graph: Graph): - """Well-wired edges don't produce errors.""" + """Well-wired edges don't produce edge errors. (The KSampler is + deliberately partial — its missing required inputs surface as + missing_required_input, which is a separate check.)""" wf = { "1": { "class_type": "CheckpointLoaderSimple", @@ -510,6 +594,35 @@ def test_valid_edges_pass(self, graph: Graph): }, } result = graph.validate_workflow(wf) + edge_codes = {"dangling_edge", "output_index_out_of_range", "edge_type_mismatch"} + assert [e for e in result["errors"] if e["code"] in edge_codes] == [] + assert all(e["code"] == "missing_required_input" for e in result["errors"]) + + def test_missing_required_input_is_error(self, graph: Graph): + """A required input that is simply ABSENT must fail validate — the + server rejects it ("Required input is missing"), so a clean pass here + is a false green. Regression: emitted partner-node workflows omitted + inputs entirely and still validated.""" + wf = self._valid_workflow() + del wf["2"]["inputs"]["steps"] # widget input + del wf["2"]["inputs"]["model"] # link input + result = graph.validate_workflow(wf) + assert result["valid"] is False + errs = {e["field"]: e for e in result["errors"] if e["code"] == "missing_required_input"} + assert set(errs) == {"steps", "model"} + assert errs["steps"]["node_id"] == "2" + # The hint should surface the schema default when there is one. + assert "20" in errs["steps"]["hint"] + + def test_missing_optional_input_is_not_error(self, graph: Graph): + """Optional inputs may be omitted freely (LtxvApiTextToVideo.seed).""" + wf = { + "1": { + "class_type": "LtxvApiTextToVideo", + "inputs": {"prompt": "a boat", "duration": 8, "fps": 25, "resolution": "1920x1080"}, + }, + } + result = graph.validate_workflow(wf) assert result["valid"] is True assert result["errors"] == [] @@ -552,17 +665,9 @@ def test_edge_type_mismatch(self, graph: Graph): This is advisory (warning, not error) — ComfyUI allows cross-type wiring via reroutes and converters; the server is the authority.""" - wf = { - "1": { - "class_type": "CheckpointLoaderSimple", - "inputs": {"ckpt_name": "sd_xl_base.safetensors"}, - }, - "2": { - "class_type": "KSampler", - # Output index 1 is CLIP, but model input expects MODEL - "inputs": {"model": ["1", 1]}, - }, - } + wf = self._valid_workflow() + # Output index 1 is CLIP, but the model input expects MODEL. + wf["2"]["inputs"]["model"] = ["1", 1] result = graph.validate_workflow(wf) # edge_type_mismatch is a warning, not a hard error assert result["valid"] is True @@ -760,8 +865,11 @@ def test_dotted_slots_validate_clean(self, graph: Graph): }, } result = graph.validate_workflow(wf) - assert result["valid"] is True, result["errors"] # The dotted slots must not trip type-mismatch or unknown-input noise. + # (The bare VAEDecode loaders legitimately miss their own required + # links — scope the check to the autogrow node.) + errs_autogrow = [e for e in result["errors"] if e["node_id"] == "20"] + assert errs_autogrow == [], errs_autogrow assert result["warnings"] == [] def test_bare_link_wiring_errors_with_slot_hint(self, graph: Graph): @@ -880,6 +988,39 @@ def test_apply_returns_catalog_warnings(self, graph: Graph): assert "above_max" in codes +class TestSlotSuggestionOnNotFound: + """A not-found address is enriched with the real address that carries the + intended widget, so an agent that targeted the wrong node/separator (the + common LLM failure of rebuilding an address from memory) self-corrects in + one step instead of looping.""" + + def test_wrong_node_right_widget_suggests_correct_address(self, graph: Graph): + # 'text' lives on the CLIPTextEncode (node 6), not EmptyLatentImage (7). + wf = _direct_workflow() + with pytest.raises(ValueError, match=r"Did you mean:.*6\.text \(CLIPTextEncode\)"): + _apply_one_slot(wf, "7.text", "x", graph) + + def test_missing_node_right_widget_suggests_correct_address(self, graph: Graph): + # Node 999 doesn't exist (mirrors a wrong id/separator); 'seed' is on KSampler 3. + wf = _direct_workflow() + with pytest.raises(ValueError, match=r"Did you mean:.*3\.seed \(KSampler\)"): + _apply_one_slot(wf, "999.seed", 1, graph) + + def test_unknown_widget_name_gets_no_false_suggestion(self, graph: Graph): + # No node carries 'nonexistent' → original error, no "Did you mean". + wf = _direct_workflow() + with pytest.raises(ValueError) as ei: + _apply_one_slot(wf, "3.nonexistent", 1, graph) + assert "Did you mean" not in str(ei.value) + + def test_shape_error_is_not_enriched(self, graph: Graph): + # The widget resolved fine; a shape rejection must pass through untouched. + wf = _direct_workflow() + with pytest.raises(ValueError) as ei: + _apply_one_slot(wf, "3.seed", "not_an_int", graph) + assert "Did you mean" not in str(ei.value) + + # =========================================================================== # TestTemplateModeSlots # =========================================================================== @@ -1234,3 +1375,56 @@ def test_load_from_target_refuses_non_loopback_local_host(): with pytest.raises(LoadError, match="non-loopback"): _load_from_target(mode="local", host="example.com", port=8188) + + +class TestComboNormalizationAndSuggestions: + """Port.canonical_combo rewrites a mangled model value (dir prefix / dropped + subfolder / case drift) to the real option when unambiguous; suggest_combo + + validate_catalog.did_you_mean point a rejected value at the nearest options.""" + + def _port(self): + from comfy_cli.cql.engine import Port + + return Port( + name="ckpt_name", + type="COMBO", + enum_values=["sd_xl_base.safetensors", "v1-5-pruned.safetensors", "sub/model_x.safetensors"], + ) + + def test_canonical_strips_added_directory_prefix(self): + p = self._port() + assert p.canonical_combo("checkpoints/sd_xl_base.safetensors") == "sd_xl_base.safetensors" + + def test_canonical_matches_dropped_subfolder_by_basename(self): + p = self._port() + assert p.canonical_combo("model_x.safetensors") == "sub/model_x.safetensors" + + def test_canonical_case_insensitive(self): + p = self._port() + assert p.canonical_combo("SD_XL_BASE.SAFETENSORS") == "sd_xl_base.safetensors" + + def test_canonical_exact_value_returns_none(self): + p = self._port() + assert p.canonical_combo("sd_xl_base.safetensors") is None + + def test_canonical_unknown_returns_none(self): + p = self._port() + assert p.canonical_combo("realisticVisionV60B1.safetensors") is None + + def test_canonical_ambiguous_basename_returns_none(self): + from comfy_cli.cql.engine import Port + + p = Port(name="ckpt_name", type="COMBO", enum_values=["a/dup.safetensors", "b/dup.safetensors"]) + assert p.canonical_combo("dup.safetensors") is None # two matches → don't guess + + def test_suggest_returns_close_options(self): + p = self._port() + got = p.suggest_combo("sd_xl_bas.safetensors") + assert "sd_xl_base.safetensors" in got + + def test_validate_catalog_adds_did_you_mean(self): + p = self._port() + w = p.validate_catalog("v1-5-prund.safetensors") # typo + assert w and w[0]["code"] == "unknown_enum_value" + assert "did_you_mean" in w[0] + assert "v1-5-pruned.safetensors" in w[0]["did_you_mean"] diff --git a/tests/comfy_cli/cql/test_loader_resilient.py b/tests/comfy_cli/cql/test_loader_resilient.py index 1bfce55c..2a346653 100644 --- a/tests/comfy_cli/cql/test_loader_resilient.py +++ b/tests/comfy_cli/cql/test_loader_resilient.py @@ -43,6 +43,8 @@ def _isolated_cache(tmp_path, monkeypatch): """Point the cache dir at a throwaway tmp dir for every test.""" cache_root = tmp_path / "cache" monkeypatch.setenv("XDG_CACHE_HOME", str(cache_root)) + # Don't let a developer's TTL override leak into the tests. + monkeypatch.delenv(loader.OBJECT_INFO_TTL_ENV, raising=False) # Make the host-key resolution deterministic and I/O-free. monkeypatch.setattr(loader, "_resolve_host_key", lambda mode, host, port: "https://test.comfy.org") return cache_root @@ -182,6 +184,9 @@ def test_persistent_failure_falls_back_to_cache_with_warning(monkeypatch): import comfy_cli.cql.engine as engine _fake_refresh(monkeypatch) + # Disable the cache-first TTL gate so the freshly-seeded cache does not + # short-circuit the fetch — this test exercises the *failure* fallback. + monkeypatch.setenv(loader.OBJECT_INFO_TTL_ENV, "0") # Seed the cache with a (stale) dump. loader.write_object_info_cache("https://test.comfy.org", STALE_OBJECT_INFO) @@ -204,6 +209,7 @@ def test_connection_error_also_falls_back_to_cache(monkeypatch): import comfy_cli.cql.engine as engine _fake_refresh(monkeypatch) + monkeypatch.setenv(loader.OBJECT_INFO_TTL_ENV, "0") loader.write_object_info_cache("https://test.comfy.org", STALE_OBJECT_INFO) def _offline(**kw): diff --git a/tests/comfy_cli/cql/test_loader_ttl.py b/tests/comfy_cli/cql/test_loader_ttl.py new file mode 100644 index 00000000..9e0d63e0 --- /dev/null +++ b/tests/comfy_cli/cql/test_loader_ttl.py @@ -0,0 +1,261 @@ +"""Cache-first TTL tests for ``resilient_load_object_info``. + +The loader serves a per-host cache entry younger than the TTL (default 10 +minutes, ``COMFY_OBJECT_INFO_TTL`` seconds to override, ``0`` = always fetch) +without any network call. These tests pin that policy: + + - a fresh cache hit never touches the network, + - an expired entry refetches live, + - TTL=0 bypasses the gate entirely, + - entries are keyed per target base URL (cloud vs local never collide), + - a fetch failure still falls back to the stale cache even past the TTL. + +``engine._load_from_target`` is always mocked — no sockets are opened. +""" + +from __future__ import annotations + +import os + +import pytest + +from comfy_cli.cql import loader +from comfy_cli.cql.engine import LoadError + +CACHED = {"CachedNode": {"input": {"required": {}}, "output": [], "category": "cached"}} +LIVE = {"LiveNode": {"input": {"required": {}}, "output": [], "category": "live"}} + +CLOUD_KEY = "https://cloud.test.comfy.org" +LOCAL_KEY = "http://127.0.0.1:8188" + + +@pytest.fixture(autouse=True) +def _isolated_cache(tmp_path, monkeypatch): + """Throwaway cache dir; no TTL override leaking in from the dev env.""" + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + monkeypatch.delenv(loader.OBJECT_INFO_TTL_ENV, raising=False) + + +def _pin_host_key(monkeypatch, key: str) -> None: + monkeypatch.setattr(loader, "_resolve_host_key", lambda mode, host, port: key) + + +def _expire_cache(host_key: str, age_seconds: float) -> None: + """Backdate the cache file's mtime so the entry reads as ``age_seconds`` old.""" + path = loader.object_info_cache_path(host_key) + stamp = path.stat().st_mtime - age_seconds + os.utime(path, (stamp, stamp)) + + +def _forbid_network(monkeypatch): + """Fail loudly if the live fetch runs; return the call counter.""" + import comfy_cli.cql.engine as engine + + calls = {"n": 0} + + def _boom(**kw): + calls["n"] += 1 + raise AssertionError("network fetch must not run on a fresh cache hit") + + monkeypatch.setattr(engine, "_load_from_target", _boom) + return calls + + +# --------------------------------------------------------------------------- +# fresh cache hit → no network call +# --------------------------------------------------------------------------- + + +def test_fresh_cache_hit_skips_network(monkeypatch): + _pin_host_key(monkeypatch, CLOUD_KEY) + calls = _forbid_network(monkeypatch) + loader.write_object_info_cache(CLOUD_KEY, CACHED) + + result = loader.resilient_load_object_info(mode="cloud", host="h", port=1) + + assert result == CACHED + assert calls["n"] == 0 + + +def test_fresh_hit_respects_custom_ttl(monkeypatch): + """An entry older than the default TTL is still fresh under a larger one.""" + _pin_host_key(monkeypatch, CLOUD_KEY) + calls = _forbid_network(monkeypatch) + loader.write_object_info_cache(CLOUD_KEY, CACHED) + _expire_cache(CLOUD_KEY, age_seconds=3600) # 1h old — past the 10m default + monkeypatch.setenv(loader.OBJECT_INFO_TTL_ENV, "7200") + + result = loader.resilient_load_object_info(mode="cloud", host="h", port=1) + + assert result == CACHED + assert calls["n"] == 0 + + +# --------------------------------------------------------------------------- +# expired entry → live refetch (and cache rewrite) +# --------------------------------------------------------------------------- + + +def test_expired_ttl_refetches(monkeypatch): + import comfy_cli.cql.engine as engine + + _pin_host_key(monkeypatch, CLOUD_KEY) + loader.write_object_info_cache(CLOUD_KEY, CACHED) + _expire_cache(CLOUD_KEY, age_seconds=loader.DEFAULT_OBJECT_INFO_TTL_SECONDS + 1) + + calls = {"n": 0} + + def _live(**kw): + calls["n"] += 1 + return LIVE + + monkeypatch.setattr(engine, "_load_from_target", _live) + + result = loader.resilient_load_object_info(mode="cloud", host="h", port=1) + + assert result == LIVE + assert calls["n"] == 1 + # The refetch rewrote the cache with the live payload. + assert loader.read_object_info_cache(CLOUD_KEY) == LIVE + + +# --------------------------------------------------------------------------- +# TTL=0 → always fetch live, even with a brand-new cache entry +# --------------------------------------------------------------------------- + + +def test_ttl_zero_bypasses_cache(monkeypatch): + import comfy_cli.cql.engine as engine + + _pin_host_key(monkeypatch, CLOUD_KEY) + loader.write_object_info_cache(CLOUD_KEY, CACHED) # fresh, would hit + monkeypatch.setenv(loader.OBJECT_INFO_TTL_ENV, "0") + + calls = {"n": 0} + + def _live(**kw): + calls["n"] += 1 + return LIVE + + monkeypatch.setattr(engine, "_load_from_target", _live) + + result = loader.resilient_load_object_info(mode="cloud", host="h", port=1) + + assert result == LIVE + assert calls["n"] == 1 + + +# --------------------------------------------------------------------------- +# per-target keying: a fresh cloud entry must not satisfy a local lookup +# --------------------------------------------------------------------------- + + +def test_fresh_entry_for_other_target_does_not_hit(monkeypatch): + import comfy_cli.cql.engine as engine + + loader.write_object_info_cache(CLOUD_KEY, CACHED) # fresh, but for cloud + _pin_host_key(monkeypatch, LOCAL_KEY) # this call targets local + + calls = {"n": 0} + + def _live(**kw): + calls["n"] += 1 + return LIVE + + monkeypatch.setattr(engine, "_load_from_target", _live) + + result = loader.resilient_load_object_info(mode="local", host="127.0.0.1", port=8188) + + assert result == LIVE # served live, never the cloud entry + assert calls["n"] == 1 + # Each target keeps its own entry. + assert loader.read_object_info_cache(CLOUD_KEY) == CACHED + assert loader.read_object_info_cache(LOCAL_KEY) == LIVE + + +def test_local_never_serves_fresh_cache(monkeypatch): + """Cache-first TTL is cloud-only: a fresh LOCAL entry is NOT served, so a + node just installed into the user's own server is visible immediately. The + live fetch still runs (and rewrites the cache for the failure fallback).""" + import comfy_cli.cql.engine as engine + + _pin_host_key(monkeypatch, LOCAL_KEY) + loader.write_object_info_cache(LOCAL_KEY, CACHED) # fresh local entry + + calls = {"n": 0} + + def _live(**kw): + calls["n"] += 1 + return LIVE + + monkeypatch.setattr(engine, "_load_from_target", _live) + + result = loader.resilient_load_object_info(mode="local", host="127.0.0.1", port=8188) + + assert result == LIVE # live, not the fresh cache + assert calls["n"] == 1 + assert loader.read_object_info_cache(LOCAL_KEY) == LIVE # cache rewritten for failure fallback + + +# --------------------------------------------------------------------------- +# expired entry + fetch failure → stale fallback still works +# --------------------------------------------------------------------------- + + +def test_expired_entry_still_serves_as_stale_fallback(monkeypatch): + import comfy_cli.cloud.oauth as oauth + import comfy_cli.cql.engine as engine + + _pin_host_key(monkeypatch, CLOUD_KEY) + monkeypatch.setattr(oauth, "ensure_fresh_session", lambda **kw: None) + loader.write_object_info_cache(CLOUD_KEY, CACHED) + _expire_cache(CLOUD_KEY, age_seconds=loader.DEFAULT_OBJECT_INFO_TTL_SECONDS + 1) + + def _offline(**kw): + raise LoadError("cannot reach the server: offline") + + monkeypatch.setattr(engine, "_load_from_target", _offline) + + warnings: list[str] = [] + result = loader.resilient_load_object_info(mode="cloud", host="h", port=1, _warn=warnings.append) + + assert result == CACHED + assert len(warnings) == 1 + assert "stale" in warnings[0].lower() + + +# --------------------------------------------------------------------------- +# TTL env parsing +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, loader.DEFAULT_OBJECT_INFO_TTL_SECONDS), # unset → default + ("", loader.DEFAULT_OBJECT_INFO_TTL_SECONDS), # blank → default + (" ", loader.DEFAULT_OBJECT_INFO_TTL_SECONDS), # whitespace → default + ("garbage", loader.DEFAULT_OBJECT_INFO_TTL_SECONDS), # unparseable → default + ("0", 0.0), + ("-5", 0.0), # negative clamps to bypass + ("30", 30.0), + ("1.5", 1.5), + ], +) +def test_object_info_cache_ttl_parsing(monkeypatch, raw, expected): + if raw is None: + monkeypatch.delenv(loader.OBJECT_INFO_TTL_ENV, raising=False) + else: + monkeypatch.setenv(loader.OBJECT_INFO_TTL_ENV, raw) + assert loader.object_info_cache_ttl() == expected + + +def test_read_fresh_missing_file_returns_none(): + assert loader.read_fresh_object_info_cache("https://nope.example", ttl=600) is None + + +def test_read_fresh_future_mtime_treated_as_expired(monkeypatch): + """Clock skew: an mtime in the future must not count as fresh.""" + loader.write_object_info_cache(CLOUD_KEY, CACHED) + _expire_cache(CLOUD_KEY, age_seconds=-3600) # 1h in the future + assert loader.read_fresh_object_info_cache(CLOUD_KEY, ttl=600) is None diff --git a/tests/comfy_cli/cql/test_object_info_env.py b/tests/comfy_cli/cql/test_object_info_env.py new file mode 100644 index 00000000..fda1ae65 --- /dev/null +++ b/tests/comfy_cli/cql/test_object_info_env.py @@ -0,0 +1,67 @@ +"""COMFY_OBJECT_INFO_FILE is honored by the single object_info loader, so EVERY +CQL consumer routed through it — workflow edits, `nodes show`/`find`, `validate`, +fragments — resolves the node schema from a baked/pre-warmed file offline: no +network fetch, no cloud credential. A host sets one env var instead of threading +`--input` through each command.""" + +from __future__ import annotations + +import comfy_cli.cql.engine as engine +import comfy_cli.cql.loader as loader + + +def test_resilient_load_honors_object_info_file_env(monkeypatch, tmp_path): + dump = tmp_path / "object_info.json" + dump.write_text('{"KSampler": {}}') + seen: dict[str, str] = {} + + def fake_load(p): + seen["path"] = p + return {"ok": True} + + monkeypatch.setattr(engine, "_load_from_file", fake_load) + # The network path must NOT run when the env dump is set. + monkeypatch.setattr( + engine, + "_load_from_target", + lambda **_: (_ for _ in ()).throw( + AssertionError("network fetch should not run with COMFY_OBJECT_INFO_FILE set") + ), + ) + monkeypatch.setenv("COMFY_OBJECT_INFO_FILE", str(dump)) + + out = loader.resilient_load_object_info(mode="cloud") + + assert out == {"ok": True} + assert seen["path"] == str(dump), "no --input => COMFY_OBJECT_INFO_FILE is read" + + +def test_explicit_input_wins_over_env(monkeypatch, tmp_path): + env_dump = tmp_path / "env.json" + env_dump.write_text("{}") + explicit = tmp_path / "explicit.json" + explicit.write_text("{}") + seen: dict[str, str] = {} + monkeypatch.setattr(engine, "_load_from_file", lambda p: seen.setdefault("path", p) or {}) + monkeypatch.setenv("COMFY_OBJECT_INFO_FILE", str(env_dump)) + + loader.resilient_load_object_info(mode="cloud", input_path=str(explicit)) + + assert seen["path"] == str(explicit), "explicit --input overrides the env default" + + +def test_no_env_falls_through_to_network(monkeypatch): + # Neither --input nor the env var: the loader proceeds to the cache/network + # path (asserted by _load_from_file NOT being called with an env path). + called: dict[str, bool] = {} + monkeypatch.setattr(engine, "_load_from_file", lambda p: called.setdefault("file", True) or {}) + monkeypatch.setattr(engine, "_load_from_target", lambda **_: {"from": "network"}) + monkeypatch.delenv("COMFY_OBJECT_INFO_FILE", raising=False) + # A fresh cache miss forces the network path. + monkeypatch.setattr(loader, "read_fresh_object_info_cache", lambda *a, **k: None) + monkeypatch.setattr(loader, "write_object_info_cache", lambda *a, **k: None) + + out = loader.resilient_load_object_info(mode="cloud") + + assert out == {"from": "network"} + assert "file" not in called, "no env => the offline file path is not taken" diff --git a/tests/comfy_cli/skills/test_installer.py b/tests/comfy_cli/skills/test_installer.py index 09297223..c087ba92 100644 --- a/tests/comfy_cli/skills/test_installer.py +++ b/tests/comfy_cli/skills/test_installer.py @@ -42,7 +42,6 @@ def _force_json_renderer(): def test_bundles_expected_skills(): names = bundled_skill_names() assert "comfy" in names - assert "comfy-fragments" in names assert "comfy-debug" in names assert "comfy-relay" in names @@ -77,12 +76,6 @@ def test_comfy_skill_covers_cloud_setup_and_routing(): assert needle in text, f"comfy skill should mention {needle}" -def test_comfy_fragments_skill_covers_composition(): - text = skill_content("comfy-fragments") - for needle in ("workflow compose", "_fragment", "blueprint"): - assert needle in text, f"comfy-fragments skill should mention {needle}" - - def test_skill_content_rejects_unknown_name(): with pytest.raises(ValueError) as exc: skill_content("not-a-real-skill") @@ -111,8 +104,8 @@ def test_plan_install_project_scope_paths(tmp_path: Path): def test_plan_install_filters_by_skill(tmp_path: Path): - plans = plan_install(scope="project", project_root=tmp_path, skills=["comfy", "comfy-fragments"]) - assert {p.skill for p in plans} == {"comfy", "comfy-fragments"} + plans = plan_install(scope="project", project_root=tmp_path, skills=["comfy", "comfy-debug"]) + assert {p.skill for p in plans} == {"comfy", "comfy-debug"} # --------------------------------------------------------------------------- diff --git a/tests/comfy_cli/test_credentials.py b/tests/comfy_cli/test_credentials.py index 2b0d5b5c..9b367964 100644 --- a/tests/comfy_cli/test_credentials.py +++ b/tests/comfy_cli/test_credentials.py @@ -27,6 +27,7 @@ from comfy_cli.cloud import oauth from comfy_cli.credentials import ( CLOUD_API_KEY_PROVIDER, + CLOUD_BEARER_ENV_VAR, Credential, find_api_key, get_session, @@ -51,6 +52,7 @@ def clean_env(monkeypatch: pytest.MonkeyPatch): """No ambient credentials: no env vars, no stored key, no session.""" monkeypatch.delenv("COMFY_CLOUD_API_KEY", raising=False) monkeypatch.delenv("COMFY_API_KEY", raising=False) + monkeypatch.delenv("COMFY_CLOUD_AUTH_TOKEN", raising=False) monkeypatch.setattr(auth_store, "get", lambda _provider: None) monkeypatch.setattr(auth_store, "get_cloud_session", lambda: None) monkeypatch.setattr(oauth, "ensure_fresh_session", lambda **kw: None) @@ -329,3 +331,46 @@ def test_no_direct_credential_reads_outside_resolver(): "Direct credential reads found outside comfy_cli/credentials.py — " "use resolve_cloud_credential / find_api_key / get_session instead:\n" + "\n".join(violations) ) + + +# --------------------------------------------------------------------------- +# forwarded Bearer token (COMFY_CLOUD_AUTH_TOKEN — the trusted-caller path) +# --------------------------------------------------------------------------- + + +class TestForwardedBearerToken: + def test_bearer_env_yields_oauth_credential_for_cloud(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, "jwt-abc") + cred = resolve_cloud_credential(purpose="cloud") + assert cred == Credential(kind="oauth", value="jwt-abc", source=f"env:{CLOUD_BEARER_ENV_VAR}") + + def test_bearer_env_is_stripped(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, " jwt-abc \n") + cred = resolve_cloud_credential(purpose="cloud") + assert cred is not None and cred.value == "jwt-abc" + + def test_blank_bearer_env_is_ignored(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, " \n\t") + assert resolve_cloud_credential(purpose="cloud") is None + + def test_live_session_outranks_bearer_env(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, "jwt-abc") + clean_env.setattr(oauth, "ensure_fresh_session", lambda **kw: _session(token="live-token")) + cred = resolve_cloud_credential(purpose="cloud") + assert cred == Credential(kind="oauth", value="live-token", source="session") + + def test_expired_session_falls_through_to_bearer_env(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, "jwt-abc") + clean_env.setattr(oauth, "ensure_fresh_session", lambda **kw: _session(expired=True)) + cred = resolve_cloud_credential(purpose="cloud") + assert cred == Credential(kind="oauth", value="jwt-abc", source=f"env:{CLOUD_BEARER_ENV_VAR}") + + def test_bearer_env_outranks_api_key_env(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, "jwt-abc") + clean_env.setenv("COMFY_CLOUD_API_KEY", "comfyui-key") + cred = resolve_cloud_credential(purpose="cloud") + assert cred == Credential(kind="oauth", value="jwt-abc", source=f"env:{CLOUD_BEARER_ENV_VAR}") + + def test_bearer_env_ignored_for_partner_purpose(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, "jwt-abc") + assert resolve_cloud_credential(purpose="partner") is None diff --git a/tests/comfy_cli/test_env_checker.py b/tests/comfy_cli/test_env_checker.py index bed31756..51ed7b59 100644 --- a/tests/comfy_cli/test_env_checker.py +++ b/tests/comfy_cli/test_env_checker.py @@ -33,22 +33,22 @@ def test_python_37_is_old(self): class TestCheckComfyServerRunning: - @patch("comfy_cli.env_checker.requests.get") + @patch("requests.get") def test_server_running(self, mock_get): mock_get.return_value.status_code = 200 assert check_comfy_server_running() is True - @patch("comfy_cli.env_checker.requests.get") + @patch("requests.get") def test_server_not_running(self, mock_get): mock_get.side_effect = requests.exceptions.ConnectionError() assert check_comfy_server_running() is False - @patch("comfy_cli.env_checker.requests.get") + @patch("requests.get") def test_non_200_status(self, mock_get): mock_get.return_value.status_code = 500 assert check_comfy_server_running() is False - @patch("comfy_cli.env_checker.requests.get") + @patch("requests.get") def test_custom_port_and_host(self, mock_get): mock_get.return_value.status_code = 200 check_comfy_server_running(port=9999, host="0.0.0.0") @@ -58,7 +58,7 @@ def test_custom_port_and_host(self, mock_get): # alter user-visible "is the server up?" behaviour on slow hosts. assert mock_get.call_args.kwargs["timeout"] == 5.0 - @patch("comfy_cli.env_checker.requests.get") + @patch("requests.get") def test_caller_can_override_timeout(self, mock_get): mock_get.return_value.status_code = 200 check_comfy_server_running(port=8188, host="127.0.0.1", timeout=42) diff --git a/tests/comfy_cli/test_run_execution_lifecycle.py b/tests/comfy_cli/test_run_execution_lifecycle.py index fb5779cf..4b43a9b0 100644 --- a/tests/comfy_cli/test_run_execution_lifecycle.py +++ b/tests/comfy_cli/test_run_execution_lifecycle.py @@ -157,3 +157,41 @@ def test_typer_exit_0_is_treated_as_success(self, runner, tracked_run): assert result.exit_code == 0 assert _event_names(tracked_run) == ["execution_start", "execution_success"] + + +class TestRunCloudLifecycle: + """Cloud submissions must emit the SAME start→success lifecycle as local. + + Regression: the cloud branch `return`ed after `execute_cloud`, skipping the + try's `else` — so a successful cloud run fired `execution_start` but never + `execution_success`. Local runs (which fall through) were unaffected. + """ + + def test_cloud_success_emits_start_then_success(self, runner, tracked_run, monkeypatch): + from comfy_cli.cmdline import app + + monkeypatch.setattr("comfy_cli.cmdline.where_module.cloud_preflight_or_exit", lambda: None) + with patch("comfy_cli.cmdline.run_inner.execute_cloud") as mock_cloud: + mock_cloud.return_value = None + result = runner.invoke(app, ["run", "--workflow", "wf.json", "--where", "cloud"]) + + assert result.exit_code == 0, f"stdout={result.output!r} exc={result.exception!r}" + assert _event_names(tracked_run) == ["execution_start", "execution_success"] + mock_cloud.assert_called_once() + + def test_resolved_target_rides_on_terminal_events(self, runner, tracked_run, monkeypatch): + """The resolved routing target (cloud vs local) is recorded so a + submission is attributable even when --where was defaulted.""" + from comfy_cli.cmdline import app + + monkeypatch.setattr("comfy_cli.cmdline.where_module.cloud_preflight_or_exit", lambda: None) + with patch("comfy_cli.cmdline.run_inner.execute_cloud"): + runner.invoke(app, ["run", "--workflow", "wf.json", "--where", "cloud"]) + cloud_success = next(props for name, props in _events(tracked_run) if name == "execution_success") + assert cloud_success.get("target") == "cloud" + + tracked_run.clear() + with patch("comfy_cli.cmdline.run_inner.execute"): + runner.invoke(app, ["run", "--workflow", "wf.json", "--where", "local"]) + local_success = next(props for name, props in _events(tracked_run) if name == "execution_success") + assert local_success.get("target") == "local" diff --git a/tests/comfy_cli/test_standalone.py b/tests/comfy_cli/test_standalone.py index 3f63e11b..a82847f6 100644 --- a/tests/comfy_cli/test_standalone.py +++ b/tests/comfy_cli/test_standalone.py @@ -33,37 +33,37 @@ def _mock_response(text, status_code=200): class TestResolvePythonVersion: - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_resolves_312(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) result = _resolve_python_version("https://example.com/release", "3.12") assert result == "3.12.13" - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_resolves_310(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) result = _resolve_python_version("https://example.com/release", "3.10") assert result == "3.10.20" - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_resolves_313(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) result = _resolve_python_version("https://example.com/release", "3.13") assert result == "3.13.12" - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_missing_version_raises(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) with pytest.raises(RuntimeError, match="No Python 3.14.x found"): _resolve_python_version("https://example.com/release", "3.14") - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_http_error_propagates(self, mock_get): mock_get.return_value = _mock_response("", status_code=404) with pytest.raises(Exception, match="HTTP 404"): _resolve_python_version("https://example.com/release", "3.12") - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_picks_highest_patch(self, mock_get): """If multiple patch versions exist for a minor series, pick the highest.""" sha256sums = """\ @@ -75,13 +75,13 @@ def test_picks_highest_patch(self, mock_get): result = _resolve_python_version("https://example.com/release", "3.12") assert result == "3.12.13" - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_url_construction(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) _resolve_python_version("https://example.com/release/", "3.12") mock_get.assert_called_once_with("https://example.com/release/SHA256SUMS") - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_no_false_match_across_minor(self, mock_get): """3.1 should not match 3.12 or 3.10.""" mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) @@ -91,7 +91,7 @@ def test_no_false_match_across_minor(self, mock_get): class TestDownloadStandalonePython: @patch("comfy_cli.standalone.download_url") - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_minor_version_triggers_resolution(self, mock_get, mock_download): """When version is a minor version (X.Y), it should resolve the patch.""" mock_get.side_effect = [ @@ -109,7 +109,7 @@ def test_minor_version_triggers_resolution(self, mock_get, mock_download): assert "3.12.13" in call_args[1].get("url", "") or "3.12.13" in str(call_args) @patch("comfy_cli.standalone.download_url") - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_full_version_skips_resolution(self, mock_get, mock_download): """When version is a full version (X.Y.Z), no resolution needed.""" mock_get.return_value = _mock_response('{"tag": "20260310", "asset_url_prefix": "https://example.com/release"}') diff --git a/tests/comfy_cli/test_tracking.py b/tests/comfy_cli/test_tracking.py index b79319ca..eeca3bbd 100644 --- a/tests/comfy_cli/test_tracking.py +++ b/tests/comfy_cli/test_tracking.py @@ -544,7 +544,7 @@ def test_no_tracking_when_stdin_not_tty(self, tracking_module): with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=False), patch.object(tracking_module.sys.stdout, "isatty", return_value=True), - patch.object(tracking_module.ui, "prompt_confirm_action") as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action") as mock_prompt, ): tracking_module.prompt_tracking_consent() mock_prompt.assert_not_called() @@ -556,7 +556,7 @@ def test_no_tracking_when_stdout_not_tty(self, tracking_module): with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=True), patch.object(tracking_module.sys.stdout, "isatty", return_value=False), - patch.object(tracking_module.ui, "prompt_confirm_action") as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action") as mock_prompt, ): tracking_module.prompt_tracking_consent() mock_prompt.assert_not_called() @@ -612,7 +612,7 @@ def test_prompts_when_both_are_tty(self, tracking_module): with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=True), patch.object(tracking_module.sys.stdout, "isatty", return_value=True), - patch.object(tracking_module.ui, "prompt_confirm_action", return_value=False) as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action", return_value=False) as mock_prompt, ): tracking_module.prompt_tracking_consent() mock_prompt.assert_called_once() @@ -623,7 +623,7 @@ def test_skip_prompt_bypasses_tty_check(self, tracking_module): with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=False), patch.object(tracking_module.sys.stdout, "isatty", return_value=False), - patch.object(tracking_module.ui, "prompt_confirm_action") as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action") as mock_prompt, ): tracking_module.prompt_tracking_consent(skip_prompt=True, default_value=False) mock_prompt.assert_not_called() @@ -635,7 +635,7 @@ def test_no_op_when_already_configured(self, tracking_module): with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=False), patch.object(tracking_module.sys.stdout, "isatty", return_value=False), - patch.object(tracking_module.ui, "prompt_confirm_action") as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action") as mock_prompt, ): tracking_module.prompt_tracking_consent() mock_prompt.assert_not_called() @@ -682,7 +682,7 @@ def test_env_var_short_circuits_consent_prompt(self, tracking_module, monkeypatc with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=True), patch.object(tracking_module.sys.stdout, "isatty", return_value=True), - patch.object(tracking_module.ui, "prompt_confirm_action") as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action") as mock_prompt, ): tracking_module.prompt_tracking_consent() mock_prompt.assert_not_called() diff --git a/tests/comfy_cli/test_tracking_providers.py b/tests/comfy_cli/test_tracking_providers.py index 591cc754..9c63a152 100644 --- a/tests/comfy_cli/test_tracking_providers.py +++ b/tests/comfy_cli/test_tracking_providers.py @@ -271,6 +271,71 @@ def download(_ctx=None, url=None, set_civitai_api_token=None, set_hf_api_token=N assert "hf-secret" not in str(properties) +class TestLazyProviderConstruction: + """Providers must be built on first dispatch, never at module import. + + Eager construction started PostHog's consumer thread, whose atexit join + stalls every CLI exit by the full flush_interval — even for invocations + that never send a single event (e.g. ``comfy --version`` with + ``DO_NOT_TRACK=1``).""" + + def test_first_track_event_constructs_providers(self, tracking_with_two_providers): + tracking_mod, _, _ = tracking_with_two_providers + built = [MagicMock(), MagicMock()] + with ( + patch.object(tracking_mod, "PROVIDERS", None), + patch.object(tracking_mod, "MixpanelProvider", return_value=built[0]), + patch.object(tracking_mod, "PostHogProvider", return_value=built[1]), + ): + assert tracking_mod.PROVIDERS is None + tracking_mod.track_event("some_event") + assert tracking_mod.PROVIDERS == built + built[0].track.assert_called_once() + built[1].track.assert_called_once() + + def test_disabled_tracking_never_constructs_providers(self, tracking_with_two_providers): + tracking_mod, _, _ = tracking_with_two_providers + tracking_mod.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, "False") + with patch.object(tracking_mod, "PROVIDERS", None): + tracking_mod.track_event("some_event") + assert tracking_mod.PROVIDERS is None + + def test_env_opt_out_never_constructs_providers(self, tracking_with_two_providers): + tracking_mod, _, _ = tracking_with_two_providers + with ( + patch.object(tracking_mod, "PROVIDERS", None), + patch.dict("os.environ", {"DO_NOT_TRACK": "1"}), + ): + tracking_mod.track_event("some_event") + assert tracking_mod.PROVIDERS is None + + def test_get_providers_constructs_once_and_caches(self): + import comfy_cli.tracking as tracking_mod + + built = MagicMock() + with ( + patch.object(tracking_mod, "PROVIDERS", None), + patch.object(tracking_mod, "MixpanelProvider", return_value=built) as mp_cls, + patch.object(tracking_mod, "PostHogProvider", return_value=built) as ph_cls, + ): + first = tracking_mod._get_providers() + second = tracking_mod._get_providers() + assert first is second + mp_cls.assert_called_once() + ph_cls.assert_called_once() + + def test_posthog_flush_interval_is_bounded(self): + """The Posthog client must be constructed with an explicit, small + flush_interval: its atexit join waits out the full interval on an + empty queue, and the library default varies by version (0.5s → 5.0s), + which would add multi-second dead time to every CLI exit.""" + with patch("posthog.Posthog") as posthog_cls: + provider = PostHogProvider("phc_test", "https://t.comfy.org") + assert provider.enabled is True + kwargs = posthog_cls.call_args.kwargs + assert kwargs["flush_interval"] <= 0.5 + + class TestAtexitFlush: def test_flush_all_providers_calls_each_flush(self): """The module registers ``_flush_all_providers`` with ``atexit`` at import @@ -286,6 +351,22 @@ def test_flush_all_providers_calls_each_flush(self): p1.flush.assert_called_once() p2.flush.assert_called_once() + def test_flush_is_noop_when_providers_never_constructed(self): + """The atexit flush must not itself trigger provider construction: + if no provider was ever built, no event was ever dispatched, so there + is nothing to flush and no reason to pay the construction cost.""" + import comfy_cli.tracking as tracking_mod + + with ( + patch.object(tracking_mod, "PROVIDERS", None), + patch.object(tracking_mod, "MixpanelProvider") as mp_cls, + patch.object(tracking_mod, "PostHogProvider") as ph_cls, + ): + tracking_mod._flush_all_providers() + assert tracking_mod.PROVIDERS is None + mp_cls.assert_not_called() + ph_cls.assert_not_called() + def test_flush_swallows_provider_errors(self): import comfy_cli.tracking as tracking_mod diff --git a/tests/comfy_cli/test_utils.py b/tests/comfy_cli/test_utils.py index d16c01a9..88d8a877 100644 --- a/tests/comfy_cli/test_utils.py +++ b/tests/comfy_cli/test_utils.py @@ -18,7 +18,7 @@ def read(self, amt=-1, decode_content=False): class TestDownloadUrl: - @patch("comfy_cli.utils.requests.get") + @patch("requests.get") def test_writes_file(self, mock_get, tmp_path): content = b"file contents here" mock_response = MagicMock() diff --git a/tests/comfy_cli/test_validate_lowers_ui.py b/tests/comfy_cli/test_validate_lowers_ui.py new file mode 100644 index 00000000..d4aa4d51 --- /dev/null +++ b/tests/comfy_cli/test_validate_lowers_ui.py @@ -0,0 +1,247 @@ +"""`comfy validate` must lower a frontend/canvas graph to API format first. + +Regression for the bug where ``validate_workflow`` (which only inspects the +API/prompt shape ``{id: {class_type, inputs}}``) never iterated the nodes of a +frontend ``{nodes: [...], links: [...]}`` graph and therefore returned +``valid:true`` for a structurally broken canvas workflow. The ``validate`` +command now converts a frontend graph with the SAME converter the ``run`` path +uses before calling ``validate_workflow``. + +Layered: + * direct convert + ``Graph.validate_workflow`` (the empirical core), and + * CLI-level envelope tests via ``CliRunner``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from typer.testing import CliRunner + +from comfy_cli.cql.engine import Graph +from comfy_cli.workflow_to_api import convert_ui_to_api, is_api_format + +FIXTURES = Path(__file__).parent / "fixtures" + + +# --------------------------------------------------------------------------- +# Fixtures: a small /object_info covering the SD1.5 text-to-image fixture, plus +# helpers to load and break that frontend workflow. +# --------------------------------------------------------------------------- + + +def _object_info() -> dict: + """Schemas for every node type in ``sd15_ui_workflow.json``. + + ``ckpt_name`` lists the exact checkpoint the fixture uses so the valid + graph validates clean (no spurious ``unknown_enum_value``). + """ + return { + "CheckpointLoaderSimple": { + "input": {"required": {"ckpt_name": [["v1-5-pruned-emaonly-fp16.safetensors", "sd_xl_base.safetensors"]]}}, + "input_order": {"required": ["ckpt_name"]}, + "output": ["MODEL", "CLIP", "VAE"], + "output_name": ["MODEL", "CLIP", "VAE"], + "display_name": "Load Checkpoint", + "output_node": False, + }, + "KSampler": { + "input": { + "required": { + "model": "MODEL", + "positive": "CONDITIONING", + "negative": "CONDITIONING", + "latent_image": "LATENT", + "seed": ["INT", {"default": 0, "control_after_generate": True}], + "steps": ["INT", {"default": 20}], + "cfg": ["FLOAT", {"default": 8.0}], + "sampler_name": [["euler", "euler_ancestral"]], + "scheduler": [["normal", "karras"]], + "denoise": ["FLOAT", {"default": 1.0}], + } + }, + "input_order": { + "required": [ + "model", + "positive", + "negative", + "latent_image", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + ] + }, + "output": ["LATENT"], + "output_name": ["LATENT"], + "display_name": "KSampler", + "output_node": False, + }, + "CLIPTextEncode": { + "input": {"required": {"text": ["STRING", {"multiline": True}], "clip": "CLIP"}}, + "input_order": {"required": ["clip", "text"]}, + "output": ["CONDITIONING"], + "output_name": ["CONDITIONING"], + "display_name": "CLIP Text Encode", + "output_node": False, + }, + "VAEDecode": { + "input": {"required": {"samples": "LATENT", "vae": "VAE"}}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "display_name": "VAE Decode", + "output_node": False, + }, + "SaveImage": { + "input": {"required": {"images": "IMAGE", "filename_prefix": ["STRING", {"default": "ComfyUI"}]}}, + "input_order": {"required": ["images", "filename_prefix"]}, + "output": [], + "output_name": [], + "display_name": "Save Image", + "output_node": True, + }, + "EmptyLatentImage": { + "input": { + "required": { + "width": ["INT", {"default": 512}], + "height": ["INT", {"default": 512}], + "batch_size": ["INT", {"default": 1}], + } + }, + "input_order": {"required": ["width", "height", "batch_size"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "display_name": "Empty Latent Image", + "output_node": False, + }, + } + + +def _sd15_ui() -> dict: + return json.loads((FIXTURES / "sd15_ui_workflow.json").read_text(encoding="utf-8")) + + +def _break_model_link(wf: dict) -> dict: + """Delete the litegraph link (id 1) that feeds KSampler(id=3).model. + + Mirrors a user deleting a required-input wire on the canvas: the link is + removed from ``links`` and the target input's ``link`` is nulled. After + conversion the ``model`` input becomes ABSENT (not a dangling reference). + """ + wf = json.loads(json.dumps(wf)) + wf["links"] = [link for link in wf["links"] if link[0] != 1] + for node in wf["nodes"]: + if node.get("id") == 3: + for inp in node.get("inputs", []): + if inp.get("name") == "model": + inp["link"] = None + return wf + + +# --------------------------------------------------------------------------- +# Layer 1 — the empirical core: convert a frontend graph, then validate. +# --------------------------------------------------------------------------- + + +class TestConvertThenValidate: + def test_deleted_required_link_becomes_absent_and_is_flagged(self): + """A deleted required-input wire → absent input → missing_required_input. + + This is the exact real-world failure the fix targets: the agent's + canvas graph had a required KSampler input unwired, yet the API-only + validator passed it. Verifies (a) the converter drops the input rather + than emitting a dangling edge, and (b) validate_workflow catches the + absent required input. + """ + oi = _object_info() + api = convert_ui_to_api(_break_model_link(_sd15_ui()), oi) + + # The required input is gone from the lowered node (not a dangling ref). + assert "model" not in api["3"]["inputs"] + + result = Graph.from_object_info(oi).validate_workflow(api) + assert result["valid"] is False + missing = [e for e in result["errors"] if e["code"] == "missing_required_input"] + assert any(e["node_id"] == "3" and e["field"] == "model" for e in missing) + + def test_valid_frontend_graph_lowers_and_validates_clean(self): + oi = _object_info() + api = convert_ui_to_api(_sd15_ui(), oi) + # Lowered form is API-shaped; the KSampler's model IS wired. + assert is_api_format(api) + assert api["3"]["inputs"]["model"] == ["4", 0] + + result = Graph.from_object_info(oi).validate_workflow(api) + assert result["valid"] is True, result["errors"] + + +# --------------------------------------------------------------------------- +# Layer 2 — CLI envelope tests: `comfy validate` end-to-end. +# --------------------------------------------------------------------------- + + +def _run_validate(tmp_path: Path, workflow: dict, object_info: dict): + from comfy_cli.cmdline import app + + wf_path = tmp_path / "workflow.json" + wf_path.write_text(json.dumps(workflow), encoding="utf-8") + oi_path = tmp_path / "object_info.json" + oi_path.write_text(json.dumps(object_info), encoding="utf-8") + + return CliRunner().invoke( + app, + ["validate", "--workflow", str(wf_path), "--input", str(oi_path), "--where", "local"], + env={"COMFY_OUTPUT": "json"}, + ) + + +def _envelope(result) -> dict: + lines = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")] + assert lines, f"no JSON envelope in output: {result.stdout!r}" + return json.loads(lines[-1]) + + +class TestValidateCLI: + def test_broken_frontend_graph_is_flagged(self, tmp_path): + result = _run_validate(tmp_path, _break_model_link(_sd15_ui()), _object_info()) + assert result.exit_code == 1 + env = _envelope(result) + assert env["ok"] is False + assert env["data"]["valid"] is False + codes = {(e["code"], e["node_id"], e["field"]) for e in env["data"]["errors"]} + assert ("missing_required_input", "3", "model") in codes + + def test_valid_frontend_graph_passes(self, tmp_path): + result = _run_validate(tmp_path, _sd15_ui(), _object_info()) + assert result.exit_code == 0 + env = _envelope(result) + assert env["ok"] is True + assert env["data"]["valid"] is True + assert env["data"]["error_count"] == 0 + + def test_already_api_format_is_validated_unchanged(self, tmp_path): + # A pre-lowered (already-API) graph must still be validated, and must + # NOT be double-converted. Feeding the lowered valid graph stays valid. + api = convert_ui_to_api(_sd15_ui(), _object_info()) + assert is_api_format(api) + result = _run_validate(tmp_path, api, _object_info()) + assert result.exit_code == 0 + assert _envelope(result)["data"]["valid"] is True + + def test_already_api_format_broken_is_still_flagged(self, tmp_path): + # Drop the model input from the already-API KSampler node: since the + # graph is already API-shaped, no conversion happens, but validation + # still runs and catches the absent required input. + api = convert_ui_to_api(_sd15_ui(), _object_info()) + del api["3"]["inputs"]["model"] + result = _run_validate(tmp_path, api, _object_info()) + assert result.exit_code == 1 + env = _envelope(result) + assert env["data"]["valid"] is False + assert any( + e["code"] == "missing_required_input" and e["node_id"] == "3" and e["field"] == "model" + for e in env["data"]["errors"] + ) diff --git a/tests/comfy_cli/test_workflow_to_api.py b/tests/comfy_cli/test_workflow_to_api.py index aa786bb1..8911af31 100644 --- a/tests/comfy_cli/test_workflow_to_api.py +++ b/tests/comfy_cli/test_workflow_to_api.py @@ -1254,6 +1254,20 @@ class TestImplicitSeedCompanion: "output_node": True, "display_name": "RegularInt", }, + # A seed-substring INT (unflagged, no real companion) immediately followed + # by a COMBO that legitimately lists a control keyword among its options. + "SeedThenCombo": { + "input": { + "required": { + "variation_seed": ["INT", {"default": 0}], + "mode": [["fixed", "auto", "manual"], {}], + "strength": ["FLOAT", {"default": 1.0}], + } + }, + "input_order": {"required": ["variation_seed", "mode", "strength"]}, + "output_node": True, + "display_name": "SeedThenCombo", + }, } def test_seed_named_input_strips_implicit_companion(self): @@ -1332,6 +1346,28 @@ def test_regular_int_input_does_not_strip_control_value(self): assert result["1"]["inputs"]["value"] == 99 assert result["1"]["inputs"]["label"] == "randomize" + def test_seed_does_not_steal_next_combos_control_keyword_value(self): + # `variation_seed` is a seed-substring INT with NO real companion; the + # NEXT widget is a COMBO whose legitimate saved value is "fixed" (one of + # its own options). The converter must keep "fixed" as the combo's value + # rather than consuming it as a phantom control_after_generate marker + # (which would drop it and shift `strength` into the wrong slot). + workflow = { + "nodes": [ + { + "id": 1, + "type": "SeedThenCombo", + "inputs": [], + "outputs": [], + "widgets_values": [7, "fixed", 0.5], + "mode": 0, + } + ], + "links": [], + } + result = convert_ui_to_api(workflow, self.OI) + assert result["1"]["inputs"] == {"variation_seed": 7, "mode": "fixed", "strength": 0.5} + class TestNodeNameForSAndRAlias: """When a node carries ``properties["Node name for S&R"]`` pointing at a @@ -1957,6 +1993,206 @@ def test_dynamic_combo_selector_reads_from_filtered_slot(self): assert inputs["shape.side"] == 10.0 +class TestSeedControlMarkerOffByOne: + """Regression for the partner-node seed + ``control_after_generate`` + off-by-one that made ``validate`` (which lowers the graph via + ``convert_ui_to_api``) read a downstream widget as the stray control + token — most visibly a Gemini / Nano Banana node whose + ``response_modalities`` was read as ``"fixed"``. + + Two independent gaps produced the same symptom: + + 1. A seed-like INT input whose name isn't literally ``seed``/``noise_seed`` + and whose schema omits the ``control_after_generate`` flag (Rodin3D's + ``Seed``, Tripo's ``image_seed``/``model_seed``/``texture_seed``, + ``rand_seed``, ``noise_seed_sde``, ``variation_seed``, ...). The old + exact-name implicit heuristic missed these, so the ``"fixed"`` marker + survived and shifted every later widget by one. + + 2. A dynamic combo (``COMFY_DYNAMICCOMBO_V3``) positioned *before* the seed + (GeminiNanoBanana2V2 / "Nano Banana 2") whose option carries a + connection-only (non-widget) sub-input. The span walk over-counted the + combo, reached the seed at the wrong index, and left the marker in + place — landing ``"fixed"`` on the ``response_modalities`` widget that + immediately follows the seed. + """ + + def test_non_canonical_seed_name_strips_control_marker(self): + # Seed-like INT named ``image_seed`` (Tripo style), unflagged, followed + # by a control marker and then a downstream COMBO. Before the fix the + # marker survived and ``response_modalities`` read "fixed". + object_info = { + "PartnerImageNode": { + "input": { + "required": { + "prompt": ["STRING", {"multiline": True}], + "image_seed": ["INT", {"default": 42}], # no control_after_generate flag + "response_modalities": [["IMAGE", "IMAGE+TEXT"], {}], + } + }, + "input_order": {"required": ["prompt", "image_seed", "response_modalities"]}, + "output_node": True, + "display_name": "Partner Image", + } + } + workflow = { + "nodes": [ + { + "id": 1, + "type": "PartnerImageNode", + "inputs": [], + "outputs": [], + "widgets_values": ["a cat", 12345, "fixed", "IMAGE"], + "mode": 0, + } + ], + "links": [], + } + result = convert_ui_to_api(workflow, object_info) + inputs = result["1"]["inputs"] + assert inputs["image_seed"] == 12345 + assert inputs["response_modalities"] == "IMAGE" # not "fixed" + assert "fixed" not in inputs.values() + + def test_nano_banana_pro_flagged_seed_still_strips(self): + # GeminiImage2Node / "Nano Banana Pro" shape: plain COMBO model, seed + # carries control_after_generate. This already worked; pin it. + object_info = { + "GeminiImage2Node": { + "input": { + "required": { + "prompt": ["STRING", {"multiline": True}], + "model": [["gemini-2.5-flash-image"], {}], + "seed": ["INT", {"default": 42, "control_after_generate": True}], + "aspect_ratio": [["auto", "1:1"], {}], + "resolution": [["1K", "2K"], {}], + "response_modalities": [["IMAGE", "IMAGE+TEXT"], {}], + } + }, + "input_order": { + "required": ["prompt", "model", "seed", "aspect_ratio", "resolution", "response_modalities"] + }, + "output_node": True, + "display_name": "Nano Banana Pro (Google Gemini Image)", + } + } + workflow = { + "nodes": [ + { + "id": 1, + "type": "GeminiImage2Node", + "inputs": [], + "outputs": [], + "widgets_values": ["a cat", "gemini-2.5-flash-image", 999, "fixed", "auto", "1K", "IMAGE"], + "mode": 0, + } + ], + "links": [], + } + result = convert_ui_to_api(workflow, object_info) + inputs = result["1"]["inputs"] + assert inputs["seed"] == 999 + assert inputs["response_modalities"] == "IMAGE" + assert "fixed" not in inputs.values() + + def test_dynamic_combo_before_seed_with_nonwidget_subinput(self): + # GeminiNanoBanana2V2 / "Nano Banana 2" shape: dynamic ``model`` combo + # precedes the seed and its option has a connection-only sub-input + # (``images`` -> IMAGE) that carries no widget value. ``response_modalities`` + # sits right after the seed, so before the fix the stray control marker + # landed on it. The non-widget sub-input must not consume a value slot. + object_info = { + "GeminiNanoBanana2V2": { + "input": { + "required": { + "prompt": ["STRING", {"multiline": True}], + "model": [ + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + { + "key": "nb2", + "inputs": { + "required": { + "aspect_ratio": [["auto", "16:9"], {}], + "resolution": [["1K", "2K"], {}], + "thinking_level": [["MINIMAL", "HIGH"], {}], + "images": ["IMAGE", {}], # connection-only, no widget value + } + }, + } + ] + }, + ], + "seed": ["INT", {"default": 42, "control_after_generate": True}], + "response_modalities": [["IMAGE", "IMAGE+TEXT"], {}], + } + }, + "input_order": {"required": ["prompt", "model", "seed", "response_modalities"]}, + "output_node": True, + "display_name": "Nano Banana 2", + } + } + workflow = { + "nodes": [ + { + "id": 1, + "type": "GeminiNanoBanana2V2", + "inputs": [], + "outputs": [], + # prompt, model_key, aspect_ratio, resolution, thinking_level, + # seed, control_marker, response_modalities + "widgets_values": ["a cat", "nb2", "auto", "1K", "HIGH", 999, "fixed", "IMAGE"], + "mode": 0, + } + ], + "links": [], + } + result = convert_ui_to_api(workflow, object_info) + inputs = result["1"]["inputs"] + assert inputs["seed"] == 999 + assert inputs["response_modalities"] == "IMAGE" # not "fixed" + assert inputs["model"] == "nb2" + assert inputs["model.aspect_ratio"] == "auto" + assert inputs["model.resolution"] == "1K" + assert inputs["model.thinking_level"] == "HIGH" + assert "fixed" not in inputs.values() + + def test_non_seed_int_before_control_keyword_not_stripped(self): + # Safety net: a non-seed INT (``steps``) followed by a COMBO whose value + # is literally "fixed" must NOT be treated as a control companion. + object_info = { + "PlainNode": { + "input": { + "required": { + "steps": ["INT", {"default": 20}], + "mode": [["fixed", "auto"], {}], + } + }, + "input_order": {"required": ["steps", "mode"]}, + "output_node": True, + "display_name": "Plain", + } + } + workflow = { + "nodes": [ + { + "id": 1, + "type": "PlainNode", + "inputs": [], + "outputs": [], + "widgets_values": [20, "fixed"], + "mode": 0, + } + ], + "links": [], + } + result = convert_ui_to_api(workflow, object_info) + inputs = result["1"]["inputs"] + assert inputs["steps"] == 20 + assert inputs["mode"] == "fixed" # preserved, not eaten as a control marker + + class TestDynamicPrompts: """Port of frontend's processDynamicPrompt behavior (formatUtil.ts). diff --git a/tests/e2e/verify_tracking_live.py b/tests/e2e/verify_tracking_live.py index 2928ab8a..7b68c148 100644 --- a/tests/e2e/verify_tracking_live.py +++ b/tests/e2e/verify_tracking_live.py @@ -62,7 +62,8 @@ def _send_smoketest_event(nonce: str) -> tuple[str, str]: if tracking._telemetry_disabled_by_env(): _die("telemetry is opted out via DO_NOT_TRACK / COMFY_NO_TELEMETRY — unset it to verify") - posthog_providers = [p for p in tracking.PROVIDERS if isinstance(p, tracking.PostHogProvider) and p.enabled] + # Providers are constructed lazily; force construction before filtering. + posthog_providers = [p for p in tracking._get_providers() if isinstance(p, tracking.PostHogProvider) and p.enabled] if not posthog_providers: _die("no enabled PostHog provider — check POSTHOG_API_KEY token") diff --git a/uv.lock b/uv.lock index ffd02f50..07a6fba5 100644 --- a/uv.lock +++ b/uv.lock @@ -182,6 +182,7 @@ dependencies = [ { name = "cookiecutter" }, { name = "gitpython" }, { name = "httpx" }, + { name = "imageio-ffmpeg" }, { name = "mixpanel" }, { name = "packaging" }, { name = "pathspec" }, @@ -218,6 +219,7 @@ requires-dist = [ { name = "cookiecutter" }, { name = "gitpython", specifier = ">=3.1.50" }, { name = "httpx" }, + { name = "imageio-ffmpeg" }, { name = "jsonschema", marker = "extra == 'dev'" }, { name = "mixpanel" }, { name = "packaging" }, @@ -375,7 +377,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -470,6 +472,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0"