From d90378752e149685af513338ce0b23dd73b9eab6 Mon Sep 17 00:00:00 2001 From: kusmer985 <2591694290@qq.com> Date: Wed, 3 Jun 2026 11:52:34 +0800 Subject: [PATCH] Add comfyui skill package --- image-creation/comfyui/SKILL.md | 612 +++++++++++++ image-creation/comfyui/comfyui.zip | Bin 0 -> 113620 bytes image-creation/comfyui/install.md | 2 + .../comfyui/references/official-cli.md | 255 ++++++ image-creation/comfyui/references/rest-api.md | 312 +++++++ .../comfyui/references/template-integrity.md | 243 +++++ .../comfyui/references/workflow-format.md | 226 +++++ image-creation/comfyui/scripts/_common.py | 835 ++++++++++++++++++ .../comfyui/scripts/auto_fix_deps.py | 225 +++++ image-creation/comfyui/scripts/check_deps.py | 437 +++++++++ .../comfyui/scripts/comfyui_setup.sh | 286 ++++++ .../comfyui/scripts/extract_schema.py | 315 +++++++ image-creation/comfyui/scripts/fetch_logs.py | 157 ++++ .../comfyui/scripts/hardware_check.py | 497 +++++++++++ .../comfyui/scripts/health_check.py | 223 +++++ image-creation/comfyui/scripts/run_batch.py | 243 +++++ .../comfyui/scripts/run_workflow.py | 796 +++++++++++++++++ image-creation/comfyui/scripts/ws_monitor.py | 267 ++++++ image-creation/comfyui/tests/README.md | 50 ++ image-creation/comfyui/tests/conftest.py | 64 ++ image-creation/comfyui/tests/pytest.ini | 5 + .../comfyui/tests/test_check_deps.py | 68 ++ .../comfyui/tests/test_cloud_integration.py | 95 ++ image-creation/comfyui/tests/test_common.py | 443 ++++++++++ .../comfyui/tests/test_extract_schema.py | 184 ++++ .../comfyui/tests/test_run_workflow.py | 210 +++++ image-creation/comfyui/workflows/README.md | 86 ++ .../comfyui/workflows/animatediff_video.json | 64 ++ .../comfyui/workflows/flux_dev_txt2img.json | 78 ++ .../comfyui/workflows/sd15_txt2img.json | 49 + .../comfyui/workflows/sdxl_img2img.json | 54 ++ .../comfyui/workflows/sdxl_inpaint.json | 59 ++ .../comfyui/workflows/sdxl_txt2img.json | 49 + .../comfyui/workflows/upscale_4x.json | 27 + .../comfyui/workflows/wan_video_t2v.json | 69 ++ 35 files changed, 7585 insertions(+) create mode 100644 image-creation/comfyui/SKILL.md create mode 100644 image-creation/comfyui/comfyui.zip create mode 100644 image-creation/comfyui/install.md create mode 100644 image-creation/comfyui/references/official-cli.md create mode 100644 image-creation/comfyui/references/rest-api.md create mode 100644 image-creation/comfyui/references/template-integrity.md create mode 100644 image-creation/comfyui/references/workflow-format.md create mode 100644 image-creation/comfyui/scripts/_common.py create mode 100644 image-creation/comfyui/scripts/auto_fix_deps.py create mode 100644 image-creation/comfyui/scripts/check_deps.py create mode 100644 image-creation/comfyui/scripts/comfyui_setup.sh create mode 100644 image-creation/comfyui/scripts/extract_schema.py create mode 100644 image-creation/comfyui/scripts/fetch_logs.py create mode 100644 image-creation/comfyui/scripts/hardware_check.py create mode 100644 image-creation/comfyui/scripts/health_check.py create mode 100644 image-creation/comfyui/scripts/run_batch.py create mode 100644 image-creation/comfyui/scripts/run_workflow.py create mode 100644 image-creation/comfyui/scripts/ws_monitor.py create mode 100644 image-creation/comfyui/tests/README.md create mode 100644 image-creation/comfyui/tests/conftest.py create mode 100644 image-creation/comfyui/tests/pytest.ini create mode 100644 image-creation/comfyui/tests/test_check_deps.py create mode 100644 image-creation/comfyui/tests/test_cloud_integration.py create mode 100644 image-creation/comfyui/tests/test_common.py create mode 100644 image-creation/comfyui/tests/test_extract_schema.py create mode 100644 image-creation/comfyui/tests/test_run_workflow.py create mode 100644 image-creation/comfyui/workflows/README.md create mode 100644 image-creation/comfyui/workflows/animatediff_video.json create mode 100644 image-creation/comfyui/workflows/flux_dev_txt2img.json create mode 100644 image-creation/comfyui/workflows/sd15_txt2img.json create mode 100644 image-creation/comfyui/workflows/sdxl_img2img.json create mode 100644 image-creation/comfyui/workflows/sdxl_inpaint.json create mode 100644 image-creation/comfyui/workflows/sdxl_txt2img.json create mode 100644 image-creation/comfyui/workflows/upscale_4x.json create mode 100644 image-creation/comfyui/workflows/wan_video_t2v.json diff --git a/image-creation/comfyui/SKILL.md b/image-creation/comfyui/SKILL.md new file mode 100644 index 0000000..e5a8a7c --- /dev/null +++ b/image-creation/comfyui/SKILL.md @@ -0,0 +1,612 @@ +--- +name: comfyui +description: "Generate images, video, and audio with ComfyUI — install, launch, manage nodes/models, run workflows with parameter injection. Uses the official comfy-cli for lifecycle and direct REST/WebSocket API for execution." +version: 5.1.0 +author: [kshitijk4poor, alt-glitch, purzbeats] +license: MIT +platforms: [macos, linux, windows] +compatibility: "Requires ComfyUI (local, Comfy Desktop, or Comfy Cloud) and comfy-cli (auto-installed via pipx/uvx by the setup script)." +prerequisites: + commands: ["python3"] +setup: + help: "Run scripts/hardware_check.py FIRST to decide local vs Comfy Cloud; then scripts/comfyui_setup.sh auto-installs locally (or use Cloud API key for platform.comfy.org)." +metadata: + hermes: + tags: + - comfyui + - image-generation + - stable-diffusion + - flux + - sd3 + - wan-video + - hunyuan-video + - creative + - generative-ai + - video-generation + related_skills: [stable-diffusion-image-generation, image_gen] + category: creative +--- + +# ComfyUI + +Generate images, video, audio, and 3D content through ComfyUI using the +official `comfy-cli` for setup/lifecycle and direct REST/WebSocket API +for workflow execution. + +## What's in this skill + +**Reference docs (`references/`):** + +- `official-cli.md` — every `comfy ...` command, with flags +- `rest-api.md` — REST + WebSocket endpoints (local + cloud), payload schemas +- `workflow-format.md` — API-format JSON, common node types, param mapping +- `template-integrity.md` — converting `comfyui-workflow-templates` from + editor format to API format: Reroute bypass, dotted dynamic-input keys + (`values.a`, `resize_type.width`), Cloud quirks (302 redirect, 1 concurrent + free-tier job, 1080p VRAM ceiling), Discord-compatible ffmpeg stitch. + Authored by [@purzbeats](https://github.com/purzbeats). Load this whenever + you're starting from an official template. + +**Scripts (`scripts/`):** + +| Script | Purpose | +|--------|---------| +| `_common.py` | Shared HTTP, cloud routing, node catalogs (don't run directly) | +| `hardware_check.py` | Probe GPU/VRAM/disk → recommend local vs Comfy Cloud | +| `comfyui_setup.sh` | Hardware check + comfy-cli + ComfyUI install + launch + verify | +| `extract_schema.py` | Read a workflow → list controllable params + model deps | +| `check_deps.py` | Check workflow against running server → list missing nodes/models | +| `auto_fix_deps.py` | Run check_deps then `comfy node install` / `comfy model download` | +| `run_workflow.py` | Inject params, submit, monitor, download outputs (HTTP or WS) | +| `run_batch.py` | Submit a workflow N times with sweeps, parallel up to your tier | +| `ws_monitor.py` | Real-time WebSocket viewer for executing jobs (live progress) | +| `health_check.py` | Verification checklist runner — comfy-cli + server + models + smoke test | +| `fetch_logs.py` | Pull traceback / status messages for a given prompt_id | + +**Example workflows (`workflows/`):** SD 1.5, SDXL, Flux Dev, SDXL img2img, +SDXL inpaint, ESRGAN upscale, AnimateDiff video, Wan T2V. See +`workflows/README.md`. + +## When to Use + +- User asks to generate images with Stable Diffusion, SDXL, Flux, SD3, etc. +- User wants to run a specific ComfyUI workflow file +- User wants to chain generative steps (txt2img → upscale → face restore) +- User needs ControlNet, inpainting, img2img, or other advanced pipelines +- User asks to manage ComfyUI queue, check models, or install custom nodes +- User wants video/audio/3D generation via AnimateDiff, Hunyuan, Wan, AudioCraft, etc. + +## Architecture: Two Layers + +``` +┌─────────────────────────────────────────────────────┐ +│ Layer 1: comfy-cli (official lifecycle tool) │ +│ Setup, server lifecycle, custom nodes, models │ +│ → comfy install / launch / stop / node / model │ +└─────────────────────────┬───────────────────────────┘ + │ +┌─────────────────────────▼───────────────────────────┐ +│ Layer 2: REST/WebSocket API + skill scripts │ +│ Workflow execution, param injection, monitoring │ +│ POST /api/prompt, GET /api/view, WS /ws │ +│ → run_workflow.py, run_batch.py, ws_monitor.py │ +└─────────────────────────────────────────────────────┘ +``` + +**Why two layers?** The official CLI is excellent for installation and server +management but has minimal workflow execution support. The REST/WS API fills +that gap — the scripts handle param injection, execution monitoring, and +output download that the CLI doesn't do. + +## Quick Start + +### Detect environment + +```bash +# What's available? +command -v comfy >/dev/null 2>&1 && echo "comfy-cli: installed" +curl -s http://127.0.0.1:8188/system_stats 2>/dev/null && echo "server: running" + +# Can this machine run ComfyUI locally? (GPU/VRAM/disk check) +python3 scripts/hardware_check.py +``` + +If nothing is installed, see **Setup & Onboarding** below — but always run the +hardware check first. + +### One-line health check + +```bash +python3 scripts/health_check.py +# → JSON: comfy_cli on PATH? server reachable? at least one checkpoint? smoke-test passes? +``` + +## Core Workflow + +### Step 1: Get a workflow JSON in API format + +Workflows must be in API format (each node has `class_type`). They come from: + +- ComfyUI web UI → **Workflow → Export (API)** (newer UI) or + the legacy "Save (API Format)" button (older UI) +- This skill's `workflows/` directory (ready-to-run examples) +- Community downloads (civitai, Reddit, Discord) — usually editor format, + must be loaded into ComfyUI then re-exported + +Editor format (top-level `nodes` and `links` arrays) is **not directly +executable**. The scripts detect this and tell you to re-export. + +### Step 2: See what's controllable + +```bash +python3 scripts/extract_schema.py workflow_api.json --summary-only +# → {"parameter_count": 12, "has_negative_prompt": true, "has_seed": true, ...} + +python3 scripts/extract_schema.py workflow_api.json +# → full schema with parameters, model deps, embedding refs +``` + +### Step 3: Run with parameters + +```bash +# Local (defaults to http://127.0.0.1:8188) +python3 scripts/run_workflow.py \ + --workflow workflow_api.json \ + --args '{"prompt": "a beautiful sunset over mountains", "seed": -1, "steps": 30}' \ + --output-dir ./outputs + +# Cloud (export API key once; uses correct /api routing automatically) +export COMFY_CLOUD_API_KEY="comfyui-..." +python3 scripts/run_workflow.py \ + --workflow workflow_api.json \ + --args '{"prompt": "..."}' \ + --host https://cloud.comfy.org \ + --output-dir ./outputs + +# Real-time progress via WebSocket (requires `pip install websocket-client`) +python3 scripts/run_workflow.py \ + --workflow flux_dev.json \ + --args '{"prompt": "..."}' \ + --ws + +# img2img / inpaint: pass --input-image to upload + reference automatically +python3 scripts/run_workflow.py \ + --workflow sdxl_img2img.json \ + --input-image image=./photo.png \ + --args '{"prompt": "make it watercolor", "denoise": 0.6}' + +# Batch / sweep: 8 random seeds, parallel up to cloud tier limit +python3 scripts/run_batch.py \ + --workflow sdxl.json \ + --args '{"prompt": "abstract"}' \ + --count 8 --randomize-seed --parallel 3 \ + --output-dir ./outputs/batch +``` + +`-1` for `seed` (or omitting it with `--randomize-seed`) generates a fresh +random seed per run. + +### Step 4: Present results + +The scripts emit JSON to stdout describing every output file: + +```json +{ + "status": "success", + "prompt_id": "abc-123", + "outputs": [ + {"file": "./outputs/sdxl_00001_.png", "node_id": "9", + "type": "image", "filename": "sdxl_00001_.png"} + ] +} +``` + +## Decision Tree + +| User says | Tool | Command | +|-----------|------|---------| +| **Lifecycle (use comfy-cli)** | | | +| "install ComfyUI" | comfy-cli | `bash scripts/comfyui_setup.sh` | +| "start ComfyUI" | comfy-cli | `comfy launch --background` | +| "stop ComfyUI" | comfy-cli | `comfy stop` | +| "install X node" | comfy-cli | `comfy node install ` | +| "download X model" | comfy-cli | `comfy model download --url --relative-path models/checkpoints` | +| "list installed models" | comfy-cli | `comfy model list` | +| "list installed nodes" | comfy-cli | `comfy node show installed` | +| **Execution (use scripts)** | | | +| "is everything ready?" | script | `health_check.py` (optionally with `--workflow X --smoke-test`) | +| "what can I change in this workflow?" | script | `extract_schema.py W.json` | +| "check if W's deps are met" | script | `check_deps.py W.json` | +| "fix missing deps" | script | `auto_fix_deps.py W.json` | +| "generate an image" | script | `run_workflow.py --workflow W --args '{...}'` | +| "use this image" (img2img) | script | `run_workflow.py --input-image image=./x.png ...` | +| "8 variations with random seeds" | script | `run_batch.py --count 8 --randomize-seed ...` | +| "show me live progress" | script | `ws_monitor.py --prompt-id ` | +| "fetch the error from job X" | script | `fetch_logs.py ` | +| **Direct REST** | | | +| "what's in the queue?" | REST | `curl http://HOST:8188/queue` (local) or `--host https://cloud.comfy.org` | +| "cancel that" | REST | `curl -X POST http://HOST:8188/interrupt` | +| "free GPU memory" | REST | `curl -X POST http://HOST:8188/free` | + +## Setup & Onboarding + +When a user asks to set up ComfyUI, **the FIRST thing to do is ask whether +they want Comfy Cloud (hosted, zero install, API key) or Local (install +ComfyUI on their machine)**. Don't start running install commands or hardware +checks until they've answered. + +**Official docs:** https://docs.comfy.org/installation +**CLI docs:** https://docs.comfy.org/comfy-cli/getting-started +**Cloud docs:** https://docs.comfy.org/get_started/cloud +**Cloud API:** https://docs.comfy.org/development/cloud/overview + +### Step 0: Ask Local vs Cloud (ALWAYS FIRST) + +Suggested script: + +> "Do you want to run ComfyUI locally on your machine, or use Comfy Cloud? +> +> - **Comfy Cloud** — hosted on RTX 6000 Pro GPUs, all common models pre-installed, +> zero setup. Requires an API key (paid subscription required to actually run +> workflows; free tier is read-only). Best if you don't have a capable GPU. +> - **Local** — free, but your machine MUST meet the hardware requirements: +> - NVIDIA GPU with **≥6 GB VRAM** (≥8 GB for SDXL, ≥12 GB for Flux/video), OR +> - AMD GPU with ROCm support (Linux), OR +> - Apple Silicon Mac (M1+) with **≥16 GB unified memory** (≥32 GB recommended). +> - Intel Macs and machines with no GPU will NOT work — use Cloud instead. +> +> Which would you like?" + +Routing: + +- **Cloud** → skip to **Path A**. +- **Local** → run hardware check first, then pick a path from Paths B–E based on the verdict. +- **Unsure** → run the hardware check and let the verdict decide. + +### Step 1: Verify Hardware (ONLY if user chose local) + +```bash +python3 scripts/hardware_check.py --json +# Optional: also probe `torch` for actual CUDA/MPS: +python3 scripts/hardware_check.py --json --check-pytorch +``` + +| Verdict | Meaning | Action | +|------------|---------------------------------------------------------------|--------| +| `ok` | ≥8 GB VRAM (discrete) OR ≥32 GB unified (Apple Silicon) | Local install — use `comfy_cli_flag` from report | +| `marginal` | SD1.5 works; SDXL tight; Flux/video unlikely | Local OK for light workflows, else **Path A (Cloud)** | +| `cloud` | No usable GPU, <6 GB VRAM, <16 GB Apple unified, Intel Mac, Rosetta Python | **Switch to Cloud** unless user explicitly forces local | + +The script also surfaces `wsl: true` (WSL2 with NVIDIA passthrough) and +`rosetta: true` (x86_64 Python on Apple Silicon — must reinstall as ARM64). + +If verdict is `cloud` but the user wants local, do not proceed silently. +Show the `notes` array verbatim and ask whether they want to (a) switch to +Cloud or (b) force a local install (will OOM or be unusably slow on modern models). + +### Choosing an Installation Path + +Use the hardware check first. The table below is the fallback for when the +user has already told you their hardware: + +| Situation | Recommended Path | +|-----------|------------------| +| `verdict: cloud` from hardware check | **Path A: Comfy Cloud** | +| No GPU / want to try without commitment | **Path A: Comfy Cloud** | +| Windows + NVIDIA + non-technical | **Path B: ComfyUI Desktop** | +| Windows + NVIDIA + technical | **Path C: Portable** or **Path D: comfy-cli** | +| Linux + any GPU | **Path D: comfy-cli** (easiest) | +| macOS + Apple Silicon | **Path B: Desktop** or **Path D: comfy-cli** | +| Headless / server / CI / agents | **Path D: comfy-cli** | + +For the fully automated path (hardware check → install → launch → verify): + +```bash +bash scripts/comfyui_setup.sh +# Or with overrides: +bash scripts/comfyui_setup.sh --m-series --port=8190 --workspace=/data/comfy +``` + +It runs `hardware_check.py` internally, refuses to install locally when the +verdict is `cloud` (unless `--force-cloud-override`), picks the right +`comfy-cli` flag, and prefers `pipx`/`uvx` over global `pip` to avoid polluting +system Python. + +--- + +### Path A: Comfy Cloud (No Local Install) + +For users without a capable GPU or who want zero setup. Hosted on RTX 6000 Pro. + +**Docs:** https://docs.comfy.org/get_started/cloud + +1. Sign up at https://comfy.org/cloud +2. Generate an API key at https://platform.comfy.org/login +3. Set the key: + ```bash + export COMFY_CLOUD_API_KEY="comfyui-xxxxxxxxxxxx" + ``` +4. Run workflows: + ```bash + python3 scripts/run_workflow.py \ + --workflow workflows/flux_dev_txt2img.json \ + --args '{"prompt": "..."}' \ + --host https://cloud.comfy.org \ + --output-dir ./outputs + ``` + +**Pricing:** https://www.comfy.org/cloud/pricing +**Concurrent jobs:** Free/Standard 1, Creator 3, Pro 5. Free tier +**cannot run workflows via API** — only browse models. Paid subscription +required for `/api/prompt`, `/api/upload/*`, `/api/view`, etc. + +--- + +### Path B: ComfyUI Desktop (Windows / macOS) + +One-click installer for non-technical users. Currently Beta. + +**Docs:** https://docs.comfy.org/installation/desktop +- **Windows (NVIDIA):** https://download.comfy.org/windows/nsis/x64 +- **macOS (Apple Silicon):** https://comfy.org + +Linux is **not supported** for Desktop — use Path D. + +--- + +### Path C: ComfyUI Portable (Windows Only) + +**Docs:** https://docs.comfy.org/installation/comfyui_portable_windows + +Download from https://github.com/comfyanonymous/ComfyUI/releases, extract, +run `run_nvidia_gpu.bat`. Update via `update/update_comfyui_stable.bat`. + +--- + +### Path D: comfy-cli (All Platforms — Recommended for Agents) + +The official CLI is the best path for headless/automated setups. + +**Docs:** https://docs.comfy.org/comfy-cli/getting-started + +#### Install comfy-cli + +```bash +# Recommended: +pipx install comfy-cli +# Or use uvx without installing: +uvx --from comfy-cli comfy --help +# Or (if pipx/uvx unavailable): +pip install --user comfy-cli +``` + +Disable analytics non-interactively: +```bash +comfy --skip-prompt tracking disable +``` + +#### Install ComfyUI + +```bash +comfy --skip-prompt install --nvidia # NVIDIA (CUDA) +comfy --skip-prompt install --amd # AMD (ROCm, Linux) +comfy --skip-prompt install --m-series # Apple Silicon (MPS) +comfy --skip-prompt install --cpu # CPU only (slow) +comfy --skip-prompt install --nvidia --fast-deps # uv-based dep resolution +``` + +Default location: `~/comfy/ComfyUI` (Linux), `~/Documents/comfy/ComfyUI` +(macOS/Win). Override with `comfy --workspace /custom/path install`. + +#### Launch / verify + +```bash +comfy launch --background # background daemon on :8188 +comfy launch -- --listen 0.0.0.0 --port 8190 # LAN-accessible custom port +curl -s http://127.0.0.1:8188/system_stats # health check +``` + +--- + +### Path E: Manual Install (Advanced / Unsupported Hardware) + +For Ascend NPU, Cambricon MLU, Intel Arc, or other unsupported hardware. + +**Docs:** https://docs.comfy.org/installation/manual_install + +```bash +git clone https://github.com/comfyanonymous/ComfyUI.git +cd ComfyUI +pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu130 +pip install -r requirements.txt +python main.py +``` + +--- + +### Post-Install: Download Models + +```bash +# SDXL (general purpose, ~6.5 GB) +comfy model download \ + --url "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors" \ + --relative-path models/checkpoints + +# SD 1.5 (lighter, ~4 GB, good for 6 GB cards) +comfy model download \ + --url "https://huggingface.co/stable-diffusion-v1-5/stable-diffusion-v1-5/resolve/main/v1-5-pruned-emaonly.safetensors" \ + --relative-path models/checkpoints + +# Flux Dev fp8 (smaller variant, ~12 GB) +comfy model download \ + --url "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev-fp8.safetensors" \ + --relative-path models/checkpoints + +# CivitAI (set token first): +comfy model download \ + --url "https://civitai.com/api/download/models/128713" \ + --relative-path models/checkpoints \ + --set-civitai-api-token "YOUR_TOKEN" +``` + +List installed: `comfy model list`. + +### Post-Install: Install Custom Nodes + +```bash +comfy node install comfyui-impact-pack # popular utility pack +comfy node install comfyui-animatediff-evolved # video generation +comfy node install comfyui-controlnet-aux # ControlNet preprocessors +comfy node install comfyui-essentials # common helpers +comfy node update all +comfy node install-deps --workflow=workflow.json # install everything a workflow needs +``` + +### Post-Install: Verify + +```bash +python3 scripts/health_check.py +# → comfy_cli on PATH? server reachable? checkpoints? smoke test? + +python3 scripts/check_deps.py my_workflow.json +# → are this workflow's nodes/models/embeddings installed? + +python3 scripts/run_workflow.py \ + --workflow workflows/sd15_txt2img.json \ + --args '{"prompt": "test", "steps": 4}' \ + --output-dir ./test-outputs +``` + +## Image Upload (img2img / Inpainting) + +The simplest way is to use `--input-image` with `run_workflow.py`: + +```bash +python3 scripts/run_workflow.py \ + --workflow workflows/sdxl_img2img.json \ + --input-image image=./photo.png \ + --args '{"prompt": "make it cyberpunk", "denoise": 0.6}' +``` + +The flag uploads `photo.png`, then injects its server-side filename into +whatever schema parameter is named `image`. For inpainting, pass both: + +```bash +python3 scripts/run_workflow.py \ + --workflow workflows/sdxl_inpaint.json \ + --input-image image=./photo.png \ + --input-image mask_image=./mask.png \ + --args '{"prompt": "fill with flowers"}' +``` + +Manual upload via REST: +```bash +curl -X POST "http://127.0.0.1:8188/upload/image" \ + -F "image=@photo.png" -F "type=input" -F "overwrite=true" +# Returns: {"name": "photo.png", "subfolder": "", "type": "input"} + +# Cloud equivalent: +curl -X POST "https://cloud.comfy.org/api/upload/image" \ + -H "X-API-Key: $COMFY_CLOUD_API_KEY" \ + -F "image=@photo.png" -F "type=input" -F "overwrite=true" +``` + +## Cloud Specifics + +- **Base URL:** `https://cloud.comfy.org` +- **Auth:** `X-API-Key` header (or `?token=KEY` for WebSocket) +- **API key:** set `$COMFY_CLOUD_API_KEY` once and the scripts pick it up automatically +- **Output download:** `/api/view` returns a 302 to a signed URL; the scripts + follow it and strip `X-API-Key` before fetching from the storage backend + (don't leak the API key to S3/CloudFront). +- **Endpoint differences from local ComfyUI:** + - `/api/object_info`, `/api/queue`, `/api/userdata` — **403 on free tier**; + paid only. + - `/history` is renamed to `/history_v2` on cloud (the scripts route + automatically). + - `/models/` is renamed to `/experiment/models/` on cloud + (the scripts route automatically). + - `clientId` in WebSocket is currently ignored — all connections for a + user receive the same broadcast. Filter by `prompt_id` client-side. + - `subfolder` is accepted on uploads but ignored — cloud has a flat namespace. +- **Concurrent jobs:** Free/Standard: 1, Creator: 3, Pro: 5. Extras queue + automatically. Use `run_batch.py --parallel N` to saturate your tier. + +## Queue & System Management + +```bash +# Local +curl -s http://127.0.0.1:8188/queue | python3 -m json.tool +curl -X POST http://127.0.0.1:8188/queue -d '{"clear": true}' # cancel pending +curl -X POST http://127.0.0.1:8188/interrupt # cancel running +curl -X POST http://127.0.0.1:8188/free \ + -H "Content-Type: application/json" \ + -d '{"unload_models": true, "free_memory": true}' + +# Cloud — same paths under /api/, plus: +python3 scripts/fetch_logs.py --tail-queue --host https://cloud.comfy.org +``` + +## Pitfalls + +1. **API format required** — every script and the `/api/prompt` endpoint expect + API-format workflow JSON. The scripts detect editor format (top-level + `nodes` and `links` arrays) and tell you to re-export via + "Workflow → Export (API)" (newer UI) or "Save (API Format)" (older UI). + +2. **Server must be running** — all execution requires a live server. + `comfy launch --background` starts one. Verify with + `curl http://127.0.0.1:8188/system_stats`. + +3. **Model names are exact** — case-sensitive, includes file extension. + `check_deps.py` does fuzzy matching (with/without extension and folder + prefix), but the workflow itself must use the canonical name. Use + `comfy model list` to discover what's installed. + +4. **Missing custom nodes** — "class_type not found" means a required node + isn't installed. `check_deps.py` reports which package to install; + `auto_fix_deps.py` runs the install for you. + +5. **Working directory** — `comfy-cli` auto-detects the ComfyUI workspace. + If commands fail with "no workspace found", use + `comfy --workspace /path/to/ComfyUI ` or + `comfy set-default /path/to/ComfyUI`. + +6. **Cloud free-tier API limits** — `/api/prompt`, `/api/view`, `/api/upload/*`, + `/api/object_info` all return 403 on free accounts. `health_check.py` and + `check_deps.py` handle this gracefully and surface a clear message. + +7. **Timeout for video/audio workflows** — auto-detected when an output node + is `VHS_VideoCombine`, `SaveVideo`, etc.; the default jumps from 300 s to + 900 s. Override explicitly with `--timeout 1800`. + +8. **Path traversal in output filenames** — server-supplied filenames are + passed through `safe_path_join` to refuse anything escaping `--output-dir`. + Keep this protection on — workflows with custom save nodes can produce + arbitrary paths. + +9. **Workflow JSON is arbitrary code** — custom nodes run Python, so + submitting an unknown workflow has the same trust profile as `eval`. + Inspect workflows from untrusted sources before running. + +10. **Auto-randomized seed** — pass `seed: -1` in `--args` (or use + `--randomize-seed` and omit the seed) to get a fresh seed per run. + The actual seed is logged to stderr. + +11. **`tracking` prompt** — first run of `comfy` may prompt for analytics. + Use `comfy --skip-prompt tracking disable` to skip non-interactively. + `comfyui_setup.sh` does this for you. + +## Verification Checklist + +Use `python3 scripts/health_check.py` to run the whole list at once. Manual: + +- [ ] `hardware_check.py` verdict is `ok` OR the user explicitly chose Comfy Cloud +- [ ] `comfy --version` works (or `uvx --from comfy-cli comfy --help`) +- [ ] `curl http://HOST:PORT/system_stats` returns JSON +- [ ] `comfy model list` shows at least one checkpoint (local) OR + `/api/experiment/models/checkpoints` returns models (cloud) +- [ ] Workflow JSON is in API format +- [ ] `check_deps.py` reports `is_ready: true` (or only `node_check_skipped` + on cloud free tier) +- [ ] Test run with a small workflow completes; outputs land in `--output-dir` diff --git a/image-creation/comfyui/comfyui.zip b/image-creation/comfyui/comfyui.zip new file mode 100644 index 0000000000000000000000000000000000000000..f35d8b35c3069ed4212b342645f06a6577c1cb3c GIT binary patch literal 113620 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 E0d$l^(f|Me literal 0 HcmV?d00001 diff --git a/image-creation/comfyui/install.md b/image-creation/comfyui/install.md new file mode 100644 index 0000000..3e4163b --- /dev/null +++ b/image-creation/comfyui/install.md @@ -0,0 +1,2 @@ +使用 ComfyUI 生成图片、视频和音频,并支持安装、启动、管理节点与模型,以及通过参数注入运行工作流。 +使用官方 comfy-cli 负责生命周期管理,并通过直接 REST/WebSocket API 执行任务。 diff --git a/image-creation/comfyui/references/official-cli.md b/image-creation/comfyui/references/official-cli.md new file mode 100644 index 0000000..59a981b --- /dev/null +++ b/image-creation/comfyui/references/official-cli.md @@ -0,0 +1,255 @@ +# comfy-cli Command Reference + +Official CLI from [Comfy-Org/comfy-cli](https://github.com/Comfy-Org/comfy-cli). +Docs: https://docs.comfy.org/comfy-cli/getting-started + +## Installation + +Order of preference: + +```bash +pipx install comfy-cli # recommended (isolated env) +uvx --from comfy-cli comfy --help # zero-install via uv +pip install --user comfy-cli # fallback +``` + +The skill's `comfyui_setup.sh` picks the best available method. + +First run may prompt for analytics. Disable non-interactively: +```bash +comfy --skip-prompt tracking disable +``` + +## Global Options + +| Option | Description | +|--------|-------------| +| `--workspace ` | Target a specific ComfyUI workspace | +| `--recent` | Use most recently used workspace | +| `--here` | Use current directory as workspace | +| `--skip-prompt` | No interactive prompts (use defaults) | +| `-v` / `--version` | Print version | + +Workspace resolution priority: +1. `--workspace` (explicit path) +2. `--recent` (from config) +3. `--here` (cwd) +4. `comfy set-default` path +5. Most recently used +6. `~/comfy/ComfyUI` (Linux) or `~/Documents/comfy/ComfyUI` (macOS/Win) + +## Lifecycle Commands + +### `comfy install` + +Download and install ComfyUI + ComfyUI-Manager. + +```bash +comfy install # interactive GPU selection +comfy install --nvidia +comfy install --amd # ROCm (Linux) +comfy install --m-series # Apple Silicon (MPS) +comfy install --cpu # CPU only (slow) +comfy install --fast-deps # use uv for deps +comfy install --skip-manager # skip ComfyUI-Manager +``` + +| Option | Description | +|--------|-------------| +| `--nvidia` / `--amd` / `--m-series` / `--cpu` | GPU type | +| `--cuda-version` | 11.8, 12.1, 12.4, 12.6, 12.8, 12.9, 13.0 | +| `--rocm-version` | 6.1, 6.2, 6.3, 7.0, 7.1 | +| `--fast-deps` | uv-based dependency resolution | +| `--skip-manager` | Don't install ComfyUI-Manager | +| `--skip-torch-or-directml` | Skip PyTorch install | +| `--version ` | `0.2.0`, `latest`, `nightly` | +| `--commit ` | Install specific commit | +| `--pr "#1234"` | Install from a PR | +| `--restore` | Restore deps for existing install | + +### `comfy launch` + +```bash +comfy launch # foreground :8188 +comfy launch --background # background daemon +comfy launch -- --listen 0.0.0.0 # LAN-accessible +comfy launch -- --port 8190 # custom port +comfy launch -- --cpu # force CPU mode +comfy launch -- --lowvram # 6 GB cards +comfy launch --background -- --listen 0.0.0.0 --port 8190 +``` + +Common extra args after `--`: `--listen`, `--port`, `--cpu`, `--lowvram`, +`--novram`, `--fp16-vae`, `--force-fp32`, `--disable-cuda-malloc`. + +### `comfy stop` + +```bash +comfy stop +``` + +### `comfy run` + +Submit a raw workflow JSON to a running server. **Limited** — no parameter +injection, no structured output download. For agents, use +`scripts/run_workflow.py` instead. + +```bash +comfy run --workflow workflow_api.json +comfy run --workflow workflow_api.json --host 10.0.0.5 --port 8188 +comfy run --workflow workflow_api.json --timeout 300 --wait +``` + +### `comfy which` + +```bash +comfy which # show targeted workspace +comfy --recent which +``` + +### `comfy set-default` + +```bash +comfy set-default /path/to/ComfyUI +comfy set-default /path/to/ComfyUI --launch-extras="--listen 0.0.0.0" +``` + +### `comfy update` + +```bash +comfy update # update ComfyUI core +comfy node update all # update all custom nodes +``` + +--- + +## `comfy node` — Custom Node Management + +All node operations use ComfyUI-Manager (`cm-cli`) under the hood. + +```bash +comfy node show installed # list installed +comfy node show enabled # list enabled +comfy node show all # all available in registry +comfy node simple-show installed # compact list + +comfy node install comfyui-impact-pack +comfy node install --uv-compile # ComfyUI-Manager v4.1+ unified resolver +comfy node uninstall +comfy node update | all +comfy node enable +comfy node disable +comfy node fix # fix broken deps + +comfy node install-deps --workflow=workflow.json +comfy node deps-in-workflow --workflow=w.json --output=deps.json + +comfy node save-snapshot +comfy node restore-snapshot + +comfy node bisect start # binary-search a culprit node +comfy node bisect good +comfy node bisect bad +comfy node bisect reset +``` + +### Dependency Resolution Options + +| Flag | Description | +|------|-------------| +| `--fast-deps` | comfy-cli built-in uv resolver | +| `--uv-compile` | ComfyUI-Manager v4.1+ unified resolver (recommended) | +| `--no-deps` | Skip dep installation | + +Make `uv-compile` default: `comfy manager uv-compile-default true` + +--- + +## `comfy model` — Model Management + +```bash +comfy model list +comfy model list --relative-path models/checkpoints + +comfy model download --url +comfy model download --url --relative-path models/loras +comfy model download --url --filename custom_name.safetensors + +comfy model remove # interactive +comfy model remove --relative-path models/checkpoints --model-names "model.safetensors" +``` + +| Option | Description | +|--------|-------------| +| `--url` | Download URL (CivitAI, HuggingFace, direct) | +| `--relative-path` | Subdirectory under workspace (e.g. `models/checkpoints`) | +| `--filename` | Custom save filename | +| `--set-civitai-api-token` | Persist CivitAI token | +| `--set-hf-api-token` | Persist HuggingFace token | +| `--downloader` | `httpx` (default) or `aria2` | + +Standard model directories: +``` +ComfyUI/models/ +├── checkpoints/ # Full model files +├── loras/ # LoRA adapters +├── vae/ # VAE models +├── controlnet/ # ControlNet models +├── clip/ # CLIP / T5 text encoders +├── clip_vision/ # CLIP vision encoders +├── upscale_models/ # ESRGAN / SwinIR / etc. +├── embeddings/ # Textual inversion embeddings +├── unet/ # Standalone UNet weights +├── diffusion_models/ # Flux / SD3 / Wan diffusion models +├── animatediff_models/ # AnimateDiff motion modules +├── ipadapter/ # IPAdapter weights +└── style_models/ # Style adapters +``` + +--- + +## `comfy manager` — ComfyUI-Manager Settings + +```bash +comfy manager disable # disable Manager completely +comfy manager enable-gui # enable new GUI +comfy manager disable-gui # API-only +comfy manager enable-legacy-gui # legacy GUI +comfy manager uv-compile-default true # make --uv-compile the default +comfy manager clear # clear startup action +``` + +--- + +## `comfy pr-cache` — Frontend PR Cache + +```bash +comfy pr-cache list +comfy pr-cache clean +comfy pr-cache clean 456 +``` + +Cache expires after 7 days; max 10 builds. + +--- + +## Configuration + +| OS | Path | +|----|------| +| Linux | `~/.config/comfy-cli/config.ini` | +| macOS | `~/Library/Application Support/comfy-cli/config.ini` | +| Windows | `~/AppData/Local/comfy-cli/config.ini` | + +Stores: default workspace, recent workspace, background server PID, API +tokens, manager GUI mode, launch extras. + +## Discovery + +Custom-node registry: +- https://registry.comfy.org/ + +Model browsers: +- https://huggingface.co/models +- https://civitai.com (NSFW; requires API token for many) +- https://comfyworkflows.com (community workflows) diff --git a/image-creation/comfyui/references/rest-api.md b/image-creation/comfyui/references/rest-api.md new file mode 100644 index 0000000..64091c9 --- /dev/null +++ b/image-creation/comfyui/references/rest-api.md @@ -0,0 +1,312 @@ +# ComfyUI REST + WebSocket API Reference + +ComfyUI exposes a REST + WebSocket interface for workflow execution and +management. **The same surface is used locally and on Comfy Cloud, with +auth/path differences.** + +## Connection + +| | Local ComfyUI | Comfy Cloud | +|---|---|---| +| Base URL | `http://127.0.0.1:8188` | `https://cloud.comfy.org` | +| API path prefix | none (`/prompt`, `/view`, …) | `/api/...` (`/api/prompt`, `/api/view`, …) | +| Auth | none (or bearer token if configured) | `X-API-Key` header | +| WebSocket | `ws://host:port/ws?clientId={uuid}` | `wss://cloud.comfy.org/ws?clientId={uuid}&token={API_KEY}` | +| `/api/view` response | direct bytes | 302 redirect → signed URL (use `curl -L`) | + +The skill scripts route URLs automatically via `_common.resolve_url()`. + +## Endpoint differences on Comfy Cloud + +The cloud surface diverges from local ComfyUI in several ways. The skill +scripts handle these transparently; document them here so anyone calling +`curl` directly knows. + +| Local path | Cloud path | Notes | +|------------|-----------|-------| +| `/system_stats` | `/api/system_stats` | Cloud version is **public** (no auth required) | +| `/object_info` | `/api/object_info` | **Paid tier only** — free returns 403 | +| `/queue` | `/api/queue` | Paid tier only | +| `/userdata` | `/api/userdata` | Paid tier only | +| `/prompt` (POST) | `/api/prompt` (POST) | Paid tier only | +| `/upload/image` | `/api/upload/image` | Paid tier only; `subfolder` accepted but ignored | +| `/upload/mask` | `/api/upload/mask` | Same as above | +| `/view` | `/api/view` | Paid tier only; **returns 302** to signed URL | +| `/history` | `/api/history_v2` | **Renamed**; old path returns 404 | +| `/history/{id}` | `/api/history_v2/{id}` or `/api/jobs/{id}` | Both work; `/jobs` returns full job | +| `/models` | `/api/experiment/models` | **Renamed** | +| `/models/{folder}` | `/api/experiment/models/{folder}` | **Renamed**; response shape differs (see below) | + +### Cloud model-list response shape + +- **Local:** `["a.safetensors", "b.safetensors", …]` — flat list of strings. +- **Cloud:** `[{"name": "a.safetensors", "pathIndex": 0}, …]` — list of objects. +- **Cloud 404 with `code: "folder_not_found"`** — folder is empty or unknown, + not an "endpoint missing" error. Distinguish by reading the body. + +The skill helper `_common.parse_model_list()` normalizes both. + +## Workflow Execution + +### Submit Workflow + +```bash +# Local +curl -X POST "http://127.0.0.1:8188/prompt" \ + -H "Content-Type: application/json" \ + -d '{"prompt": '"$(cat workflow_api.json)"', "client_id": "'"$(uuidgen)"'"}' + +# Cloud +curl -X POST "https://cloud.comfy.org/api/prompt" \ + -H "X-API-Key: $COMFY_CLOUD_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"prompt": '"$(cat workflow_api.json)"'}' +``` + +**Response:** +```json +{"prompt_id": "abc-123-def", "number": 1, "node_errors": {}} +``` + +If `node_errors` is non-empty, the workflow has validation errors (missing +nodes, bad inputs). + +### Check Job Status (Cloud) + +```bash +curl -X GET "https://cloud.comfy.org/api/job/{prompt_id}/status" \ + -H "X-API-Key: $COMFY_CLOUD_API_KEY" +``` + +| Status | Description | +| ------------- | ---------------------------------- | +| `pending` | Job is queued and waiting to start | +| `in_progress` | Job is currently executing | +| `completed` | Job finished successfully | +| `failed` | Job encountered an error | +| `cancelled` | Job was cancelled by user | + +### Job detail with outputs (Cloud) + +```bash +curl -X GET "https://cloud.comfy.org/api/jobs/{prompt_id}" \ + -H "X-API-Key: $COMFY_CLOUD_API_KEY" +``` + +Response includes `outputs` keyed by node ID. Cloud uses `video` (singular) +in the output structure; local uses `videos` (plural). The skill scripts +accept both. + +### Get History (Local) + +```bash +curl -s "http://127.0.0.1:8188/history" # all +curl -s "http://127.0.0.1:8188/history/{id}" # one prompt_id +``` + +Local entry shape: +```json +{ + "": { + "prompt": [...], + "outputs": {"": {"images": [...]}}, + "status": { + "status_str": "success" | "error", + "completed": true | false, + "messages": [["execution_start", {...}], ["execution_error", {...}], …] + } + } +} +``` + +**Important:** when reading status, check `status_str == "error"` BEFORE +checking `completed`, because both can be true for failed runs. + +### Download Output + +```bash +# Local (direct bytes) +curl -s "http://127.0.0.1:8188/view?filename=ComfyUI_00001_.png&subfolder=&type=output" \ + -o output.png + +# Cloud (302 → signed URL; -L follows; STRIP X-API-Key for the second hop) +curl -L "https://cloud.comfy.org/api/view?filename=...&type=output" \ + -H "X-API-Key: $COMFY_CLOUD_API_KEY" \ + -o output.png +``` + +The skill's `run_workflow.py` strips `X-API-Key` automatically on the +cross-host redirect, so the signed URL never sees your auth. + +## WebSocket Monitoring + +Connect for real-time execution events. + +```bash +# Local +wscat -c "ws://127.0.0.1:8188/ws?clientId=MY-UUID" + +# Cloud +wscat -c "wss://cloud.comfy.org/ws?clientId=MY-UUID&token=$COMFY_CLOUD_API_KEY" +``` + +**Note:** on Cloud the `clientId` is currently ignored — all messages for a +user are broadcast to every connection. Filter messages client-side by +`data.prompt_id`. + +### JSON Message Types + +| Type | When | Key Fields | +|------|------|------------| +| `status` | Queue change | `status.exec_info.queue_remaining` | +| `notification` | User-friendly status string | `value` | +| `execution_start` | Workflow begins | `prompt_id` | +| `executing` | Node running (or end-of-run if `node` is null on local) | `node`, `prompt_id` | +| `progress` | Sampling steps | `node`, `value`, `max` | +| `progress_state` | Extended progress with per-node metadata | `nodes` (dict) | +| `executed` | Node output ready | `node`, `output` (with `images`/`video`/etc.) | +| `execution_cached` | Nodes skipped because of cache | `nodes` (list of IDs) | +| `execution_success` | All done | `prompt_id` | +| `execution_error` | Failure | `exception_type`, `exception_message`, `traceback`, `node_id` | +| `execution_interrupted` | Cancelled | `prompt_id` | + +### Binary Frames (Preview Images) + +| Type code | Meaning | +|-----------|---------| +| `0x00000001` | `PREVIEW_IMAGE` — `[type:4][image_type:4][data]` (image_type 1=JPEG, 2=PNG) | +| `0x00000003` | `TEXT` — `[type:4][nid_len:4][nid][text]` (UTF-8) | +| `0x00000004` | `PREVIEW_IMAGE_WITH_METADATA` — `[type:4][meta_len:4][json][image_data]` | + +`scripts/ws_monitor.py --previews ` saves preview frames to disk. + +## File Upload + +```bash +# Image +curl -X POST "http://127.0.0.1:8188/upload/image" \ + -F "image=@photo.png" -F "type=input" -F "overwrite=true" +# Returns: {"name": "photo.png", "subfolder": "", "type": "input"} + +# Mask (linked to a previously uploaded image) +curl -X POST "http://127.0.0.1:8188/upload/mask" \ + -F "image=@mask.png" -F "type=input" \ + -F 'original_ref={"filename":"photo.png","subfolder":"","type":"input"}' +``` + +Cloud equivalent: prepend `https://cloud.comfy.org/api` and add `-H "X-API-Key: $COMFY_CLOUD_API_KEY"`. + +## Node & Model Discovery + +```bash +# All node types and their input specs +curl -s "http://127.0.0.1:8188/object_info" | python3 -m json.tool + +# Specific node +curl -s "http://127.0.0.1:8188/object_info/KSampler" + +# Models per folder (local) +curl -s "http://127.0.0.1:8188/models/checkpoints" +curl -s "http://127.0.0.1:8188/models/loras" + +# Models per folder (cloud — note the experimental prefix) +curl -s "https://cloud.comfy.org/api/experiment/models/checkpoints" \ + -H "X-API-Key: $COMFY_CLOUD_API_KEY" +``` + +## Queue Management + +```bash +# View queue +curl -s "http://127.0.0.1:8188/queue" + +# Clear all pending +curl -X POST "http://127.0.0.1:8188/queue" \ + -H "Content-Type: application/json" \ + -d '{"clear": true}' + +# Delete specific items +curl -X POST "http://127.0.0.1:8188/queue" \ + -H "Content-Type: application/json" \ + -d '{"delete": ["prompt_id_1", "prompt_id_2"]}' + +# Cancel currently-running job +curl -X POST "http://127.0.0.1:8188/interrupt" +``` + +## System Management + +```bash +# Stats (VRAM, RAM, GPU, ComfyUI version) +curl -s "http://127.0.0.1:8188/system_stats" + +# Free GPU memory +curl -X POST "http://127.0.0.1:8188/free" \ + -H "Content-Type: application/json" \ + -d '{"unload_models": true, "free_memory": true}' +``` + +## ComfyUI-Manager Endpoints (Optional) + +These require ComfyUI-Manager installed. Useful for installing nodes/models +via the API instead of `comfy-cli`. + +```bash +# Install a custom node from a git URL +curl -X POST "http://127.0.0.1:8188/manager/queue/install" \ + -H "Content-Type: application/json" \ + -d '{"git_url": "https://github.com/user/comfyui-node.git"}' + +# Check install queue status +curl -s "http://127.0.0.1:8188/manager/queue/status" + +# Install model +curl -X POST "http://127.0.0.1:8188/manager/queue/install_model" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://...", "path": "models/checkpoints", "filename": "model.safetensors"}' +``` + +## POST /prompt Payload Format + +```json +{ + "prompt": { + "3": { + "class_type": "KSampler", + "inputs": { + "seed": 42, + "steps": 20, + "cfg": 7.5, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1.0, + "model": ["4", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["5", 0] + } + } + }, + "client_id": "unique-uuid-for-ws-filtering", + "extra_data": { + "api_key_comfy_org": "optional-PARTNER-NODE-key (NOT the cloud auth key)" + } +} +``` + +- `prompt`: workflow graph in API format +- `client_id`: UUID — local server uses it to filter WebSocket events; cloud + ignores it. +- `extra_data.api_key_comfy_org`: ONLY required when the workflow uses + partner nodes (Flux Pro, Ideogram, etc.). Don't conflate with `X-API-Key`. + +## Error Categories (cloud `execution_error` `exception_type`) + +| Type | Meaning | +|------|---------| +| `ValidationError` | Bad workflow / inputs (often nicer to surface from `node_errors`) | +| `ModelDownloadError` | Required model not available | +| `ImageDownloadError` | Failed to fetch input image from URL | +| `OOMError` | Out of GPU memory | +| `InsufficientFundsError` | Account balance too low (partner nodes) | +| `InactiveSubscriptionError` | Subscription not active | diff --git a/image-creation/comfyui/references/template-integrity.md b/image-creation/comfyui/references/template-integrity.md new file mode 100644 index 0000000..050e3e6 --- /dev/null +++ b/image-creation/comfyui/references/template-integrity.md @@ -0,0 +1,243 @@ +# ComfyUI Workflow-Template Integrity + +> **Authored by [@purzbeats](https://github.com/purzbeats)** — adapted from +> [purzbeats/hermes-agent-comfyui-helper](https://github.com/purzbeats/hermes-agent-comfyui-helper). +> Use this reference when converting workflows from the official +> `comfyui-workflow-templates` package (editor format) into API format for +> submission via `/api/prompt`. The conversion has subtle gotchas that cause +> hard-to-diagnose validation errors if you don't follow these rules. + +## Background + +The official ComfyUI template package (`comfyui-workflow-templates`, currently +v0.9.69) is installed inside the ComfyUI venv at a path like: + +``` +/.venv/lib/python3.*/site-packages/comfyui_workflow_templates_*/templates/ +``` + +The exact path depends on how ComfyUI was installed (comfy-cli default, +Comfy Desktop, manual venv, etc.). Find it once with: + +```bash +comfy --workspace run-python -c "import comfyui_workflow_templates, pathlib; print(pathlib.Path(comfyui_workflow_templates.__file__).parent / 'templates')" +``` + +Templates ship in **editor format** — `nodes` / `links` arrays inside +`data['definitions']['subgraphs'][0]`. They must be converted to **API +format** (a `node_id -> {class_type, inputs}` mapping) before submission. + +--- + +## RULE #1: Use templates AS CLOSE TO ORIGINAL AS POSSIBLE + +- **Never strip, simplify, or "minimize" nodes** from a template. +- Full template architecture (dual-pass pipelines, LoRA chains, distilled + sigmas, conditioning paths) is intentional — removing any part breaks quality. +- If an image-dependent path exists but the task is text-to-video, **leave + it wired with the bypass toggle enabled** — don't remove the nodes. +- Only change: prompt text, seed, and dimensions (when explicitly requested). + +## RULE #2: Server validation errors are the source of truth + +When a workflow submission fails, the server response looks like: + +```json +{ + "node_errors": { + "238": { + "errors": [{ + "message": "Required input is missing", + "details": "width", + "extra_info": { "input_name": "resize_type.width" } + }] + } + } +} +``` + +**The `extra_info.input_name` field tells you EXACTLY what JSON key the server +wants. Use it literally.** If it says `"values.a"` or `"resize_type.width"`, +those are the actual key names in the JSON object. Do not "simplify" them to +flat names based on assumptions about what the field "should" be called. + +## RULE #3: Don't rebuild from scratch — patch the failing nodes + +Every regeneration from the template reintroduces the same bugs. Instead: + +1. Submit the workflow once. +2. Read the server error details for exact key names. +3. Use targeted patch/fix calls against the workflow file on disk. +4. Resubmit and check if errors resolved. + +--- + +## Reroute nodes: bypass, don't delete + +Most servers (local, Cloud) don't have a `Reroute` node type. When converting +a template: + +1. Find what feeds into the Reroute by looking at links where + `target_id` = the Reroute node ID. +2. Replace all inputs referencing the Reroute with + `[source_node_id, source_slot]`. +3. Delete the Reroute node from the API mapping. + +**Real example — LTX 2.3 t2v template:** + +- Reroute node 255 receives VAE from `CheckpointLoaderSimple 236` slot 2. +- Three nodes reference Reroute 255 for their VAE input: + `LTXVImgToVideoInplace` (230), `LTXVLatentUpsampler` (253), + `VAEDecodeTiled` (251). +- Fix: replace all occurrences of `vae: ["255", 0]` with `vae: ["236", 2]`. +- `CheckpointLoaderSimple` slot 2 = VAE (not slot 0 = MODEL). + +| | | +|---|---| +| ❌ Wrong | `vae: ["236", 0]` → `MODELV mismatch input_type(VAE)` | +| ✅ Correct | `vae: ["236", 2]` | + +--- + +## Dynamic template nodes: dotted key names are correct + +### ComfyMathExpression (COMFY_AUTOGROW_V3) + +```json +{ + "class_type": "ComfyMathExpression", + "inputs": { + "expression": "a/2", + "values.a": ["257", 0] + } +} +``` + +- `values` is a `COMFY_AUTOGROW_V3` template. +- Input names in links are `values.a`, `values.b`, etc. +- **Keep the dotted format as JSON keys.** +- Do NOT convert to `{"values": {"a": ...}}` or flatten to just `"a"`. + +### ResizeImageMaskNode (COMFY_DYNAMICCOMBO_V3) + +```json +{ + "class_type": "ResizeImageMaskNode", + "inputs": { + "input": ["276", 0], + "scale_method": "lanczos", + "resize_type": "scale dimensions", + "resize_type.width": 1920, + "resize_type.height": 1088, + "resize_type.crop": "center" + } +} +``` + +- `resize_type` is a `COMFY_DYNAMICCOMBO_V3`. +- Mode-specific fields: `resize_type.width`, `resize_type.height`, `resize_type.crop`. +- `scale_method` options: `"nearest-exact"`, `"bilinear"`, `"area"`, `"bicubic"`, `"lanczos"`. +- **Keep the dotted format as JSON keys.** +- Do NOT flatten `resize_type.width` to just `"width"`. + +--- + +## Conversion recipe + +1. Load template from the installed package path. +2. Parse `data['definitions']['subgraphs'][0]`. +3. For each node (skip Reroute): + - Resolve linked inputs from `sg['links']` dict. + - Map `widgets_values` to input field names. + - Keep all dotted key names as-is from the template. +4. Bypass Reroute: trace source, replace references. +5. Change only: prompt text, seed values, and user-requested parameters. +6. Add `SaveVideo` terminal node if template uses only `CreateVideo`. +7. Submit → read errors → patch specific nodes → resubmit. + +## What to NEVER change in a template + +| Element | Why | +|---------|-----| +| Node topology | Graph is designed for the specific model | +| Sigmas values | Tuned for the model/sampler combination | +| LoRA/distilled paths | Required for quality, even if they look unused | +| Model parameters (cfg, steps, shifts) | Model-specific | +| Conditioning chains (zero-out, crop guides) | Required for correct conditioning | +| Pass-through wiring | Don't remove nodes, bypass them | + +--- + +## Cloud compatibility (verified May 2025) + +The full LTX 2.3 T2V template (`video_ltx2_3_t2v.json`) runs **without +modification** on Comfy Cloud. + +**Confirmed working on Cloud (all custom nodes available):** +`ComfyMathExpression`, `ResizeImageMaskNode`, `ResizeImagesByLongerEdge`, +`PrimitiveInt`, `PrimitiveStringMultiline`, `PrimitiveBoolean`, `SaveVideo`, +`LTXVCropGuides`, `LTXVImgToVideoInplace`, `LTXVConcatAVLatent`, +`LTXVSeparateAVLatent`, `LTXVLatentUpsampler`, `LTXVAudioVAELoader`, +`LTXVAudioVAEDecode`, `LTXVEmptyLatentAudio`, `LTXVPreprocess`, +`LTXVConditioning`, `ManualSigmas`, `LTXAVTextEncoderLoader`, plus all core +nodes. + +**Cloud vs Local for LTX 2.3 (768x512):** + +- Cloud: ~39s per video (4x faster). +- Local (RTX 5090): ~160s per video. +- `example.png` placeholder works on Cloud for bypassed image-dependent paths. +- Submission format is **identical** between local and Cloud: + `{"prompt": wf, "extra_data": {}}` to `/api/prompt`. +- Free tier = 1 concurrent job. + +**Cloud submission pitfalls:** + +- `/api/object_info/` returns 404 on free tier — can't query node + schemas remotely, but the workflow runs fine anyway. Always probe + `object_info` locally before building workflows. +- Cloud is ~4x faster — prefer Cloud for batch runs unless local is needed + for debugging. +- Cloud `/api/view` returns **302 redirect to signed GCS URL** — use + `curl -s -L` to follow and download. Python `urllib` fails with 401 + (forwards auth headers to GCS CDN). +- `COMFY_CLOUD_API_KEY` is only in the terminal/bash env, not in the Python + sandbox. Use subprocess or terminal scripts for Cloud API calls. +- Cloud free tier processes jobs **sequentially** (1 at a time). Submit all, + then poll history. +- LTX 2.3 at **1920x1080 OOMs locally** (even RTX 5090) — upscaler pass + exceeds VRAM. Prefer Cloud for 1080p; use 1280x720 locally (~90s/video). + +--- + +## FFmpeg stitch settings (Discord-compatible) + +Generated ComfyUI videos often use `yuv444p` pixel format which does NOT work +on Discord. Re-encode with: + +```bash +ffmpeg -y -i input.mp4 \ + -c:v libx264 -profile:v main -preset medium -crf 13 -pix_fmt yuv420p \ + -c:a aac -b:a 192k \ + output_discord.mp4 +``` + +Key settings: + +- `-pix_fmt yuv420p` — **required for Discord**, ComfyUI outputs `yuv444p` by default. +- `-crf 13` — high quality without massive file size (default 23 is too lossy). +- `-profile:v main` — widely compatible. + +For multi-video crossfade stitching, chain `xfade` (video) and `acrossfade` +(audio): + +```bash +ffmpeg -y -i a.mp4 -i b.mp4 -i c.mp4 \ + -filter_complex "[0:v][1:v]xfade=transition=fade:duration=1:offset=3.04[v1];[v1][2:v]xfade=transition=fade:duration=1:offset=6.08[vout];[0:a][1:a]acrossfade=duration=1:c1=tri:c2=tri[a1];[a1][2:a]acrossfade=duration=1:c1=tri:c2=tri[aout]" \ + -map "[vout]" -map "[aout]" \ + -c:v libx264 -profile:v main -crf 13 -pix_fmt yuv420p \ + -c:a aac -b:a 192k \ + output.mp4 +``` + +Offset for xfade #N = `(N+1) × duration - N × overlap`. diff --git a/image-creation/comfyui/references/workflow-format.md b/image-creation/comfyui/references/workflow-format.md new file mode 100644 index 0000000..e8343de --- /dev/null +++ b/image-creation/comfyui/references/workflow-format.md @@ -0,0 +1,226 @@ +# ComfyUI Workflow JSON Format + +## Two Formats — Only API Format Is Executable + +**API format** is required for `/api/prompt` and every script in this skill. +The web UI also produces an "editor format" used for visual editing, which +**cannot** be submitted directly. + +### API Format + +Top-level keys are string node IDs. Each node has `class_type` and `inputs`: + +```json +{ + "3": { + "class_type": "KSampler", + "inputs": { + "seed": 156680208700286, + "steps": 20, + "cfg": 8, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1.0, + "model": ["4", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["5", 0] + }, + "_meta": {"title": "KSampler"} + }, + "4": { + "class_type": "CheckpointLoaderSimple", + "inputs": {"ckpt_name": "v1-5-pruned-emaonly.safetensors"} + } +} +``` + +**Detection:** every top-level value has `class_type`. The skill's +`_common.is_api_format()` does this check. + +### Editor Format (not directly executable) + +Has `nodes[]` and `links[]` arrays — the visual graph. To convert: open in +ComfyUI's web UI and use **Workflow → Export (API)** (newer UI) or the +"Save (API Format)" button (older UI). + +**Detection:** top-level has `"nodes"` and `"links"` keys. + +## Inputs: Literals vs Links + +```json +"inputs": { + "text": "a cat", // literal — modifiable + "seed": 42, // literal — modifiable + "clip": ["4", 1] // link — wiring; do NOT overwrite +} +``` + +Links are length-2 arrays of `[upstream_node_id, output_slot]`. The skill's +parameter injector refuses to overwrite a link with a literal (logs a +warning and skips). + +## Common Node Types and Their Controllable Parameters + +The full catalog lives in `scripts/_common.py` (`PARAM_PATTERNS` and +`MODEL_LOADERS`). Highlights: + +### Text Prompts + +| Node Class | Key Fields | +|------------|------------| +| `CLIPTextEncode` | `text` | +| `CLIPTextEncodeSDXL` | `text_g`, `text_l`, `width`, `height` | +| `CLIPTextEncodeFlux` | `clip_l`, `t5xxl`, `guidance` | + +To distinguish positive from negative the skill traces `KSampler.negative` +back through Reroute / Primitive nodes to the source CLIPTextEncode. Falls +back to `_meta.title` heuristics ("negative", "neg", "anti"). + +### Sampling + +| Node Class | Key Fields | +|------------|------------| +| `KSampler` | `seed`, `steps`, `cfg`, `sampler_name`, `scheduler`, `denoise` | +| `KSamplerAdvanced` | `noise_seed`, `steps`, `cfg`, `start_at_step`, `end_at_step` | +| `SamplerCustom` | `noise_seed`, `cfg`, `sampler`, `sigmas` | +| `SamplerCustomAdvanced` | `noise_seed` (via RandomNoise input) | +| `RandomNoise` | `noise_seed` | +| `BasicScheduler` | `steps`, `scheduler`, `denoise` | +| `KSamplerSelect` | `sampler_name` | +| `BasicGuider` / `CFGGuider` | `cfg` | +| `ModelSamplingFlux` | `max_shift`, `base_shift`, `width`, `height` | +| `SDTurboScheduler` | `steps`, `denoise` | + +### Latent / Dimensions + +| Node Class | Key Fields | +|------------|------------| +| `EmptyLatentImage` | `width`, `height`, `batch_size` | +| `EmptySD3LatentImage` | `width`, `height`, `batch_size` | +| `EmptyHunyuanLatentVideo` | `width`, `height`, `length`, `batch_size` | +| `EmptyMochiLatentVideo` | `width`, `height`, `length`, `batch_size` | +| `EmptyLTXVLatentVideo` | `width`, `height`, `length`, `batch_size` | + +### Model Loading + +| Node Class | Key Fields | Folder | +|------------|------------|--------| +| `CheckpointLoaderSimple` | `ckpt_name` | `checkpoints` | +| `LoraLoader` | `lora_name`, `strength_model`, `strength_clip` | `loras` | +| `LoraLoaderModelOnly` | `lora_name`, `strength_model` | `loras` | +| `VAELoader` | `vae_name` | `vae` | +| `ControlNetLoader` | `control_net_name` | `controlnet` | +| `CLIPLoader` | `clip_name` | `clip` | +| `DualCLIPLoader` | `clip_name1`, `clip_name2` | `clip` | +| `TripleCLIPLoader` | `clip_name1/2/3` | `clip` | +| `UNETLoader` | `unet_name` | `unet` | +| `DiffusionModelLoader` | `model_name` | `diffusion_models` | +| `UpscaleModelLoader` | `model_name` | `upscale_models` | +| `IPAdapterModelLoader` | `ipadapter_file` | `ipadapter` | +| `ADE_AnimateDiffLoaderWithContext` | `model_name`, `motion_scale` | `animatediff_models` | + +### Image Input/Output + +| Node Class | Key Fields | +|------------|------------| +| `LoadImage` | `image` (server-side filename, after upload) | +| `LoadImageMask` | `image`, `channel` (`red` / `green` / `blue` / `alpha`) | +| `VAEEncode` / `VAEDecode` | (no controllable fields) | +| `VAEEncodeForInpaint` | `grow_mask_by` | +| `SaveImage` | `filename_prefix` | +| `VHS_VideoCombine` | `frame_rate`, `format`, `filename_prefix`, `loop_count`, `pingpong` | + +### ControlNet + +| Node Class | Key Fields | +|------------|------------| +| `ControlNetApply` | `strength` | +| `ControlNetApplyAdvanced` | `strength`, `start_percent`, `end_percent` | + +### IPAdapter (community pack `comfyui_ipadapter_plus`) + +| Node Class | Key Fields | +|------------|------------| +| `IPAdapterAdvanced` | `weight`, `start_at`, `end_at` | +| `IPAdapter` | `weight` | + +### Embeddings (referenced inside prompt strings) + +ComfyUI scans prompt text for `embedding:NAME` syntax. The skill's +`_common.iter_embedding_refs()` extracts these as model dependencies. + +```text +"a beautiful cat, embedding:goodvibes:1.2, embedding:art-style" +``` + +`extract_schema.py` and `check_deps.py` surface these in +`embedding_dependencies` / `missing_embeddings`. + +## Parameter Injection Pattern + +```python +import json, copy + +with open("workflow_api.json") as f: + workflow = json.load(f) + +wf = copy.deepcopy(workflow) +wf["6"]["inputs"]["text"] = "a beautiful sunset" +wf["7"]["inputs"]["text"] = "ugly, blurry" +wf["3"]["inputs"]["seed"] = 42 +wf["3"]["inputs"]["steps"] = 30 +wf["5"]["inputs"]["width"] = 1024 +wf["5"]["inputs"]["height"] = 1024 +``` + +`scripts/extract_schema.py` automates discovering which node IDs/fields +correspond to which user-facing parameters. It returns a `parameters` dict +that `run_workflow.py` reads to inject values from `--args`. + +## Identifying Controllable Parameters (Heuristics) + +For unknown workflows: + +1. **Prompt text** — any `CLIPTextEncode.text`. Use connection tracing back + from `KSampler.positive` / `.negative` to disambiguate (don't trust + meta-title alone). +2. **Seed** — `KSampler.seed` / `KSamplerAdvanced.noise_seed` / `RandomNoise.noise_seed`. +3. **Dimensions** — `Empty*LatentImage.width/height` (must be multiples of 8). +4. **Steps / CFG** — `KSampler.steps`, `KSampler.cfg`. Steps 20–50 typical. + CFG 5–15 typical (Flux uses guidance, not CFG). +5. **Model / checkpoint** — `CheckpointLoaderSimple.ckpt_name`. Filename must + match an installed file *exactly*. +6. **LoRA** — `LoraLoader.lora_name`, `.strength_model`. +7. **Images for img2img / inpaint** — `LoadImage.image`. Server-side filename + after upload. +8. **Denoise** — `KSampler.denoise`. 0.0–1.0; 1.0 = ignore input image, + 0.0 = pass through. Sweet spot for img2img: 0.4–0.7. + +## Output Nodes + +Output is produced by these node types. The skill's `OUTPUT_NODES` set +extends to common community packs. + +| Node | Output Key | Content | +|------|-----------|---------| +| `SaveImage` | `images` | List of `{filename, subfolder, type}` | +| `PreviewImage` | `images` | Temporary preview (not saved) | +| `VHS_VideoCombine` | `gifs` (older) or `videos`/`video` (newer cloud) | Video file refs | +| `SaveAudio` | `audio` | Audio file refs | +| `SaveAnimatedWEBP` / `SaveAnimatedPNG` | `images` | Animated images | +| `Save3D` | `3d` | 3D asset refs | + +After execution, fetch outputs from `/history/{prompt_id}` (local) or +`/api/jobs/{prompt_id}` (cloud) → `outputs` → `{node_id}` → `{key}`. + +## Wrapper Variants + +Some saved JSON files wrap the workflow under a `"prompt"` key (matching +the `/api/prompt` payload shape). The skill's `_common.unwrap_workflow()` +handles this — pass any of: + +- raw API format: `{"3": {...}, "4": {...}}` +- wrapped: `{"prompt": {"3": {...}}, "client_id": "..."}` + +It rejects editor format with a clear error and a re-export instruction. diff --git a/image-creation/comfyui/scripts/_common.py b/image-creation/comfyui/scripts/_common.py new file mode 100644 index 0000000..efe592a --- /dev/null +++ b/image-creation/comfyui/scripts/_common.py @@ -0,0 +1,835 @@ +""" +_common.py — Shared logic for ComfyUI skill scripts. + +Single source of truth for: +- HTTP transport (with retry/backoff, streaming, timeout handling) +- Cloud detection and endpoint mapping (local ComfyUI vs Comfy Cloud) +- Workflow node-type catalogs (param patterns, model loaders, output nodes) +- API-format validation +- Path-traversal-safe file writes +- API-key loading from env / CLI + +Stdlib-only by design (with optional `requests` upgrade if installed). Python 3.10+. +""" + +from __future__ import annotations + +import json +import os +import random +import re +import sys +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterator +from urllib.parse import urlparse + +# Optional: prefer `requests` if installed (better redirects, streaming, header handling) +try: + import requests # type: ignore[import-not-found] + HAS_REQUESTS = True +except ImportError: # pragma: no cover - exercised via stdlib fallback + HAS_REQUESTS = False + import urllib.error + import urllib.request + + +# ============================================================================= +# Constants & catalogs +# ============================================================================= + +DEFAULT_LOCAL_HOST = "http://127.0.0.1:8188" +DEFAULT_CLOUD_HOST = "https://cloud.comfy.org" +ENV_API_KEY = "COMFY_CLOUD_API_KEY" + +# Connection / retry defaults +DEFAULT_HTTP_TIMEOUT = 60 # seconds — single-attempt request timeout +DEFAULT_RETRIES = 3 # total attempts including the first +RETRY_BASE_DELAY = 1.0 # seconds — exponential backoff base +RETRY_MAX_DELAY = 30.0 # seconds — cap on backoff +RETRY_STATUS_CODES = {408, 429, 500, 502, 503, 504, 522, 524} + +# Streaming download chunk size (bytes) +DOWNLOAD_CHUNK_SIZE = 1 << 16 # 64 KiB + +# Heuristic: workflows with these node types tend to be slow → larger default timeout +SLOW_OUTPUT_NODES = { + "VHS_VideoCombine", "SaveAnimatedWEBP", "SaveAnimatedPNG", + "SaveVideo", "SaveAudio", "SaveAnimateDiffVideo", + "SVD_img2vid_Conditioning", + "WanVideoSampler", "HunyuanVideoSampler", + "CogVideoSampler", "LTXVideoSampler", +} + +# --------------------------------------------------------------------------- +# Output node catalog (extensible — community packs add their own) +# --------------------------------------------------------------------------- +OUTPUT_NODES: set[str] = { + # Built-in + "SaveImage", "PreviewImage", + "SaveAudio", "SaveVideo", "PreviewAudio", "PreviewVideo", + "SaveAnimatedWEBP", "SaveAnimatedPNG", + # Common community packs + "VHS_VideoCombine", # Video Helper Suite + "ImageSave", # Was Node Suite + "Image Save", # Was Node Suite (alt name) + "easy imageSave", # easy-use + "Image Save With Metadata", + "PreviewImage|pysssss", # pysssss preview + "ShowText|pysssss", + "SaveLatent", + "SaveGLB", # 3D + "Save3D", +} + +# --------------------------------------------------------------------------- +# Folder aliases — handle ComfyUI's gradual folder renames +# --------------------------------------------------------------------------- +# When `check_deps.py` queries `/models/` and gets 404 / empty, +# it tries each alias in turn. Critical for Comfy Cloud which has fully +# migrated to the new naming (unet → diffusion_models, clip → text_encoders). +FOLDER_ALIASES: dict[str, list[str]] = { + "unet": ["unet", "diffusion_models"], + "diffusion_models": ["diffusion_models", "unet"], + "clip": ["clip", "text_encoders"], + "text_encoders": ["text_encoders", "clip"], + "controlnet": ["controlnet", "control_net"], +} + + +def folder_aliases_for(folder: str) -> list[str]: + """Return the search order of folder names (primary first).""" + return FOLDER_ALIASES.get(folder, [folder]) + + +# --------------------------------------------------------------------------- +# Model-loader catalog: class_type -> (input field, model folder) +# --------------------------------------------------------------------------- +# A loader can have multiple fields (e.g., DualCLIPLoader has clip_name1 and +# clip_name2). We list them with explicit entries. The folder name is the +# *canonical* one; FOLDER_ALIASES is consulted when querying. +MODEL_LOADERS: dict[str, list[tuple[str, str]]] = { + # Checkpoints + "CheckpointLoaderSimple": [("ckpt_name", "checkpoints")], + "CheckpointLoader": [("ckpt_name", "checkpoints")], + "CheckpointLoader (Simple)": [("ckpt_name", "checkpoints")], + "ImageOnlyCheckpointLoader": [("ckpt_name", "checkpoints")], + "unCLIPCheckpointLoader": [("ckpt_name", "checkpoints")], + # LoRA + "LoraLoader": [("lora_name", "loras")], + "LoraLoaderModelOnly": [("lora_name", "loras")], + "LoraLoaderTagsQuery": [("lora_name", "loras")], + # VAE + "VAELoader": [("vae_name", "vae")], + # ControlNet + "ControlNetLoader": [("control_net_name", "controlnet")], + "DiffControlNetLoader": [("control_net_name", "controlnet")], + "ControlNetLoaderAdvanced": [("control_net_name", "controlnet")], + # CLIP / text encoders (primary "clip" folder; check_deps tries text_encoders too) + "CLIPLoader": [("clip_name", "clip")], + "DualCLIPLoader": [("clip_name1", "clip"), ("clip_name2", "clip")], + "TripleCLIPLoader": [("clip_name1", "clip"), ("clip_name2", "clip"), ("clip_name3", "clip")], + "CLIPVisionLoader": [("clip_name", "clip_vision")], + # UNET / Diffusion model (primary "unet"; check_deps tries diffusion_models too) + "UNETLoader": [("unet_name", "unet")], + "DiffusionModelLoader": [("model_name", "diffusion_models")], + "UNETLoaderGGUF": [("unet_name", "unet")], + # Upscaler + "UpscaleModelLoader": [("model_name", "upscale_models")], + # Style / GLIGEN / Hypernetwork + "StyleModelLoader": [("style_model_name", "style_models")], + "GLIGENLoader": [("gligen_name", "gligen")], + "HypernetworkLoader": [("hypernetwork_name", "hypernetworks")], + # IPAdapter family (community). + # Note: IPAdapterUnifiedLoader's `preset` and IPAdapterInsightFaceLoader's + # `provider` are enums (not file paths), so they're intentionally omitted — + # check_deps would otherwise treat enum values as missing model files. + "IPAdapterModelLoader": [("ipadapter_file", "ipadapter")], + "InstantIDModelLoader": [("instantid_file", "instantid")], + # AnimateDiff / video + "ADE_LoadAnimateDiffModel": [("model_name", "animatediff_models")], + "ADE_AnimateDiffLoaderWithContext": [("model_name", "animatediff_models")], + "ADE_AnimateDiffLoaderGen1": [("model_name", "animatediff_models")], + # Photomaker + "PhotoMakerLoader": [("photomaker_model_name", "photomaker")], + # Sampler / scheduler models + "ModelSamplingFlux": [], # parametric only +} + +# --------------------------------------------------------------------------- +# Param patterns: (class_type, field_name) -> friendly_name +# Order matters — first match wins for naming. Use _meta.title for disambiguation. +# --------------------------------------------------------------------------- +PARAM_PATTERNS: list[tuple[str, str, str]] = [ + # ---- Prompts ---- + ("CLIPTextEncode", "text", "prompt"), + ("CLIPTextEncodeSDXL", "text_g", "prompt"), + ("CLIPTextEncodeSDXL", "text_l", "prompt_l"), + ("CLIPTextEncodeSDXLRefiner", "text", "refiner_prompt"), + ("CLIPTextEncodeFlux", "clip_l", "prompt_l"), + ("CLIPTextEncodeFlux", "t5xxl", "prompt"), + ("CLIPTextEncodeFlux", "guidance", "guidance"), + ("smZ CLIPTextEncode", "text", "prompt"), + ("BNK_CLIPTextEncodeAdvanced", "text", "prompt"), + + # ---- Standard sampling ---- + ("KSampler", "seed", "seed"), + ("KSampler", "steps", "steps"), + ("KSampler", "cfg", "cfg"), + ("KSampler", "sampler_name", "sampler_name"), + ("KSampler", "scheduler", "scheduler"), + ("KSampler", "denoise", "denoise"), + ("KSamplerAdvanced", "noise_seed", "seed"), + ("KSamplerAdvanced", "steps", "steps"), + ("KSamplerAdvanced", "cfg", "cfg"), + ("KSamplerAdvanced", "sampler_name", "sampler_name"), + ("KSamplerAdvanced", "scheduler", "scheduler"), + ("KSamplerAdvanced", "start_at_step", "start_at_step"), + ("KSamplerAdvanced", "end_at_step", "end_at_step"), + + # ---- Modern sampler chain (Flux / SD3 / SDXL refiner via SamplerCustom) ---- + ("RandomNoise", "noise_seed", "seed"), + ("BasicScheduler", "steps", "steps"), + ("BasicScheduler", "scheduler", "scheduler"), + ("BasicScheduler", "denoise", "denoise"), + ("KSamplerSelect", "sampler_name", "sampler_name"), + # NB: BasicGuider has no cfg input (it just bundles model+conditioning). + ("CFGGuider", "cfg", "cfg"), + ("DualCFGGuider", "cfg_conds", "cfg"), + ("DualCFGGuider", "cfg_cond2_negative", "cfg_negative"), + ("ModelSamplingFlux", "max_shift", "max_shift"), + ("ModelSamplingFlux", "base_shift", "base_shift"), + ("ModelSamplingFlux", "width", "model_width"), + ("ModelSamplingFlux", "height", "model_height"), + ("ModelSamplingSD3", "shift", "shift"), + ("ModelSamplingDiscrete", "sampling", "sampling"), + ("SDTurboScheduler", "steps", "steps"), + ("SDTurboScheduler", "denoise", "denoise"), + ("SamplerCustom", "noise_seed", "seed"), + ("SamplerCustom", "cfg", "cfg"), + # NB: SamplerCustomAdvanced takes a NOISE input (from RandomNoise) — no seed field directly. + + # ---- Dimensions / latent ---- + ("EmptyLatentImage", "width", "width"), + ("EmptyLatentImage", "height", "height"), + ("EmptyLatentImage", "batch_size", "batch_size"), + ("EmptySD3LatentImage", "width", "width"), + ("EmptySD3LatentImage", "height", "height"), + ("EmptySD3LatentImage", "batch_size", "batch_size"), + ("EmptyHunyuanLatentVideo", "width", "width"), + ("EmptyHunyuanLatentVideo", "height", "height"), + ("EmptyHunyuanLatentVideo", "length", "length"), + ("EmptyHunyuanLatentVideo", "batch_size", "batch_size"), + ("EmptyMochiLatentVideo", "width", "width"), + ("EmptyMochiLatentVideo", "height", "height"), + ("EmptyMochiLatentVideo", "length", "length"), + ("EmptyLTXVLatentVideo", "width", "width"), + ("EmptyLTXVLatentVideo", "height", "height"), + ("EmptyLTXVLatentVideo", "length", "length"), + ("LatentUpscale", "width", "upscale_width"), + ("LatentUpscale", "height", "upscale_height"), + ("LatentUpscaleBy", "scale_by", "scale_by"), + ("ImageScale", "width", "width"), + ("ImageScale", "height", "height"), + + # ---- Image input ---- + ("LoadImage", "image", "image"), + ("LoadImageMask", "image", "mask_image"), + ("LoadImageOutput", "image", "image"), + ("VHS_LoadVideo", "video", "video"), + ("VHS_LoadAudio", "audio", "audio"), + + # ---- Model selection (sometimes useful to swap per run) ---- + ("CheckpointLoaderSimple", "ckpt_name", "ckpt_name"), + ("CheckpointLoader", "ckpt_name", "ckpt_name"), + ("ImageOnlyCheckpointLoader", "ckpt_name", "ckpt_name"), + ("VAELoader", "vae_name", "vae_name"), + ("UNETLoader", "unet_name", "unet_name"), + ("DiffusionModelLoader", "model_name", "diffusion_model_name"), + ("UpscaleModelLoader", "model_name", "upscale_model_name"), + ("CLIPLoader", "clip_name", "clip_name"), + ("DualCLIPLoader", "clip_name1", "clip_name1"), + ("DualCLIPLoader", "clip_name2", "clip_name2"), + ("ControlNetLoader", "control_net_name", "controlnet_name"), + + # ---- LoRA ---- + ("LoraLoader", "lora_name", "lora_name"), + ("LoraLoader", "strength_model", "lora_strength"), + ("LoraLoader", "strength_clip", "lora_strength_clip"), + ("LoraLoaderModelOnly", "lora_name", "lora_name"), + ("LoraLoaderModelOnly", "strength_model", "lora_strength"), + + # ---- ControlNet ---- + ("ControlNetApply", "strength", "controlnet_strength"), + ("ControlNetApplyAdvanced", "strength", "controlnet_strength"), + ("ControlNetApplyAdvanced", "start_percent", "controlnet_start"), + ("ControlNetApplyAdvanced", "end_percent", "controlnet_end"), + + # ---- IPAdapter ---- + ("IPAdapterAdvanced", "weight", "ipadapter_weight"), + ("IPAdapterAdvanced", "start_at", "ipadapter_start"), + ("IPAdapterAdvanced", "end_at", "ipadapter_end"), + ("IPAdapter", "weight", "ipadapter_weight"), + + # ---- Upscale ---- + ("ImageUpscaleWithModel", "upscale_method", "upscale_method"), + + # ---- AnimateDiff ---- + ("ADE_AnimateDiffLoaderWithContext", "motion_scale", "motion_scale"), + ("ADE_AnimateDiffLoaderGen1", "motion_scale", "motion_scale"), + + # ---- Video / Save ---- + ("VHS_VideoCombine", "frame_rate", "frame_rate"), + ("VHS_VideoCombine", "format", "video_format"), + ("VHS_VideoCombine", "filename_prefix", "filename_prefix"), + ("SaveImage", "filename_prefix", "filename_prefix"), + + # ---- Hunyuan / Wan / LTX video ---- + ("HunyuanVideoSampler", "seed", "seed"), + ("HunyuanVideoSampler", "steps", "steps"), + ("HunyuanVideoSampler", "cfg", "cfg"), + ("WanVideoSampler", "seed", "seed"), + ("WanVideoSampler", "steps", "steps"), + ("WanVideoSampler", "cfg", "cfg"), + ("LTXVScheduler", "max_shift", "max_shift"), + ("LTXVScheduler", "base_shift", "base_shift"), + + # ---- rgthree primitives (often used as user-facing inputs) ---- + ("Seed (rgthree)", "seed", "seed"), + ("Image Comparer (rgthree)", "image_a", "image"), + ("Power Lora Loader (rgthree)", "PowerLoraLoaderHeaderWidget", "_lora_header"), + + # ---- Easy-use / utility primitives ---- + ("PrimitiveNode", "value", "primitive_value"), + ("easy seed", "seed", "seed"), + ("easy positive", "positive", "prompt"), + ("easy negative", "negative", "negative_prompt"), + ("easy fullLoader", "ckpt_name", "ckpt_name"), + ("easy fullLoader", "vae_name", "vae_name"), + ("easy fullLoader", "lora_name", "lora_name"), + ("easy fullLoader", "positive", "prompt"), + ("easy fullLoader", "negative", "negative_prompt"), +] + +# Prompt-like fields whose value should be scanned for embedding references +PROMPT_FIELDS = {"text", "text_g", "text_l", "t5xxl", "clip_l", "positive", "negative"} + +# Pattern matches: embedding:name, embedding:name.pt, embedding:name:1.2, (embedding:name:1.2) +# Word-boundary at start avoids matching things like "no_embedding:foo". +EMBEDDING_REGEX = re.compile( + r"(?:^|[\s,(\[])embedding\s*:\s*([A-Za-z0-9_\-\./\\]+?)(?:\.(?:pt|safetensors|bin))?(?=[\s:,)\(\]]|$)", + re.IGNORECASE, +) + + +# ============================================================================= +# Cloud detection & endpoint routing +# ============================================================================= + +CLOUD_DOMAIN_SUFFIXES = (".comfy.org",) +CLOUD_DOMAIN_EXACT = {"cloud.comfy.org"} + + +def is_cloud_host(host: str) -> bool: + """True if the host points at Comfy Cloud (or staging/preview subdomain).""" + parsed = urlparse(host if "://" in host else f"http://{host}") + hostname = (parsed.hostname or "").lower() + if hostname in CLOUD_DOMAIN_EXACT: + return True + return any(hostname.endswith(s) for s in CLOUD_DOMAIN_SUFFIXES) + + +def build_cloud_aware_url(base: str, path: str, *, force_cloud: bool | None = None) -> str: + """Build a URL that adds /api prefix when targeting Comfy Cloud. + + Local ComfyUI accepts both `/foo` and `/api/foo` for many endpoints. + Cloud requires `/api/foo`. + + `path` should be a path component (e.g. "/prompt") or full path with query + (e.g. "/view?filename=x"). + """ + base = base.rstrip("/") + cloud = is_cloud_host(base) if force_cloud is None else force_cloud + if not path.startswith("/"): + path = "/" + path + if cloud and not path.startswith("/api/"): + path = "/api" + path + return base + path + + +def cloud_endpoint(path: str) -> str: + """Map a cloud endpoint path to its current canonical form. + + Handles known renames documented in the Comfy Cloud API: + /history -> /history_v2 + /models/ -> /experiment/models/ + /models -> /experiment/models + """ + if path.startswith("/history") and not path.startswith("/history_v2"): + return "/history_v2" + path[len("/history"):] + if path.startswith("/models/"): + return "/experiment/models/" + path[len("/models/"):] + if path == "/models": + return "/experiment/models" + return path + + +def resolve_url(base: str, path: str, *, is_cloud: bool | None = None) -> str: + """Top-level URL resolver. Applies cloud rename + /api prefix as needed.""" + cloud = is_cloud_host(base) if is_cloud is None else is_cloud + if cloud: + path = cloud_endpoint(path) + return build_cloud_aware_url(base, path, force_cloud=cloud) + + +# ============================================================================= +# API key resolution +# ============================================================================= + +def resolve_api_key(explicit: str | None) -> str | None: + """Look up API key from CLI flag → env var. Strips whitespace and quotes.""" + val = explicit if explicit else os.environ.get(ENV_API_KEY) + if val is None: + return None + val = val.strip().strip("'\"") + return val or None + + +# ============================================================================= +# HTTP transport +# ============================================================================= + +@dataclass +class HTTPResponse: + status: int + headers: dict[str, str] + body: bytes + url: str # final URL after redirects + + def text(self, encoding: str = "utf-8") -> str: + return self.body.decode(encoding, errors="replace") + + def json(self) -> Any: + return json.loads(self.body.decode("utf-8", errors="replace")) + + +def _sleep_backoff(attempt: int, base: float = RETRY_BASE_DELAY, cap: float = RETRY_MAX_DELAY) -> None: + """Sleep with full-jitter exponential backoff.""" + delay = min(cap, base * (2 ** attempt)) + delay = random.uniform(0, delay) + time.sleep(delay) + + +def http_request( + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + json_body: Any = None, + data: bytes | None = None, + files: dict | None = None, + form: dict | None = None, + timeout: float = DEFAULT_HTTP_TIMEOUT, + follow_redirects: bool = True, + retries: int = DEFAULT_RETRIES, + stream: bool = False, + sink: Path | None = None, +) -> HTTPResponse: + """Single entry point for all HTTP traffic. + + Behavior: + - Retries on connection errors and on HTTP statuses in RETRY_STATUS_CODES, + with exponential backoff + jitter. + - For cross-host redirects, drops Authorization-style headers (so signed + URLs don't leak the API key to S3/CloudFront). + - When `stream=True` and `sink` is a Path, streams the response body to + disk in 64 KiB chunks instead of buffering. + + Either `json_body`, `data`, or `files`+`form` may be supplied (mutually exclusive). + """ + if headers is None: + headers = {} + headers = dict(headers) # copy + headers.setdefault("User-Agent", "hermes-comfyui-skill/5.0") + + if files or form is not None: + # Multipart upload — needs `requests`. The stdlib fallback lacks + # multipart encoding helpers; raise a clear error. + if not HAS_REQUESTS: + raise RuntimeError( + "Multipart upload requires the `requests` package. " + "Install with: pip install requests" + ) + + last_exc: Exception | None = None + for attempt in range(retries): + try: + resp = _http_once( + method=method, url=url, headers=headers, + json_body=json_body, data=data, files=files, form=form, + timeout=timeout, follow_redirects=follow_redirects, + stream=stream, sink=sink, + ) + if resp.status in RETRY_STATUS_CODES and attempt + 1 < retries: + _sleep_backoff(attempt) + continue + return resp + except (TimeoutError, ConnectionError, OSError) as e: + last_exc = e + if attempt + 1 < retries: + _sleep_backoff(attempt) + continue + raise + + # Should not reach here unless retries was 0 + if last_exc: + raise last_exc + raise RuntimeError("http_request: retries exhausted with no response") + + +_SENSITIVE_HEADERS = ("x-api-key", "authorization", "cookie") + + +if HAS_REQUESTS: + class _StripSensitiveOnRedirectSession(requests.Session): + """Session that drops sensitive headers on cross-host redirects. + + `requests` already strips `Authorization` cross-host (rebuild_auth), + but it does NOT strip custom headers like `X-API-Key`. We override + `rebuild_auth` to additionally strip every header in + `_SENSITIVE_HEADERS` when the destination is a different host — + critical when ComfyUI Cloud's `/api/view` redirects to a signed S3 URL. + """ + + def rebuild_auth(self, prepared_request, response): # type: ignore[override] + super().rebuild_auth(prepared_request, response) + try: + old_url = response.request.url + new_url = prepared_request.url + old_host = (urlparse(old_url).hostname or "").lower() + new_host = (urlparse(new_url).hostname or "").lower() + if old_host and new_host and old_host != new_host: + headers = prepared_request.headers + for key in list(headers.keys()): + if key.lower() in _SENSITIVE_HEADERS: + del headers[key] + except Exception: + # Defensive: never let header stripping break a redirect. + pass + + +def _http_once( + *, method: str, url: str, headers: dict[str, str], + json_body: Any, data: bytes | None, files: dict | None, form: dict | None, + timeout: float, follow_redirects: bool, + stream: bool, sink: Path | None, +) -> HTTPResponse: + """One HTTP attempt. No retry.""" + if HAS_REQUESTS: + kwargs: dict[str, Any] = { + "method": method, "url": url, "headers": headers, + "timeout": timeout, "allow_redirects": follow_redirects, + } + if json_body is not None: + kwargs["json"] = json_body + elif data is not None: + kwargs["data"] = data + elif files is not None or form is not None: + kwargs["files"] = files + kwargs["data"] = form + if stream: + kwargs["stream"] = True + + # Use the subclass that strips sensitive headers cross-host + with _StripSensitiveOnRedirectSession() as s: + try: + r = s.request(**kwargs) + if stream and sink is not None: + sink.parent.mkdir(parents=True, exist_ok=True) + with sink.open("wb") as f: + for chunk in r.iter_content(DOWNLOAD_CHUNK_SIZE): + if chunk: + f.write(chunk) + body = b"" # already drained + else: + body = r.content + return HTTPResponse( + status=r.status_code, + headers={k: v for k, v in r.headers.items()}, + body=body, + url=r.url, + ) + except requests.exceptions.RequestException as e: + # Convert to TimeoutError / ConnectionError so the retry loop + # picks them up uniformly with the stdlib path. + if isinstance(e, requests.exceptions.Timeout): + raise TimeoutError(str(e)) from e + raise ConnectionError(str(e)) from e + + # ---------- stdlib fallback ---------- + if json_body is not None: + body_bytes = json.dumps(json_body).encode("utf-8") + headers.setdefault("Content-Type", "application/json") + else: + body_bytes = data + req = urllib.request.Request(url, data=body_bytes, headers=headers, method=method) + + # urllib follows redirects by default. We need to: + # 1) intercept cross-host redirects and drop X-API-Key + # 2) optionally NOT follow redirects when follow_redirects=False + class _RedirectHandler(urllib.request.HTTPRedirectHandler): + def __init__(self, original_host: str, follow: bool): + self.original_host = original_host + self.follow = follow + + def redirect_request(self, req2, fp, code, msg, hdrs, newurl): + if not self.follow: + return None + new_host = (urlparse(newurl).hostname or "").lower() + if new_host != self.original_host: + # Build a new request with cleaned headers + clean_headers = { + k: v for k, v in req2.header_items() + if k.lower() not in {"x-api-key", "authorization", "cookie"} + } + new_req = urllib.request.Request(newurl, headers=clean_headers, method="GET") + return new_req + return super().redirect_request(req2, fp, code, msg, hdrs, newurl) + + original_host = (urlparse(url).hostname or "").lower() + opener = urllib.request.build_opener(_RedirectHandler(original_host, follow_redirects)) + + try: + resp = opener.open(req, timeout=timeout) + except urllib.error.HTTPError as e: + return HTTPResponse( + status=e.code, + headers=dict(e.headers) if e.headers else {}, + body=e.read() or b"", + url=getattr(e, "url", url), + ) + + final_url = resp.geturl() + final_status = resp.status + final_headers = dict(resp.headers) + + if stream and sink is not None: + sink.parent.mkdir(parents=True, exist_ok=True) + with sink.open("wb") as f: + while True: + chunk = resp.read(DOWNLOAD_CHUNK_SIZE) + if not chunk: + break + f.write(chunk) + return HTTPResponse(status=final_status, headers=final_headers, body=b"", url=final_url) + + return HTTPResponse(status=final_status, headers=final_headers, body=resp.read(), url=final_url) + + +def http_get(url: str, **kwargs: Any) -> HTTPResponse: + return http_request("GET", url, **kwargs) + + +def http_post(url: str, **kwargs: Any) -> HTTPResponse: + return http_request("POST", url, **kwargs) + + +# ============================================================================= +# Workflow validation & helpers +# ============================================================================= + +def is_api_format(workflow: Any) -> bool: + """API format = top-level dict where each value has `class_type`.""" + if not isinstance(workflow, dict): + return False + if "nodes" in workflow and "links" in workflow: + return False + for v in workflow.values(): + if isinstance(v, dict) and "class_type" in v: + return True + return False + + +def unwrap_workflow(payload: Any) -> dict: + """Unwrap common wrapper variants. Returns API-format workflow or raises ValueError.""" + if isinstance(payload, dict) and is_api_format(payload): + return payload + # Some files wrap workflow under "prompt" key (e.g. saved /prompt payloads) + if isinstance(payload, dict) and "prompt" in payload and is_api_format(payload["prompt"]): + return payload["prompt"] + # Editor format + if isinstance(payload, dict) and "nodes" in payload and "links" in payload: + raise ValueError( + "Workflow is in editor format (has top-level 'nodes' and 'links' arrays). " + "Re-export from ComfyUI using 'Workflow → Export (API)' (newer UI) " + "or 'Save (API Format)' (older UI)." + ) + raise ValueError( + "Workflow is not in API format. Each top-level entry must have a 'class_type' field." + ) + + +def is_link(value: Any) -> bool: + """True if `value` is a [node_id, output_index] connection (length-2 list).""" + return ( + isinstance(value, list) + and len(value) == 2 + and isinstance(value[0], str) + and isinstance(value[1], int) + ) + + +def iter_nodes(workflow: dict) -> Iterator[tuple[str, dict]]: + """Yield (node_id, node) for each valid API-format node.""" + for node_id, node in workflow.items(): + if isinstance(node, dict) and "class_type" in node: + yield node_id, node + + +def iter_model_deps(workflow: dict) -> Iterator[dict]: + """Yield {node_id, class_type, field, value, folder} for each model dependency.""" + for node_id, node in iter_nodes(workflow): + cls = node["class_type"] + if cls not in MODEL_LOADERS: + continue + inputs = node.get("inputs", {}) or {} + for field_name, folder in MODEL_LOADERS[cls]: + val = inputs.get(field_name) + if val and isinstance(val, str) and not is_link(val): + yield { + "node_id": node_id, + "class_type": cls, + "field": field_name, + "value": val, + "folder": folder, + } + + +def iter_embedding_refs(workflow: dict) -> Iterator[tuple[str, str]]: + """Yield (node_id, embedding_name) for every embedding mention in prompts.""" + for node_id, node in iter_nodes(workflow): + inputs = node.get("inputs", {}) or {} + for field_name, val in inputs.items(): + if field_name not in PROMPT_FIELDS: + continue + if not isinstance(val, str): + continue + for m in EMBEDDING_REGEX.finditer(val): + yield node_id, m.group(1) + + +# ============================================================================= +# Path safety +# ============================================================================= + +def safe_path_join(base: Path, *parts: str) -> Path: + """Join paths, raising if the result escapes `base`. + + Server-supplied filenames may contain `../` etc. This guards against + path-traversal attacks when downloading outputs. + """ + base_resolved = base.resolve() + candidate = base.joinpath(*parts).resolve() + try: + candidate.relative_to(base_resolved) + except ValueError as e: + raise ValueError( + f"Refusing path traversal: {candidate} is outside {base_resolved}" + ) from e + return candidate + + +def media_type_from_filename(filename: str) -> str: + ext = Path(filename).suffix.lower() + if ext in {".mp4", ".webm", ".avi", ".mov", ".mkv", ".gif", ".webp"}: + return "video" + if ext in {".wav", ".mp3", ".flac", ".ogg", ".m4a"}: + return "audio" + if ext in {".glb", ".obj", ".ply", ".gltf"}: + return "3d" + if ext in {".json", ".txt", ".md"}: + return "text" + return "image" + + +def looks_like_video_workflow(workflow: dict) -> bool: + """Used to bump default timeout for video workflows.""" + for _, node in iter_nodes(workflow): + if node["class_type"] in SLOW_OUTPUT_NODES: + return True + if node["class_type"].lower().startswith(("animatediff", "ade_", "wanvideo", "hunyuanvideo", "ltxvideo", "cogvideo")): + return True + return False + + +# ============================================================================= +# Seed handling +# ============================================================================= + +# ComfyUI's max seed range. Many UIs treat `-1` as "randomize on submit". +SEED_MAX = 2**63 - 1 +SEED_MIN = 0 + + +def coerce_seed(value: Any) -> int: + """Convert -1 or None to a fresh random seed; otherwise return int(value). + + Accepts numeric -1 OR string "-1" (both treated as "randomize"). Other + parse failures raise TypeError/ValueError for the caller to surface. + """ + if value is None: + return random.randint(SEED_MIN, SEED_MAX) + # Stringly-typed -1 from CLI / JSON should also randomize + if isinstance(value, str) and value.strip() == "-1": + return random.randint(SEED_MIN, SEED_MAX) + if value == -1: + return random.randint(SEED_MIN, SEED_MAX) + return int(value) + + +# ============================================================================= +# Cloud model-list normalization +# ============================================================================= + +def parse_model_list(payload: Any) -> set[str]: + """Normalize model-list responses from local ComfyUI vs Comfy Cloud. + + Local: `["a.safetensors", "b.safetensors"]` + Cloud: `[{"name": "a.safetensors", "pathIndex": 0}, ...]` + """ + if not isinstance(payload, list): + return set() + out: set[str] = set() + for item in payload: + if isinstance(item, str): + out.add(item) + elif isinstance(item, dict): + name = item.get("name") or item.get("filename") or item.get("path") + if isinstance(name, str): + out.add(name) + return out + + +# ============================================================================= +# Misc utilities +# ============================================================================= + +def new_client_id() -> str: + return str(uuid.uuid4()) + + +def fmt_kv(d: dict) -> str: + """Pretty key=value for log lines.""" + return " ".join(f"{k}={v!r}" for k, v in d.items()) + + +def emit_json(obj: Any, *, indent: int = 2) -> None: + """Print JSON to stdout. Centralised so behavior can be tweaked (e.g., --raw).""" + print(json.dumps(obj, indent=indent, default=str)) + + +def log(msg: str) -> None: + """stderr log with consistent prefix (so JSON stdout stays clean).""" + print(f"[comfyui-skill] {msg}", file=sys.stderr) diff --git a/image-creation/comfyui/scripts/auto_fix_deps.py b/image-creation/comfyui/scripts/auto_fix_deps.py new file mode 100644 index 0000000..788bf8e --- /dev/null +++ b/image-creation/comfyui/scripts/auto_fix_deps.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +auto_fix_deps.py — Run check_deps.py, then attempt to install whatever is missing. + +For local servers: + - Missing custom nodes → `comfy node install ` + - Missing models → `comfy model download` (only if a URL is supplied via + --model-source-file or detected via well-known names) + +For cloud: prints what would be needed but cannot install (cloud preinstalls +custom nodes and most models server-side; if something genuinely isn't there, +ask Comfy support). + +This is conservative: it never installs without an explicit URL for models +(downloading the wrong model is hard to undo). Custom nodes from the registry +are auto-installed by name. + +Usage: + python3 auto_fix_deps.py workflow_api.json + python3 auto_fix_deps.py workflow_api.json --models-from-file urls.json + python3 auto_fix_deps.py workflow_api.json --dry-run +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _common import ( # noqa: E402 + DEFAULT_LOCAL_HOST, ENV_API_KEY, emit_json, log, resolve_api_key, +) +from check_deps import check_deps # noqa: E402 +from _common import unwrap_workflow # noqa: E402 + + +def comfy_cli_available() -> str | None: + """Return command prefix for comfy-cli, or None.""" + if shutil.which("comfy"): + return "comfy" + if shutil.which("uvx"): + return "uvx --from comfy-cli comfy" + return None + + +def run_cmd(cmd: list[str], *, dry_run: bool = False) -> tuple[int, str]: + if dry_run: + return 0, "[dry-run]" + log(f"$ {' '.join(cmd)}") + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + out = (proc.stdout or "") + (proc.stderr or "") + return proc.returncode, out + + +def install_node(package: str, *, dry_run: bool = False, comfy_cmd: str = "comfy") -> bool: + cmd = comfy_cmd.split() + ["--skip-prompt", "node", "install", package] + code, _ = run_cmd(cmd, dry_run=dry_run) + return code == 0 + + +def install_model(url: str, folder: str, filename: str | None = None, + *, dry_run: bool = False, comfy_cmd: str = "comfy", + hf_token: str | None = None, civitai_token: str | None = None) -> bool: + cmd = comfy_cmd.split() + [ + "--skip-prompt", "model", "download", + "--url", url, + "--relative-path", f"models/{folder}", + ] + if filename: + cmd.extend(["--filename", filename]) + if hf_token: + cmd.extend(["--set-hf-api-token", hf_token]) + if civitai_token: + cmd.extend(["--set-civitai-api-token", civitai_token]) + code, _ = run_cmd(cmd, dry_run=dry_run) + return code == 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Run check_deps and install whatever is missing") + p.add_argument("workflow") + p.add_argument("--host", default=DEFAULT_LOCAL_HOST) + p.add_argument("--api-key", help=f"or set ${ENV_API_KEY}") + p.add_argument("--models-from-file", + help="JSON file mapping {model_filename: download_url} for models that need install") + p.add_argument("--hf-token", help="HuggingFace token for downloads") + p.add_argument("--civitai-token", help="CivitAI token for downloads") + p.add_argument("--dry-run", action="store_true", + help="Show what would be installed without doing it") + p.add_argument("--no-restart", action="store_true", + help="Don't suggest restarting the server after node install") + args = p.parse_args(argv) + + api_key = resolve_api_key(args.api_key) + + wf_path = Path(args.workflow).expanduser() + if not wf_path.exists(): + emit_json({"error": f"Workflow not found: {args.workflow}"}) + return 1 + try: + with wf_path.open() as f: + workflow = unwrap_workflow(json.load(f)) + except (ValueError, json.JSONDecodeError) as e: + emit_json({"error": str(e)}) + return 1 + + report = check_deps(workflow, host=args.host, api_key=api_key) + + if report["is_ready"]: + emit_json({"status": "ready", "report": report}) + return 0 + + if report["is_cloud"]: + emit_json({ + "status": "cannot_fix_cloud", + "reason": "Comfy Cloud preinstalls nodes; if something is genuinely missing, contact support.", + "report": report, + }) + return 1 + + comfy_cmd = comfy_cli_available() + if not comfy_cmd: + emit_json({ + "status": "cannot_fix", + "reason": "comfy-cli not on PATH; install with `pip install comfy-cli` or `pipx install comfy-cli`", + "report": report, + }) + return 1 + + actions: list[dict] = [] + failures: list[dict] = [] + + # ---- Install missing custom nodes ---- + seen_packages: set[str] = set() + for entry in report["missing_nodes"]: + cmd = entry.get("fix_command", "") + if cmd.startswith("comfy node install "): + package = cmd.split(" ")[-1] + if package in seen_packages: + continue + seen_packages.add(package) + ok = install_node(package, dry_run=args.dry_run, comfy_cmd=comfy_cmd) + (actions if ok else failures).append({ + "kind": "node", "package": package, "node_class": entry["class_type"], + "ok": ok, + }) + else: + failures.append({ + "kind": "node", "node_class": entry["class_type"], + "ok": False, "reason": "No registry mapping known. " + entry.get("fix_hint", ""), + }) + + # ---- Install missing models (only when URL provided) ---- + sources: dict[str, str] = {} + if args.models_from_file: + try: + sources = json.loads(Path(args.models_from_file).read_text()) + except (OSError, json.JSONDecodeError) as e: + log(f"Could not read --models-from-file: {e}") + + for entry in report["missing_models"]: + filename = entry["value"] + url = sources.get(filename) + if not url: + failures.append({ + "kind": "model", "filename": filename, "folder": entry["folder"], + "ok": False, "reason": "No URL provided in --models-from-file. " + "Refusing to guess.", + }) + continue + ok = install_model( + url, entry["folder"], filename, + dry_run=args.dry_run, comfy_cmd=comfy_cmd, + hf_token=args.hf_token, civitai_token=args.civitai_token, + ) + (actions if ok else failures).append({ + "kind": "model", "filename": filename, "folder": entry["folder"], + "url": url, "ok": ok, + }) + + # ---- Embeddings ---- + for entry in report["missing_embeddings"]: + emb_name = entry["embedding_name"] + # Try common extensions in user-supplied source map + url = (sources.get(f"{emb_name}.pt") + or sources.get(f"{emb_name}.safetensors") + or sources.get(emb_name)) + if not url: + failures.append({ + "kind": "embedding", "name": emb_name, + "ok": False, "reason": "No URL provided in --models-from-file.", + }) + continue + target_filename = ( + f"{emb_name}.safetensors" if url.endswith(".safetensors") + else f"{emb_name}.pt" + ) + ok = install_model( + url, "embeddings", target_filename, + dry_run=args.dry_run, comfy_cmd=comfy_cmd, + hf_token=args.hf_token, civitai_token=args.civitai_token, + ) + (actions if ok else failures).append({ + "kind": "embedding", "name": emb_name, "url": url, "ok": ok, + }) + + needs_restart = any(a["kind"] == "node" and a.get("ok") for a in actions) + + emit_json({ + "status": "fixed" if not failures else "partial", + "actions_taken": actions, + "failures": failures, + "needs_server_restart": needs_restart and not args.no_restart, + "restart_hint": "comfy stop && comfy launch --background", + "dry_run": args.dry_run, + }) + return 0 if not failures else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/image-creation/comfyui/scripts/check_deps.py b/image-creation/comfyui/scripts/check_deps.py new file mode 100644 index 0000000..607e2c0 --- /dev/null +++ b/image-creation/comfyui/scripts/check_deps.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 +""" +check_deps.py — Verify a ComfyUI workflow's dependencies (custom nodes, models, +embeddings) against a running server. + +Improvements over v1: + - Cloud-aware endpoint mapping (handles `/api/experiment/models/{folder}` and + `/api/object_info` variants verified against live cloud API) + - Distinguishes 200-empty (genuinely no models in folder) vs 404 + (folder doesn't exist) vs 403 (auth/tier issue) — no silent passes + - Outputs concrete remediation commands (e.g. `comfy node install `) + when nodes are missing + - Detects embedding references inside prompt strings as model deps + - Skips check on cloud free tier `/api/object_info` (403) without false alarm + - Accepts API key from CLI flag OR $COMFY_CLOUD_API_KEY env var + +Usage: + python3 check_deps.py workflow_api.json + python3 check_deps.py workflow_api.json --host 127.0.0.1 --port 8188 + python3 check_deps.py workflow_api.json --host https://cloud.comfy.org + +Stdlib-only. Python 3.10+. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _common import ( # noqa: E402 + DEFAULT_LOCAL_HOST, ENV_API_KEY, + emit_json, folder_aliases_for, http_get, is_cloud_host, + iter_embedding_refs, iter_model_deps, iter_nodes, parse_model_list, + resolve_api_key, resolve_url, unwrap_workflow, +) + + +# Known node → custom-node-package map. When a workflow needs a node we don't +# recognize, suggesting the right `comfy node install ...` makes the difference +# between a working agent and a stuck one. +NODE_TO_PACKAGE: dict[str, str] = { + # rgthree (Reroute is JS-only and doesn't appear in /object_info) + "Power Lora Loader (rgthree)": "rgthree-comfy", + "Image Comparer (rgthree)": "rgthree-comfy", + "Seed (rgthree)": "rgthree-comfy", + "Display Any (rgthree)": "rgthree-comfy", + "Display Int (rgthree)": "rgthree-comfy", + # Impact pack + "FaceDetailer": "comfyui-impact-pack", + "DetailerForEach": "comfyui-impact-pack", + "BboxDetectorSEGS": "comfyui-impact-pack", + "SAMLoader": "comfyui-impact-pack", + "ImpactWildcardProcessor": "comfyui-impact-pack", + # Impact subpack (separate package) + "UltralyticsDetectorProvider": "comfyui-impact-subpack", + # Was Node Suite + "Image Save": "was-node-suite-comfyui", + "Number Counter": "was-node-suite-comfyui", + "Text String": "was-node-suite-comfyui", + # easy-use + "easy fullLoader": "comfyui-easy-use", + "easy positive": "comfyui-easy-use", + "easy negative": "comfyui-easy-use", + "easy seed": "comfyui-easy-use", + "easy imageSave": "comfyui-easy-use", + # Video Helper Suite + "VHS_VideoCombine": "comfyui-videohelpersuite", + "VHS_LoadVideo": "comfyui-videohelpersuite", + "VHS_LoadAudio": "comfyui-videohelpersuite", + # AnimateDiff + "ADE_AnimateDiffLoaderWithContext": "comfyui-animatediff-evolved", + "ADE_AnimateDiffLoaderGen1": "comfyui-animatediff-evolved", + "ADE_LoadAnimateDiffModel": "comfyui-animatediff-evolved", + # ControlNet aux preprocessors (full class names) + "CannyEdgePreprocessor": "comfyui_controlnet_aux", + "DWPreprocessor": "comfyui_controlnet_aux", + "OpenposePreprocessor": "comfyui_controlnet_aux", + "DepthAnythingPreprocessor": "comfyui_controlnet_aux", + "Zoe_DepthAnythingPreprocessor": "comfyui_controlnet_aux", + "AnimalPosePreprocessor": "comfyui_controlnet_aux", + # IPAdapter Plus + "IPAdapterAdvanced": "comfyui_ipadapter_plus", + "IPAdapterUnifiedLoader": "comfyui_ipadapter_plus", + "IPAdapterModelLoader": "comfyui_ipadapter_plus", + "IPAdapterInsightFaceLoader": "comfyui_ipadapter_plus", + # InstantID + "InstantIDModelLoader": "comfyui_instantid", + "ApplyInstantID": "comfyui_instantid", + # Comfy essentials (note: registry slug uses underscore, not hyphen) + "GetImageSize+": "comfyui_essentials", + "ImageBatchMultiple+": "comfyui_essentials", + # pysssss + "ShowText|pysssss": "comfyui-custom-scripts", + "PreviewImage|pysssss": "comfyui-custom-scripts", + # SUPIR + "SUPIR_Upscale": "comfyui-supir", + "SUPIR_first_stage": "comfyui-supir", + # GGUF (case-sensitive registry slug) + "UNETLoaderGGUF": "ComfyUI-GGUF", + "DualCLIPLoaderGGUF": "ComfyUI-GGUF", + # Florence2 + "Florence2Run": "comfyui-florence2", + # WAS + "Image Filter Adjustments": "was-node-suite-comfyui", + # Photomaker (case-sensitive) + "PhotoMakerLoader": "ComfyUI-PhotoMaker-Plus", + # Wan video (case-sensitive) + "WanVideoSampler": "ComfyUI-WanVideoWrapper", + "WanVideoModelLoader": "ComfyUI-WanVideoWrapper", +} + +# Nodes whose package isn't on the comfy registry — need git-URL install via +# ComfyUI-Manager. We surface a helpful hint instead of an unrunnable command. +NODE_TO_GIT_URL: dict[str, str] = { + "HunyuanVideoSampler": "https://github.com/kijai/ComfyUI-HunyuanVideoWrapper", + "HunyuanVideoModelLoader": "https://github.com/kijai/ComfyUI-HunyuanVideoWrapper", +} + + +def fetch_object_info(url: str, headers: dict) -> tuple[set[str] | None, dict | None]: + """Returns (installed_node_set, error_info). Error info is a dict if we + couldn't query (e.g. cloud free tier), else None. + """ + r = http_get(url, headers=headers, retries=2, timeout=30) + if r.status == 200: + try: + data = r.json() + if isinstance(data, dict): + return set(data.keys()), None + except Exception: + pass + return None, {"http_status": 200, "reason": "non-dict response"} + if r.status == 403: + try: + body = r.json() + except Exception: + body = {"raw": r.text()[:200]} + return None, {"http_status": 403, "reason": "forbidden", "body": body} + if r.status == 404: + return None, {"http_status": 404, "reason": "endpoint not found"} + return None, {"http_status": r.status, "reason": "unexpected", "body": r.text()[:200]} + + +def _fetch_one_folder( + base: str, folder: str, headers: dict, *, is_cloud: bool, +) -> tuple[set[str] | None, dict | None]: + """Single-folder fetch, no aliasing. Returns (installed_set, error_info).""" + url = resolve_url(base, f"/models/{folder}", is_cloud=is_cloud) + r = http_get(url, headers=headers, retries=2, timeout=30) + if r.status == 200: + try: + return parse_model_list(r.json()), None + except Exception: + return set(), {"http_status": 200, "reason": "non-list response"} + if r.status == 404: + body_text = r.text() + try: + body = r.json() + except Exception: + body = {"raw": body_text[:200]} + code = body.get("code") if isinstance(body, dict) else None + if code == "folder_not_found": + # Folder is genuinely empty/missing on server — not the same as + # "endpoint missing". Return empty set with informational error. + return set(), {"http_status": 404, "reason": "folder_empty_or_unknown", "body": body} + return None, {"http_status": 404, "reason": "endpoint not found", "body": body} + if r.status == 403: + try: + body = r.json() + except Exception: + body = {} + return None, {"http_status": 403, "reason": "forbidden", "body": body} + return None, {"http_status": r.status, "reason": "unexpected"} + + +def fetch_models_for_folder( + base: str, folder: str, headers: dict, *, is_cloud: bool, +) -> tuple[set[str] | None, dict | None]: + """Fetch installed models for a folder, trying aliases. + + Folder renames over time (e.g. unet → diffusion_models, clip → text_encoders) + mean a workflow asking for a model in `unet` may need to look in + `diffusion_models`. We union models from every reachable alias. + + Returns (combined_set | None, last_error | None). + """ + aliases = folder_aliases_for(folder) + combined: set[str] = set() + any_success = False + last_err: dict | None = None + for alias in aliases: + models, err = _fetch_one_folder(base, alias, headers, is_cloud=is_cloud) + if models is not None: + combined.update(models) + any_success = True + last_err = None + else: + last_err = err + if not any_success: + return None, last_err + return combined, None + + +def fetch_embeddings(base: str, headers: dict, *, is_cloud: bool) -> tuple[set[str] | None, dict | None]: + """Local ComfyUI exposes /embeddings; cloud uses /experiment/models/embeddings.""" + if is_cloud: + return fetch_models_for_folder(base, "embeddings", headers, is_cloud=True) + # Local: dedicated /embeddings returns a flat list of names + r = http_get(resolve_url(base, "/embeddings", is_cloud=False), headers=headers, retries=2) + if r.status == 200: + try: + data = r.json() + if isinstance(data, list): + # Strip extensions from the registered names since prompt syntax + # usually omits them ("embedding:goodvibes" vs "goodvibes.pt") + names = set() + for n in data: + if isinstance(n, str): + names.add(n) + # Also store stem for fuzzy matching + names.add(Path(n).stem) + return names, None + except Exception: + pass + return None, {"http_status": r.status, "reason": "unexpected"} + + +def normalize_for_match(name: str) -> set[str]: + """Generate matching variants of a model name (with/without extension, slashes, etc.)""" + s = {name} + s.add(Path(name).stem) + s.add(Path(name).name) + # ComfyUI sometimes strips/keeps the leading folder + if "/" in name or "\\" in name: + flat = name.replace("\\", "/").split("/")[-1] + s.add(flat) + s.add(Path(flat).stem) + return {x for x in s if x} + + +def model_present(needed: str, installed: set[str]) -> bool: + if not installed: + return False + needed_variants = normalize_for_match(needed) + installed_norm: set[str] = set() + for inst in installed: + installed_norm.update(normalize_for_match(inst)) + return bool(needed_variants & installed_norm) + + +def suggest_install_command(node_class: str) -> str | None: + pkg = NODE_TO_PACKAGE.get(node_class) + if pkg: + return f"comfy node install {pkg}" + return None + + +def suggest_git_url(node_class: str) -> str | None: + """For nodes not on the registry, return a git URL the user can hand to + ComfyUI-Manager's `/manager/queue/install` endpoint.""" + return NODE_TO_GIT_URL.get(node_class) + + +def check_deps( + workflow: dict, host: str, *, api_key: str | None = None, +) -> dict: + headers: dict[str, str] = {} + if api_key: + headers["X-API-Key"] = api_key + + is_cloud = is_cloud_host(host) + base = host.rstrip("/") + + # ---- 1. Required nodes ---- + required_nodes: set[str] = set() + for _, node in iter_nodes(workflow): + required_nodes.add(node["class_type"]) + + object_info_url = resolve_url(base, "/object_info", is_cloud=is_cloud) + installed_nodes, obj_err = fetch_object_info(object_info_url, headers) + + missing_nodes: list[dict] = [] + node_check_skipped = False + if installed_nodes is None: + # Couldn't query (e.g. cloud free tier). Don't false-alarm; mark skipped. + node_check_skipped = True + else: + for cls in sorted(required_nodes): + if cls not in installed_nodes: + entry = {"class_type": cls} + cmd = suggest_install_command(cls) + git_url = suggest_git_url(cls) + if cmd: + entry["fix_command"] = cmd + elif git_url: + entry["fix_git_url"] = git_url + entry["fix_hint"] = ( + f"Not on registry. Install via Manager with this git URL: {git_url}" + ) + else: + entry["fix_hint"] = ( + "Search https://registry.comfy.org or " + "use ComfyUI-Manager UI to find the package providing this node." + ) + missing_nodes.append(entry) + + # ---- 2. Required models ---- + model_cache: dict[str, tuple[set[str] | None, dict | None]] = {} + missing_models: list[dict] = [] + folder_errors: dict[str, dict] = {} + + for dep in iter_model_deps(workflow): + folder = dep["folder"] + if folder not in model_cache: + model_cache[folder] = fetch_models_for_folder( + base, folder, headers, is_cloud=is_cloud, + ) + installed, err = model_cache[folder] + if installed is None: + # Couldn't enumerate this folder — record once + folder_errors.setdefault(folder, err or {}) + # Don't flag as missing (we don't know); the folder_errors block surfaces this + continue + if not model_present(dep["value"], installed): + entry = dict(dep) + entry["fix_hint"] = ( + f"comfy model download --url --relative-path models/{folder} " + f"--filename {dep['value']!r}" + ) + missing_models.append(entry) + + # ---- 3. Embedding refs in prompts ---- + emb_installed, emb_err = fetch_embeddings(base, headers, is_cloud=is_cloud) + missing_embeddings: list[dict] = [] + seen_emb: set[tuple[str, str]] = set() + for nid, emb_name in iter_embedding_refs(workflow): + if (nid, emb_name) in seen_emb: + continue + seen_emb.add((nid, emb_name)) + if emb_installed is None: + # Couldn't enumerate — skip silently here, surface the error in the + # folder_errors block + continue + if not model_present(emb_name, emb_installed): + missing_embeddings.append({ + "node_id": nid, + "embedding_name": emb_name, + "folder": "embeddings", + "fix_hint": ( + f"Download {emb_name}.pt or .safetensors and place in " + f"models/embeddings/, or `comfy model download --url " + f"--relative-path models/embeddings`" + ), + }) + + if emb_err and emb_installed is None: + folder_errors.setdefault("embeddings", emb_err) + + is_ready = ( + not node_check_skipped + and not missing_nodes + and not missing_models + and not missing_embeddings + ) + + return { + "is_ready": is_ready, + "node_check_skipped": node_check_skipped, + "node_check_skip_reason": obj_err if node_check_skipped else None, + "missing_nodes": missing_nodes, + "missing_models": missing_models, + "missing_embeddings": missing_embeddings, + "folder_errors": folder_errors, + # 0 is a legitimate count (e.g. empty server). Use None only when not queried. + "installed_node_count": len(installed_nodes) if installed_nodes is not None else None, + "required_node_count": len(required_nodes), + "required_nodes": sorted(required_nodes), + "host": base, + "is_cloud": is_cloud, + } + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Check ComfyUI workflow dependencies against a running server") + p.add_argument("workflow", help="Path to workflow API JSON file") + p.add_argument("--host", default=DEFAULT_LOCAL_HOST, help="ComfyUI server URL") + p.add_argument("--port", type=int, help="Server port (overrides --host port)") + p.add_argument("--api-key", help=f"API key for cloud (or set ${ENV_API_KEY} env var)") + p.add_argument("--strict", action="store_true", + help="Exit non-zero if node check is skipped (e.g. on cloud free tier)") + args = p.parse_args(argv) + + host = args.host + if args.port is not None: + # Strip any port from host and append --port + from urllib.parse import urlparse, urlunparse + parsed = urlparse(host if "://" in host else f"http://{host}") + new_netloc = f"{parsed.hostname}:{args.port}" + host = urlunparse(parsed._replace(netloc=new_netloc)) + + api_key = resolve_api_key(args.api_key) + + wf_path = Path(args.workflow).expanduser() + if not wf_path.exists(): + emit_json({"error": f"Workflow file not found: {args.workflow}"}) + return 1 + try: + with wf_path.open() as f: + payload = json.load(f) + workflow = unwrap_workflow(payload) + except ValueError as e: + emit_json({"error": str(e)}) + return 1 + except json.JSONDecodeError as e: + emit_json({"error": f"Invalid JSON: {e}"}) + return 1 + + try: + result = check_deps(workflow, host=host, api_key=api_key) + except Exception as e: + emit_json({"error": f"Dep check failed: {e}", "host": host}) + return 1 + + emit_json(result) + + if not result["is_ready"]: + return 1 + if args.strict and result["node_check_skipped"]: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/image-creation/comfyui/scripts/comfyui_setup.sh b/image-creation/comfyui/scripts/comfyui_setup.sh new file mode 100644 index 0000000..dd03698 --- /dev/null +++ b/image-creation/comfyui/scripts/comfyui_setup.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash +# ComfyUI Setup — Install, launch, and verify using the official comfy-cli. +# +# Improvements over v1: +# - Prefers `pipx` / `uvx` over global `pip install` (avoids polluting system Python) +# - Idempotent: detects already-running server and skips re-launch +# - Configurable port via --port=N (default 8188) +# - Configurable workspace via --workspace=PATH +# - Persistent log file in /tmp/comfyui_setup..log for debugging +# - SIGINT trap cleans up partial state +# - Refuses local install when hardware_check.py verdict is "cloud" +# - Forwards extra flags to comfy-cli (e.g. --cuda-version=12.4) +# +# Usage: +# bash scripts/comfyui_setup.sh +# (auto-detects GPU; uses recommendation from hardware_check.py) +# bash scripts/comfyui_setup.sh --nvidia +# bash scripts/comfyui_setup.sh --m-series --port=8190 +# bash scripts/comfyui_setup.sh --amd --workspace=/data/comfy +# +# Flags: +# --nvidia | --amd | --m-series | --cpu GPU selection (skips hw check) +# --port=N HTTP port (default 8188) +# --workspace=PATH ComfyUI install location +# --skip-launch Install only, don't start server +# --force-cloud-override Install locally even if hw says cloud +# -- Pass remaining args to `comfy install` + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HARDWARE_CHECK="$SCRIPT_DIR/hardware_check.py" +LOG_FILE="/tmp/comfyui_setup.$$.log" +PORT=8188 +WORKSPACE="" +GPU_FLAG="" +SKIP_LAUNCH=0 +FORCE_CLOUD_OVERRIDE=0 +EXTRA_INSTALL_ARGS=() + +cleanup() { + local exit_code=$? + if [ $exit_code -ne 0 ]; then + echo "==> Setup exited with status $exit_code. Log: $LOG_FILE" >&2 + fi + exit $exit_code +} +trap cleanup EXIT INT TERM + +log() { echo "==> $*" | tee -a "$LOG_FILE" >&2; } +err() { echo "ERROR: $*" | tee -a "$LOG_FILE" >&2; } + +# --- Argument parsing --- +PASSTHROUGH=0 +for arg in "$@"; do + if [ "$PASSTHROUGH" -eq 1 ]; then + EXTRA_INSTALL_ARGS+=("$arg") + continue + fi + case "$arg" in + --nvidia|--amd|--m-series|--cpu) + GPU_FLAG="$arg" + ;; + --port=*) + PORT="${arg#*=}" + ;; + --workspace=*) + WORKSPACE="${arg#*=}" + ;; + --skip-launch) + SKIP_LAUNCH=1 + ;; + --force-cloud-override) + FORCE_CLOUD_OVERRIDE=1 + ;; + --) + PASSTHROUGH=1 + ;; + --help|-h) + # Print the leading comment block, stripping the `# ` prefix. + # Stops at the first blank line which separates docs from code. + awk ' + NR == 1 { next } # skip shebang + /^[^#]/ { exit } # stop at first non-comment line + /^$/ { exit } # ...or first blank line + { sub(/^# ?/, ""); print } + ' "$0" + exit 0 + ;; + *) + err "Unknown argument: $arg" + exit 64 + ;; + esac +done + +log "Logging to $LOG_FILE" + +# --- Step 0: Hardware check (skipped if user gave an explicit GPU flag) --- +if [ -z "$GPU_FLAG" ]; then + if [ ! -f "$HARDWARE_CHECK" ]; then + log "hardware_check.py not found — defaulting to --nvidia" + GPU_FLAG="--nvidia" + else + log "Running hardware check…" + set +e + HW_JSON="$(python3 "$HARDWARE_CHECK" --json 2>>"$LOG_FILE")" + HW_EXIT=$? + set -e + + if [ -z "$HW_JSON" ]; then + err "hardware_check.py produced no output (exit $HW_EXIT). Pass an explicit flag." + exit 1 + fi + echo "$HW_JSON" | tee -a "$LOG_FILE" >&2 + + VERDICT="$(echo "$HW_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("verdict",""))')" + FLAG="$(echo "$HW_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("comfy_cli_flag") or "")')" + + if [ "$VERDICT" = "cloud" ] && [ "$FORCE_CLOUD_OVERRIDE" -ne 1 ]; then + log "" + log "Hardware check: this machine is not suitable for local ComfyUI." + log "Recommended: Comfy Cloud — https://platform.comfy.org" + log "" + log "To override and force a local install, re-run with --force-cloud-override" + log "or pass an explicit GPU flag (--nvidia|--amd|--m-series|--cpu)." + exit 2 + fi + + if [ "$VERDICT" = "marginal" ]; then + log "Hardware check: verdict is MARGINAL." + log " SD1.5 should work; SDXL/Flux may be slow or OOM." + log " Consider Comfy Cloud for heavier workflows: https://platform.comfy.org" + fi + + if [ -z "$FLAG" ]; then + log "hardware_check could not pick a comfy-cli flag. Defaulting to --nvidia." + log "(For Intel Arc or unsupported hardware, use the manual install path.)" + GPU_FLAG="--nvidia" + else + GPU_FLAG="$FLAG" + fi + fi +fi + +log "GPU flag: $GPU_FLAG" +log "Port: $PORT" +[ -n "$WORKSPACE" ] && log "Workspace: $WORKSPACE" +[ "${#EXTRA_INSTALL_ARGS[@]}" -gt 0 ] && log "Extra install args: ${EXTRA_INSTALL_ARGS[*]}" + +# --- Step 1: Install comfy-cli (prefer pipx / uvx over global pip) --- +COMFY_BIN="" +if command -v comfy >/dev/null 2>&1; then + COMFY_BIN="comfy" + log "comfy-cli already on PATH: $(comfy -v 2>/dev/null || echo 'unknown version')" +elif command -v uvx >/dev/null 2>&1; then + log "Using uvx (no install needed)" + COMFY_BIN="uvx --from comfy-cli comfy" +elif command -v pipx >/dev/null 2>&1; then + log "Installing comfy-cli via pipx…" + pipx install comfy-cli >>"$LOG_FILE" 2>&1 + COMFY_BIN="comfy" + # pipx adds shims to ~/.local/bin which may need to be on PATH + if ! command -v comfy >/dev/null 2>&1; then + if [ -x "$HOME/.local/bin/comfy" ]; then + export PATH="$HOME/.local/bin:$PATH" + COMFY_BIN="$HOME/.local/bin/comfy" + fi + fi +else + log "Neither pipx nor uvx found. Falling back to pip install --user…" + log " (Recommend installing pipx: https://pipx.pypa.io)" + if ! pip install --user comfy-cli >>"$LOG_FILE" 2>&1; then + # macOS: PEP 668 externally-managed-environment may block --user + log "pip install --user failed. Retrying with --break-system-packages…" + pip install --user --break-system-packages comfy-cli >>"$LOG_FILE" 2>&1 || { + err "Could not install comfy-cli. Install pipx or uv first." + exit 1 + } + fi + # Resolve the actual `comfy` script — pip --user puts it in: + # Linux: ~/.local/bin/comfy + # macOS: ~/Library/Python//bin/comfy OR ~/.local/bin/comfy + COMFY_BIN="" + for candidate in "$HOME/.local/bin/comfy" \ + "$HOME/Library/Python/3.13/bin/comfy" \ + "$HOME/Library/Python/3.12/bin/comfy" \ + "$HOME/Library/Python/3.11/bin/comfy" \ + "$HOME/Library/Python/3.10/bin/comfy"; do + if [ -x "$candidate" ]; then + COMFY_BIN="$candidate" + export PATH="$(dirname "$candidate"):$PATH" + break + fi + done + if [ -z "$COMFY_BIN" ]; then + if command -v comfy >/dev/null 2>&1; then + COMFY_BIN="comfy" + else + err "Installed comfy-cli but couldn't find the 'comfy' script." + err "Add the right Python user-bin directory to PATH and retry." + exit 1 + fi + fi +fi + +# --- Step 2: Disable analytics tracking (avoid interactive prompt) --- +log "Disabling analytics tracking…" +$COMFY_BIN --skip-prompt tracking disable >>"$LOG_FILE" 2>&1 || true + +# --- Step 3: Install ComfyUI --- +WORKSPACE_ARG=() +if [ -n "$WORKSPACE" ]; then + WORKSPACE_ARG=(--workspace "$WORKSPACE") +fi + +if $COMFY_BIN "${WORKSPACE_ARG[@]}" which 2>/dev/null | grep -q "ComfyUI"; then + EXISTING_WS="$($COMFY_BIN "${WORKSPACE_ARG[@]}" which 2>/dev/null || true)" + log "ComfyUI already installed at: $EXISTING_WS" +else + log "Installing ComfyUI ($GPU_FLAG)…" + if ! $COMFY_BIN "${WORKSPACE_ARG[@]}" --skip-prompt install "$GPU_FLAG" "${EXTRA_INSTALL_ARGS[@]}" >>"$LOG_FILE" 2>&1; then + err "Install failed. Tail of log:" + tail -20 "$LOG_FILE" >&2 + exit 1 + fi +fi + +if [ "$SKIP_LAUNCH" -eq 1 ]; then + log "Setup complete (--skip-launch). Run \`$COMFY_BIN launch --background -- --port $PORT\` when ready." + exit 0 +fi + +# --- Step 4: Detect already-running server --- +if curl -fsS "http://127.0.0.1:$PORT/system_stats" >/dev/null 2>&1; then + log "Server already running on port $PORT — skipping launch." + log "Stop with \`$COMFY_BIN stop\` if you want a fresh start." + curl -fsS "http://127.0.0.1:$PORT/system_stats" | python3 -m json.tool 2>/dev/null || true + log "Done." + exit 0 +fi + +# --- Step 5: Launch --- +log "Launching ComfyUI in background on port $PORT…" +LAUNCH_EXTRAS=("--" "--port" "$PORT") +if ! $COMFY_BIN "${WORKSPACE_ARG[@]}" launch --background "${LAUNCH_EXTRAS[@]}" >>"$LOG_FILE" 2>&1; then + err "Background launch failed. Tail of log:" + tail -20 "$LOG_FILE" >&2 + err "Try foreground launch to see real-time errors: $COMFY_BIN launch -- --port $PORT" + exit 1 +fi + +# --- Step 6: Wait for server --- +log "Waiting for server…" +MAX_WAIT=60 +ELAPSED=0 +while [ $ELAPSED -lt $MAX_WAIT ]; do + if curl -fsS "http://127.0.0.1:$PORT/system_stats" >/dev/null 2>&1; then + log "Server is running!" + curl -fsS "http://127.0.0.1:$PORT/system_stats" | python3 -m json.tool 2>/dev/null || true + break + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) +done + +if [ $ELAPSED -ge $MAX_WAIT ]; then + err "Server did not start within ${MAX_WAIT}s." + err "Inspect log: $LOG_FILE" + err "Or run foreground: $COMFY_BIN launch -- --port $PORT" + exit 1 +fi + +log "" +log "Setup complete!" +log " Server: http://127.0.0.1:$PORT" +log " Web UI: http://127.0.0.1:$PORT (open in browser)" +log " Stop: $COMFY_BIN stop" +log " Log: $LOG_FILE (kept until shell closes)" +log "" +log "Next steps:" +log " - Download a model: $COMFY_BIN model download --url --relative-path models/checkpoints" +log " - Run a workflow: python3 $SCRIPT_DIR/run_workflow.py --workflow --args '{...}'" + +# Disable trap on success path +trap - EXIT diff --git a/image-creation/comfyui/scripts/extract_schema.py b/image-creation/comfyui/scripts/extract_schema.py new file mode 100644 index 0000000..0eab65b --- /dev/null +++ b/image-creation/comfyui/scripts/extract_schema.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +""" +extract_schema.py — Analyze a ComfyUI API-format workflow and extract +controllable parameters. + +Improvements over v1: + - Catalogs live in `_common.py`, shared with `check_deps.py` + - Coverage expanded for Flux / SD3 / Wan / Hunyuan / LTX / IPAdapter / rgthree + - Symmetric duplicate-name resolution: ALL duplicates get a node-id suffix + (instead of "first wins, second renamed"), so callers see consistent names + - Negative prompt detected by tracing `KSampler.negative` connections back to + the source CLIPTextEncode (more reliable than meta-title heuristic) + - Embedding references in prompt text are extracted as model dependencies + - Detects Primitive nodes that drive other nodes' inputs (and surfaces them + as the user-facing parameter) + - Reroutes are followed when tracing connections + +Usage: + python3 extract_schema.py workflow_api.json + python3 extract_schema.py workflow_api.json --output schema.json + +Stdlib-only. Python 3.10+. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _common import ( # noqa: E402 + OUTPUT_NODES, PARAM_PATTERNS, PROMPT_FIELDS, + is_link, iter_embedding_refs, iter_model_deps, iter_nodes, unwrap_workflow, +) + + +# Sampler nodes whose `positive` / `negative` connections we trace +SAMPLER_NODE_FAMILY = { + "KSampler", "KSamplerAdvanced", + "SamplerCustom", "SamplerCustomAdvanced", + "BasicGuider", "CFGGuider", "DualCFGGuider", +} + + +def infer_type(value: Any) -> str: + if isinstance(value, bool): + return "bool" + if isinstance(value, int): + return "int" + if isinstance(value, float): + return "float" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "link" + if isinstance(value, dict): + return "object" + return "unknown" + + +def trace_to_node(workflow: dict, link: list, *, max_hops: int = 8) -> str | None: + """Follow a [node_id, slot] link, hopping through Reroute / Primitive nodes + if needed, to find the *upstream* node id that holds the actual value/input. + + Bounded by both `max_hops` AND a visited-set to prevent infinite loops on + pathological graphs. + """ + if not is_link(link): + return None + nid: str | None = link[0] + visited: set[str] = set() + for _ in range(max_hops): + if nid is None or nid in visited: + return nid + visited.add(nid) + node = workflow.get(nid) + if not isinstance(node, dict): + return None + cls = node.get("class_type", "") + # Reroute / Primitive / passthrough wrappers + if cls in {"Reroute", "PrimitiveNode", "Note", "easy showAnything"}: + inputs = node.get("inputs", {}) or {} + # Find first link-shaped input and follow it + next_link = next((v for v in inputs.values() if is_link(v)), None) + if next_link is None: + return nid + nid = next_link[0] + continue + return nid + return nid + + +def find_negative_prompt_node(workflow: dict) -> str | None: + """Trace `negative` input of a sampler back to the source text encoder.""" + for nid, node in iter_nodes(workflow): + if node["class_type"] not in SAMPLER_NODE_FAMILY: + continue + inputs = node.get("inputs", {}) or {} + neg = inputs.get("negative") + if not is_link(neg): + continue + src = trace_to_node(workflow, neg) + if src and isinstance(workflow.get(src), dict): + cls = workflow[src].get("class_type", "") + if cls.startswith("CLIPTextEncode") or cls in {"smZ CLIPTextEncode", "BNK_CLIPTextEncodeAdvanced"}: + return src + return None + + +def find_positive_prompt_node(workflow: dict) -> str | None: + for nid, node in iter_nodes(workflow): + if node["class_type"] not in SAMPLER_NODE_FAMILY: + continue + inputs = node.get("inputs", {}) or {} + pos = inputs.get("positive") + if not is_link(pos): + continue + src = trace_to_node(workflow, pos) + if src and isinstance(workflow.get(src), dict): + cls = workflow[src].get("class_type", "") + if cls.startswith("CLIPTextEncode") or cls in {"smZ CLIPTextEncode", "BNK_CLIPTextEncodeAdvanced"}: + return src + return None + + +def extract_schema(workflow: dict) -> dict: + """Extract controllable parameters from a workflow. + + Returns: + { + "parameters": { friendly_name: {node_id, field, type, value, ...} }, + "output_nodes": [node_id, ...], + "model_dependencies": [{node_id, class_type, field, value, folder}], + "embedding_dependencies": [{node_id, embedding_name, found_in_field, value_excerpt}], + "summary": {...} + } + """ + output_nodes: list[str] = [] + + # First pass: identify positive / negative prompt nodes via connection tracing + pos_node = find_positive_prompt_node(workflow) + neg_node = find_negative_prompt_node(workflow) + + # ----- collect raw parameter candidates ----- + # Each candidate = (friendly_name, node_id, field, value) + # We resolve duplicate friendly_names AFTER the loop so dedup is symmetric. + raw_params: list[dict] = [] + + for node_id, node in iter_nodes(workflow): + cls = node["class_type"] + inputs = node.get("inputs", {}) or {} + + if cls in OUTPUT_NODES: + output_nodes.append(node_id) + + # Match this node against PARAM_PATTERNS + for p_class, p_field, friendly in PARAM_PATTERNS: + if cls != p_class: + continue + if p_field not in inputs: + continue + value = inputs[p_field] + t = infer_type(value) + if t == "link": + continue # connections aren't directly controllable + + actual_name = friendly + + # Disambiguate prompt vs negative_prompt by connection tracing + if friendly == "prompt": + if node_id == neg_node and pos_node != neg_node: + actual_name = "negative_prompt" + elif node_id == pos_node: + actual_name = "prompt" + else: + # Fallback: use _meta.title hints if present + meta_title = (node.get("_meta") or {}).get("title", "").lower() + if any(t_ in meta_title for t_ in ("negative", "neg", "-prompt", "anti")): + actual_name = "negative_prompt" + + raw_params.append({ + "name_hint": actual_name, + "node_id": node_id, + "field": p_field, + "type": t, + "value": value, + "class_type": cls, + }) + + # ----- symmetric duplicate-name resolution ----- + # Group by name_hint. If a hint appears once, keep it. If multiple, suffix + # ALL with their node_id. Always-stable, always-uniquely-addressable. + by_name: dict[str, list[dict]] = {} + for r in raw_params: + by_name.setdefault(r["name_hint"], []).append(r) + + parameters: dict[str, dict] = {} + for name, entries in by_name.items(): + if len(entries) == 1: + r = entries[0] + parameters[name] = { + "node_id": r["node_id"], "field": r["field"], + "type": r["type"], "value": r["value"], + "class_type": r["class_type"], + } + else: + # Sort by node_id (string-natural) for stability + entries.sort(key=lambda x: (str(x["node_id"]).zfill(8), x["field"])) + for r in entries: + full_name = f"{name}_{r['node_id']}" + parameters[full_name] = { + "node_id": r["node_id"], "field": r["field"], + "type": r["type"], "value": r["value"], + "class_type": r["class_type"], + "alias_of": name, + } + + # ----- model dependencies ----- + model_deps = list(iter_model_deps(workflow)) + + # ----- embedding dependencies (in prompt text) ----- + embedding_deps: list[dict] = [] + seen_emb: set[tuple[str, str]] = set() + for nid, emb_name in iter_embedding_refs(workflow): + key = (nid, emb_name) + if key in seen_emb: + continue + seen_emb.add(key) + # Find which field had the reference, for context + node = workflow.get(nid, {}) + inputs = node.get("inputs", {}) or {} + found_field = None + excerpt = None + for fname, fval in inputs.items(): + if isinstance(fval, str) and fname in PROMPT_FIELDS and emb_name in fval: + found_field = fname + excerpt = fval[:120] + break + embedding_deps.append({ + "node_id": nid, + "embedding_name": emb_name, + "field": found_field, + "value_excerpt": excerpt, + "folder": "embeddings", + }) + + # ----- summary ----- + summary = { + "parameter_count": len(parameters), + "output_node_count": len(output_nodes), + "model_dep_count": len(model_deps), + "embedding_dep_count": len(embedding_deps), + "has_negative_prompt": "negative_prompt" in parameters, + "has_seed": "seed" in parameters or any(p.startswith("seed_") for p in parameters), + "is_video_workflow": any( + workflow.get(n, {}).get("class_type", "") in { + "VHS_VideoCombine", "SaveVideo", "SaveAnimatedWEBP", "SaveAnimatedPNG", + } for n in output_nodes + ), + } + + return { + "parameters": parameters, + "output_nodes": output_nodes, + "model_dependencies": model_deps, + "embedding_dependencies": embedding_deps, + "summary": summary, + } + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Extract controllable parameters from a ComfyUI workflow") + p.add_argument("workflow", help="Path to workflow API JSON file") + p.add_argument("--output", "-o", help="Output file (default: stdout)") + p.add_argument("--summary-only", action="store_true", + help="Only print the summary block") + args = p.parse_args(argv) + + wf_path = Path(args.workflow).expanduser() + if not wf_path.exists(): + print(f"Error: {wf_path} not found", file=sys.stderr) + return 1 + + try: + with wf_path.open() as f: + payload = json.load(f) + workflow = unwrap_workflow(payload) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except json.JSONDecodeError as e: + print(f"Error: invalid JSON — {e}", file=sys.stderr) + return 1 + + schema = extract_schema(workflow) + + if args.summary_only: + out = json.dumps(schema["summary"], indent=2) + else: + out = json.dumps(schema, indent=2, default=str) + + if args.output: + Path(args.output).write_text(out) + print(f"Schema written to {args.output}", file=sys.stderr) + else: + print(out) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/image-creation/comfyui/scripts/fetch_logs.py b/image-creation/comfyui/scripts/fetch_logs.py new file mode 100644 index 0000000..e885a03 --- /dev/null +++ b/image-creation/comfyui/scripts/fetch_logs.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +""" +fetch_logs.py — Retrieve workflow execution diagnostics from a ComfyUI server. + +When a workflow errors, the server's /history (local) or /jobs (cloud) entry +contains the full Python traceback. This script makes it easy to fetch by +prompt_id, with sensible formatting. + +Usage: + python3 fetch_logs.py + python3 fetch_logs.py --host https://cloud.comfy.org + python3 fetch_logs.py --tail-queue # show currently queued/running jobs +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _common import ( # noqa: E402 + DEFAULT_LOCAL_HOST, ENV_API_KEY, emit_json, http_get, is_cloud_host, + resolve_api_key, resolve_url, +) + + +def fetch_history_entry(host: str, headers: dict, prompt_id: str, *, is_cloud: bool) -> dict: + if is_cloud: + # Try /jobs/{id} first + url = resolve_url(host, f"/jobs/{prompt_id}", is_cloud=True) + r = http_get(url, headers=headers, retries=2, timeout=30) + if r.status == 200: + try: + return {"ok": True, "entry": r.json(), "source": "/api/jobs"} + except Exception: + pass + # Fallback to history_v2 + url = resolve_url(host, f"/history/{prompt_id}", is_cloud=True) + r = http_get(url, headers=headers, retries=2, timeout=30) + try: + data = r.json() + except Exception: + data = None + if r.status == 200 and data: + return {"ok": True, "entry": data, "source": "/api/history_v2"} + return {"ok": False, "http_status": r.status, "body": r.text()[:500]} + + url = resolve_url(host, f"/history/{prompt_id}", is_cloud=False) + r = http_get(url, headers=headers, retries=2, timeout=30) + if r.status != 200: + return {"ok": False, "http_status": r.status, "body": r.text()[:500]} + try: + data = r.json() + except Exception: + return {"ok": False, "reason": "non-JSON response"} + if not isinstance(data, dict) or prompt_id not in data: + return {"ok": False, "reason": "prompt_id not found in history", + "history_keys": list(data.keys())[:5] if isinstance(data, dict) else []} + return {"ok": True, "entry": data[prompt_id], "source": "/history"} + + +def fetch_queue(host: str, headers: dict) -> dict: + url = resolve_url(host, "/queue") + r = http_get(url, headers=headers, retries=2, timeout=15) + try: + data = r.json() + except Exception: + data = {"raw": r.text()[:500]} + return {"http_status": r.status, "data": data} + + +def extract_diagnostics(entry: dict) -> dict: + """Pull out the parts a human cares about: status, errors, traceback, timing.""" + diag: dict = {} + status = entry.get("status") or {} + diag["status_str"] = status.get("status_str") + diag["completed"] = status.get("completed") + + messages = status.get("messages") or [] + diag["execution_log"] = [] + for msg in messages: + if isinstance(msg, list) and len(msg) >= 2: + mtype, mdata = msg[0], msg[1] + diag["execution_log"].append({"type": mtype, "data": mdata}) + else: + diag["execution_log"].append(msg) + + # Look for execution_error inside messages + errors = [] + for msg in messages: + if isinstance(msg, list) and len(msg) >= 2 and msg[0] == "execution_error": + errors.append(msg[1]) + if errors: + diag["errors"] = errors + + # Cloud's /jobs response shape: top-level outputs / status / etc. + if "outputs" in entry: + out = entry["outputs"] or {} + if isinstance(out, dict): + diag["output_node_ids"] = list(out.keys()) + # Count file refs across all output buckets (images / video / etc.) + total = 0 + for node_output in out.values(): + if not isinstance(node_output, dict): + continue + for v in node_output.values(): + if isinstance(v, list): + total += len(v) + diag["output_count"] = total + else: + diag["output_node_ids"] = [] + diag["output_count"] = 0 + return diag + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Fetch workflow execution diagnostics") + p.add_argument("prompt_id", nargs="?", help="prompt_id to look up") + p.add_argument("--host", default=DEFAULT_LOCAL_HOST) + p.add_argument("--api-key", help=f"or set ${ENV_API_KEY}") + p.add_argument("--raw", action="store_true", + help="Print the full history entry instead of the digest") + p.add_argument("--tail-queue", action="store_true", + help="Show currently running/pending jobs instead") + args = p.parse_args(argv) + + api_key = resolve_api_key(args.api_key) + headers = {"X-API-Key": api_key} if api_key else {} + is_cloud = is_cloud_host(args.host) + + if args.tail_queue: + emit_json(fetch_queue(args.host, headers)) + return 0 + + if not args.prompt_id: + print("Error: prompt_id is required (or use --tail-queue)", file=sys.stderr) + return 1 + + res = fetch_history_entry(args.host, headers, args.prompt_id, is_cloud=is_cloud) + if not res.get("ok"): + emit_json(res) + return 1 + + if args.raw: + emit_json(res) + return 0 + + diag = extract_diagnostics(res["entry"]) + diag["source"] = res.get("source") + diag["prompt_id"] = args.prompt_id + emit_json(diag) + return 0 if diag.get("status_str") not in {"error",} else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/image-creation/comfyui/scripts/hardware_check.py b/image-creation/comfyui/scripts/hardware_check.py new file mode 100644 index 0000000..083d018 --- /dev/null +++ b/image-creation/comfyui/scripts/hardware_check.py @@ -0,0 +1,497 @@ +#!/usr/bin/env python3 +"""hardware_check.py — Detect whether this machine can realistically run ComfyUI locally. + +Improvements over v1: + - Multi-GPU detection: scans all NVIDIA / AMD GPUs, picks the best one (most VRAM) + - Apple Silicon: detects Rosetta-via-x86_64 false negative; warns instead of misclassifying + - Apple generation: defaults to None (unknown) instead of mis-tagging as M1 + - WSL2 detection: identifies WSL2 + nvidia-smi situation explicitly + - ROCm: prefers `rocm-smi --json` for new ROCm 6.x output + - Disk space check: warns if /home or workspace volume has < 25 GB free + - PyTorch verification (optional): tries to import torch and check device availability + - Windows: prefers PowerShell `Get-CimInstance` over deprecated `wmic` + - More accurate VRAM thresholds and verdict reasons + +Emits a structured JSON report. Exit codes match `verdict`: + 0 → ok + 1 → marginal + 2 → cloud + +Usage: + python3 hardware_check.py [--json] [--check-pytorch] +""" + +from __future__ import annotations + +import json +import os +import platform +import re +import shutil +import subprocess +import sys +from typing import Any + + +# Thresholds (GiB). +MIN_VRAM_GB_USABLE = 6 +OK_VRAM_GB = 8 +GREAT_VRAM_GB = 12 +MIN_MAC_RAM_GB = 16 +OK_MAC_RAM_GB = 32 +MIN_FREE_DISK_GB = 25 # ComfyUI core ~5 GB + one model ~5–24 GB + +_COMFY_CLI_FLAG = { + "nvidia": "--nvidia", + "amd": "--amd", + "apple-silicon": "--m-series", + "intel": None, + "comfy-cloud": None, + "cpu": "--cpu", +} + + +def _run(cmd: list[str], timeout: int = 8) -> str: + try: + out = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, check=False + ) + return (out.stdout or "") + (out.stderr or "") + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return "" + + +def is_wsl() -> bool: + """Return True when running under Windows Subsystem for Linux.""" + if platform.system() != "Linux": + return False + if "microsoft" in platform.release().lower() or "wsl" in platform.release().lower(): + return True + try: + with open("/proc/version", "r") as fh: + return "microsoft" in fh.read().lower() + except OSError: + return False + + +def is_rosetta() -> bool: + """Return True when Python is running translated under Rosetta on Apple Silicon.""" + if platform.system() != "Darwin": + return False + if platform.machine() == "arm64": + return False + # x86_64 on Darwin — could be Intel Mac or Rosetta. Probe sysctl. + out = _run(["sysctl", "-in", "sysctl.proc_translated"]).strip() + return out == "1" + + +def detect_nvidia() -> dict | None: + """Detect NVIDIA GPUs. Returns the GPU with the most VRAM, plus list of all.""" + if not shutil.which("nvidia-smi"): + return None + out = _run([ + "nvidia-smi", + "--query-gpu=index,name,memory.total,driver_version", + "--format=csv,noheader,nounits", + ]) + if not out.strip(): + return None + gpus = [] + for line in out.strip().splitlines(): + parts = [p.strip() for p in line.split(",")] + if len(parts) < 3: + continue + try: + idx = int(parts[0]) + name = parts[1] + vram_mb = int(parts[2]) + except ValueError: + continue + driver = parts[3] if len(parts) > 3 else "" + gpus.append({ + "vendor": "nvidia", + "index": idx, + "name": name, + "vram_gb": round(vram_mb / 1024, 1), + "driver": driver, + }) + if not gpus: + return None + # Pick GPU with most VRAM + best = max(gpus, key=lambda g: g["vram_gb"]) + if len(gpus) > 1: + best["all_gpus"] = gpus + return best + + +def detect_rocm() -> dict | None: + if not shutil.which("rocm-smi"): + return None + # Prefer JSON output (new ROCm 6.x) + out = _run(["rocm-smi", "--showproductname", "--showmeminfo", "vram", "--json"]) + if out.strip().startswith("{"): + try: + data = json.loads(out) + cards = [] + for card_id, info in data.items(): + if not card_id.startswith("card"): + continue + name = (info.get("Card series") or info.get("Card model") + or info.get("Marketing Name") or "AMD GPU") + vram_b = info.get("VRAM Total Memory (B)") or info.get("vram_total_memory_b") or 0 + try: + vram_b = int(vram_b) + except (ValueError, TypeError): + vram_b = 0 + cards.append({ + "vendor": "amd", + "name": str(name).strip(), + "vram_gb": round(vram_b / (1024**3), 1), + "driver": "rocm", + }) + if cards: + best = max(cards, key=lambda c: c["vram_gb"]) + if len(cards) > 1: + best["all_gpus"] = cards + return best + except json.JSONDecodeError: + pass + # Fall back to text parsing + out = _run(["rocm-smi", "--showproductname", "--showmeminfo", "vram"]) + if not out.strip(): + return None + name_m = re.search(r"Card (?:series|model|Marketing Name):\s*(.+)", out) + vram_m = re.search(r"VRAM Total Memory \(B\):\s*(\d+)", out) + vram_gb = round(int(vram_m.group(1)) / (1024**3), 1) if vram_m else 0.0 + return { + "vendor": "amd", + "name": name_m.group(1).strip() if name_m else "AMD GPU", + "vram_gb": vram_gb, + "driver": "rocm", + } + + +def detect_apple_silicon() -> dict | None: + if platform.system() != "Darwin": + return None + if platform.machine() != "arm64": + return None + chip = _run(["sysctl", "-n", "machdep.cpu.brand_string"]).strip() + m = re.search(r"Apple M(\d+)", chip) + generation = int(m.group(1)) if m else None + mem_bytes = 0 + try: + mem_bytes = int(_run(["sysctl", "-n", "hw.memsize"]).strip() or 0) + except ValueError: + pass + ram_gb = round(mem_bytes / (1024**3), 1) if mem_bytes else 0.0 + + # Detect chip variant ("Pro", "Max", "Ultra") — affects performance even at same gen + variant = None + for v in ("Ultra", "Max", "Pro"): + if v in chip: + variant = v + break + + return { + "vendor": "apple", + "name": chip or "Apple Silicon", + "generation": generation, + "variant": variant, + "unified_memory_gb": ram_gb, + } + + +def detect_intel_arc() -> dict | None: + if platform.system() not in {"Linux", "Windows"}: + return None + if shutil.which("clinfo"): + out = _run(["clinfo", "--list"]) + if "Intel" in out and ("Arc" in out or "Xe" in out): + return {"vendor": "intel", "name": "Intel Arc/Xe", "vram_gb": 0.0} + # Windows: try Get-CimInstance + if platform.system() == "Windows" and shutil.which("powershell"): + out = _run(["powershell", "-NoProfile", + "Get-CimInstance Win32_VideoController | Select-Object Name | Format-List"]) + if "Intel" in out and ("Arc" in out or "Iris Xe" in out): + return {"vendor": "intel", "name": "Intel Arc/Iris Xe", "vram_gb": 0.0} + return None + + +def total_system_ram_gb() -> float: + sysname = platform.system() + if sysname == "Darwin": + try: + return round(int(_run(["sysctl", "-n", "hw.memsize"]).strip() or 0) / (1024**3), 1) + except ValueError: + return 0.0 + if sysname == "Linux": + try: + with open("/proc/meminfo", "r") as fh: + for line in fh: + if line.startswith("MemTotal:"): + kb = int(line.split()[1]) + return round(kb / (1024**2), 1) + except OSError: + return 0.0 + if sysname == "Windows": + if shutil.which("powershell"): + out = _run([ + "powershell", "-NoProfile", + "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory", + ]) + m = re.search(r"(\d{8,})", out) + if m: + return round(int(m.group(1)) / (1024**3), 1) + # Fall back to wmic for older Windows + out = _run(["wmic", "ComputerSystem", "get", "TotalPhysicalMemory"]) + m = re.search(r"(\d{6,})", out) + if m: + return round(int(m.group(1)) / (1024**3), 1) + return 0.0 + + +def total_free_disk_gb(path: str = ".") -> float: + try: + usage = shutil.disk_usage(path) + return round(usage.free / (1024**3), 1) + except OSError: + return 0.0 + + +def check_pytorch_cuda() -> dict | None: + """Optional PyTorch availability check. Only run when --check-pytorch is set.""" + try: + import torch # type: ignore[import-not-found] + except Exception as e: + return {"available": False, "reason": f"torch not importable: {e}"} + info: dict[str, Any] = { + "available": True, + "torch_version": torch.__version__, + } + try: + info["cuda_available"] = bool(torch.cuda.is_available()) + if info["cuda_available"]: + info["cuda_device_count"] = torch.cuda.device_count() + info["cuda_device_0"] = torch.cuda.get_device_name(0) + except Exception: + info["cuda_available"] = False + try: + info["mps_available"] = bool(torch.backends.mps.is_available()) + except Exception: + info["mps_available"] = False + return info + + +def classify(gpu: dict | None, ram_gb: float, free_disk_gb: float, *, wsl: bool, rosetta: bool) -> tuple[str, str, list[str]]: + notes: list[str] = [] + + if rosetta: + notes.append( + "Detected Python running under Rosetta on Apple Silicon. " + "ComfyUI MPS support requires native ARM64 Python — install via " + "`brew install python` or arm64 Miniforge, then re-run." + ) + return "cloud", "comfy-cloud", notes + + if wsl and gpu and gpu["vendor"] == "nvidia": + notes.append("Detected WSL2 + NVIDIA — confirm `nvidia-smi` works in your WSL distro before installing.") + + if free_disk_gb and free_disk_gb < MIN_FREE_DISK_GB: + notes.append( + f"Free disk space ({free_disk_gb} GB) is below the {MIN_FREE_DISK_GB} GB recommended minimum. " + "ComfyUI core (~5 GB) plus one SDXL model (~6.5 GB) needs space; Flux Dev needs ~24 GB." + ) + + # Host RAM matters even for discrete-GPU systems: ComfyUI swaps model + # weights through CPU RAM when shuffling between text encoders / VAE / UNet. + # Apple's unified-memory check is handled below so don't double-warn. + if ram_gb and ram_gb < 8 and gpu and gpu.get("vendor") != "apple": + notes.append( + f"System RAM ({ram_gb} GB) is low. ComfyUI swaps model weights through " + "host RAM; <8 GB causes severe slowdowns. 16+ GB recommended." + ) + + if gpu is None: + notes.append( + "No supported accelerator found (NVIDIA CUDA / AMD ROCm / Apple Silicon / Intel Arc)." + ) + notes.append( + "CPU-only ComfyUI works but is unusably slow for modern models — use Comfy Cloud." + ) + return "cloud", "comfy-cloud", notes + + if gpu["vendor"] == "apple": + gen = gpu.get("generation") + variant = gpu.get("variant") + mem = gpu.get("unified_memory_gb", 0.0) + gen_str = f"M{gen}" if gen else "Apple Silicon" + if variant: + gen_str += f" {variant}" + if mem < MIN_MAC_RAM_GB: + notes.append( + f"{gen_str} with {mem} GB unified memory — below the {MIN_MAC_RAM_GB} GB practical minimum." + ) + notes.append("SD1.5 may work; SDXL/Flux will swap or OOM. Recommend Comfy Cloud.") + return "cloud", "comfy-cloud", notes + if mem < OK_MAC_RAM_GB: + notes.append( + f"{gen_str} with {mem} GB — SDXL works but slow. Flux/video likely too tight." + ) + return "marginal", "apple-silicon", notes + notes.append(f"{gen_str} with {mem} GB unified memory — good for SDXL/Flux.") + return "ok", "apple-silicon", notes + + if gpu["vendor"] == "intel": + notes.append("Intel Arc detected — ComfyUI IPEX support is experimental; Comfy Cloud is more reliable.") + return "marginal", "intel", notes + + # Discrete NVIDIA / AMD + vram = gpu.get("vram_gb", 0.0) + name = gpu["name"] + if vram < MIN_VRAM_GB_USABLE: + notes.append( + f"{name} has only {vram} GB VRAM — below the {MIN_VRAM_GB_USABLE} GB practical minimum." + ) + notes.append("Most modern models won't load. Recommend Comfy Cloud.") + return "cloud", "comfy-cloud", notes + if vram < OK_VRAM_GB: + notes.append( + f"{name} ({vram} GB VRAM) — SD1.5 works, SDXL tight, Flux/video unlikely." + ) + return "marginal", gpu["vendor"], notes + if vram < GREAT_VRAM_GB: + notes.append(f"{name} ({vram} GB VRAM) — SDXL comfortable, Flux possible with optimizations.") + return "ok", gpu["vendor"], notes + notes.append(f"{name} ({vram} GB VRAM) — can run everything including Flux/video.") + return "ok", gpu["vendor"], notes + + +def build_report(*, check_pytorch: bool = False) -> dict: + sysname = platform.system() + arch = platform.machine() + ram_gb = total_system_ram_gb() + free_disk_gb = total_free_disk_gb(os.path.expanduser("~")) + + rosetta = is_rosetta() + wsl = is_wsl() + + gpu = ( + detect_nvidia() + or detect_rocm() + or detect_apple_silicon() + or detect_intel_arc() + ) + + # Intel Mac: arm64 detect failed AND no other GPU paths + if gpu is None and sysname == "Darwin" and arch != "arm64" and not rosetta: + notes = [ + "Intel Mac detected — no MPS backend available.", + "ComfyUI will fall back to CPU which is unusably slow. Use Comfy Cloud.", + ] + report = { + "os": sysname, + "arch": arch, + "system_ram_gb": ram_gb, + "free_disk_gb": free_disk_gb, + "wsl": False, + "rosetta": False, + "gpu": None, + "verdict": "cloud", + "recommended_install_path": "comfy-cloud", + "comfy_cli_flag": None, + "notes": notes, + "install_urls": _install_urls(), + } + if check_pytorch: + report["pytorch"] = check_pytorch_cuda() + return report + + verdict, install_path, notes = classify( + gpu, ram_gb, free_disk_gb, wsl=wsl, rosetta=rosetta, + ) + + report = { + "os": sysname, + "arch": arch, + "system_ram_gb": ram_gb, + "free_disk_gb": free_disk_gb, + "wsl": wsl, + "rosetta": rosetta, + "gpu": gpu, + "verdict": verdict, + "recommended_install_path": install_path, + "comfy_cli_flag": _COMFY_CLI_FLAG.get(install_path), + "notes": notes, + "install_urls": _install_urls(), + } + if check_pytorch: + report["pytorch"] = check_pytorch_cuda() + return report + + +def _install_urls() -> dict: + return { + "desktop": "https://docs.comfy.org/installation/desktop", + "manual": "https://docs.comfy.org/installation/manual_install", + "comfy_cli": "https://docs.comfy.org/comfy-cli/getting-started", + "cloud": "https://platform.comfy.org", + } + + +def main(argv: list[str] | None = None) -> int: + import argparse + p = argparse.ArgumentParser(description="Check whether this machine can run ComfyUI locally.") + p.add_argument("--json", action="store_true", help="Emit machine-readable JSON only") + p.add_argument("--check-pytorch", action="store_true", + help="Also probe `torch` for CUDA/MPS availability (slower)") + args = p.parse_args(argv) + + report = build_report(check_pytorch=args.check_pytorch) + + if args.json: + print(json.dumps(report, indent=2)) + else: + print(f"OS: {report['os']} ({report['arch']})") + if report.get("wsl"): + print("Env: WSL2") + if report.get("rosetta"): + print("Env: Rosetta (x86_64 Python on Apple Silicon)") + print(f"RAM: {report['system_ram_gb']} GB") + print(f"Free disk: {report['free_disk_gb']} GB (~/)") + if report["gpu"]: + g = report["gpu"] + if g["vendor"] == "apple": + print(f"GPU: {g['name']} — {g.get('unified_memory_gb', 0)} GB unified memory") + else: + print(f"GPU: {g['name']} — {g.get('vram_gb', 0)} GB VRAM") + if g.get("all_gpus") and len(g["all_gpus"]) > 1: + print(f" ({len(g['all_gpus'])} GPUs total; using best by VRAM)") + else: + print("GPU: (none detected)") + print(f"Verdict: {report['verdict']} → {report['recommended_install_path']}") + if report["comfy_cli_flag"]: + print(f" run: comfy --skip-prompt install {report['comfy_cli_flag']}") + if report.get("pytorch"): + pt = report["pytorch"] + if pt.get("available"): + line = f"PyTorch: {pt.get('torch_version')}" + if pt.get("cuda_available"): + line += f" + CUDA ({pt.get('cuda_device_0', '?')})" + if pt.get("mps_available"): + line += " + MPS" + print(line) + else: + print(f"PyTorch: not available — {pt.get('reason')}") + for n in report["notes"]: + print(f" • {n}") + + if report["verdict"] == "ok": + return 0 + if report["verdict"] == "marginal": + return 1 + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/image-creation/comfyui/scripts/health_check.py b/image-creation/comfyui/scripts/health_check.py new file mode 100644 index 0000000..63c5025 --- /dev/null +++ b/image-creation/comfyui/scripts/health_check.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +health_check.py — One-stop verification that the ComfyUI environment is ready. + +Runs through the verification checklist: + 1. comfy-cli on PATH + 2. server reachable (/system_stats) + 3. at least one checkpoint installed + 4. (optional) a specific workflow's deps are met + 5. (optional) actually submit a tiny test workflow and verify round-trip + +Usage: + python3 health_check.py + python3 health_check.py --host https://cloud.comfy.org + python3 health_check.py --workflow my.json + python3 health_check.py --smoke-test # actually submit a tiny workflow +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _common import ( # noqa: E402 + DEFAULT_LOCAL_HOST, ENV_API_KEY, emit_json, http_get, parse_model_list, + resolve_api_key, resolve_url, unwrap_workflow, +) + + +def comfy_cli_status() -> dict: + if shutil.which("comfy"): + return {"available": True, "method": "comfy", "path": shutil.which("comfy")} + if shutil.which("uvx"): + return {"available": True, "method": "uvx", + "hint": "Invoke as `uvx --from comfy-cli comfy ...`"} + return { + "available": False, + "hint": "Install with: pipx install comfy-cli (or `pip install comfy-cli`)", + } + + +def server_status(host: str, headers: dict) -> dict: + url = resolve_url(host, "/system_stats") + try: + r = http_get(url, headers=headers, retries=2, timeout=10) + if r.status == 200: + try: + stats = r.json() or {} + except Exception: + stats = {} + return {"reachable": True, "url": url, "stats": stats} + return {"reachable": False, "url": url, "http_status": r.status, "body": r.text()[:200]} + except Exception as e: + return {"reachable": False, "url": url, "error": str(e)} + + +def checkpoint_status(host: str, headers: dict) -> dict: + url = resolve_url(host, "/models/checkpoints") + try: + r = http_get(url, headers=headers, retries=2, timeout=15) + except Exception as e: + return {"queryable": False, "error": str(e)} + if r.status != 200: + return {"queryable": False, "http_status": r.status, "url": url, "body": r.text()[:200]} + try: + models = parse_model_list(r.json()) + except Exception: + models = set() + return {"queryable": True, "count": len(models), + "first_few": sorted(models)[:5]} + + +SMOKE_WORKFLOW = { + # Minimal SD1.5 workflow that doesn't depend on rare nodes. + # 256x256 + 1 step is the smallest config that doesn't trigger SDXL/Flux + # validation errors while still executing fast. + "3": { + "class_type": "KSampler", + "inputs": { + "seed": 1, "steps": 1, "cfg": 7.0, + "sampler_name": "euler", "scheduler": "normal", "denoise": 1.0, + "model": ["4", 0], "positive": ["6", 0], "negative": ["7", 0], + "latent_image": ["5", 0], + }, + }, + "4": {"class_type": "CheckpointLoaderSimple", + "inputs": {"ckpt_name": "REPLACE_ME"}}, + "5": {"class_type": "EmptyLatentImage", + "inputs": {"width": 256, "height": 256, "batch_size": 1}}, + "6": {"class_type": "CLIPTextEncode", + "inputs": {"text": "test", "clip": ["4", 1]}}, + "7": {"class_type": "CLIPTextEncode", + "inputs": {"text": "", "clip": ["4", 1]}}, + "9": {"class_type": "SaveImage", + "inputs": {"filename_prefix": "smoke", "images": ["3", 0]}}, +} + + +def smoke_test(host: str, headers: dict, ckpt_name: str | None) -> dict: + """Submit a tiny workflow and verify the server accepts it. + + Cancels the job immediately after acceptance so we don't burn GPU + time / cloud minutes on a smoke test. + """ + if not ckpt_name: + return {"ran": False, "reason": "no checkpoint available"} + wf = json.loads(json.dumps(SMOKE_WORKFLOW)) + wf["4"]["inputs"]["ckpt_name"] = ckpt_name + + # Lazy import to avoid circular issues + from run_workflow import ComfyRunner + api_key = headers.get("X-API-Key") + runner = ComfyRunner(host=host, api_key=api_key) + sub = runner.submit(wf) + if "_http_error" in sub: + return {"ran": True, "submitted": False, + "http_status": sub["_http_error"], "body": sub.get("body")} + pid = sub.get("prompt_id") + if not pid: + return {"ran": True, "submitted": False, "response": sub} + + # Cancel so we don't actually waste compute on the smoke test. + cancelled = False + try: + cancelled = runner.cancel(pid) + except Exception: + pass + + return { + "ran": True, "submitted": True, "prompt_id": pid, + "cancelled_after_submit": cancelled, + "note": "Submission accepted; cancelled to avoid running the full pipeline.", + } + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="One-stop ComfyUI health check") + p.add_argument("--host", default=DEFAULT_LOCAL_HOST) + p.add_argument("--api-key", help=f"or set ${ENV_API_KEY}") + p.add_argument("--workflow", help="Optional: also run check_deps on this workflow") + p.add_argument("--smoke-test", action="store_true", + help="Submit a tiny test workflow and verify round-trip") + p.add_argument("--strict", action="store_true", + help="Exit non-zero on any non-pass condition (including warnings)") + args = p.parse_args(argv) + + api_key = resolve_api_key(args.api_key) + headers = {"X-API-Key": api_key} if api_key else {} + + cli = comfy_cli_status() + server = server_status(args.host, headers) + ckpts = checkpoint_status(args.host, headers) if server.get("reachable") else None + + # ---- workflow check ---- + workflow_check: dict | None = None + if args.workflow: + wf_path = Path(args.workflow).expanduser() + if not wf_path.exists(): + workflow_check = {"error": "workflow file not found"} + else: + try: + with wf_path.open() as f: + workflow = unwrap_workflow(json.load(f)) + from check_deps import check_deps + workflow_check = check_deps(workflow, host=args.host, api_key=api_key) + except (ValueError, json.JSONDecodeError) as e: + workflow_check = {"error": str(e)} + + smoke = None + if args.smoke_test and server.get("reachable"): + first_ckpt = ckpts["first_few"][0] if ckpts and ckpts.get("first_few") else None + smoke = smoke_test(args.host, headers, first_ckpt) + + # ---- verdict ---- + verdict = "pass" + reasons: list[str] = [] + if not server.get("reachable"): + verdict = "fail" + reasons.append("server unreachable") + if ckpts and ckpts.get("queryable") and ckpts.get("count", 0) == 0: + verdict = "warn" if verdict == "pass" else verdict + reasons.append("no checkpoints installed") + if workflow_check and workflow_check.get("error"): + verdict = "fail" + reasons.append(f"workflow check failed: {workflow_check['error']}") + elif workflow_check and not workflow_check.get("is_ready"): + if workflow_check.get("node_check_skipped"): + reasons.append("node check skipped (cloud free tier)") + else: + verdict = "fail" + reasons.append("workflow has missing deps") + if smoke and smoke.get("ran") and not smoke.get("submitted"): + verdict = "fail" + reasons.append("smoke-test submission failed") + if not cli.get("available"): + verdict = "warn" if verdict == "pass" else verdict + reasons.append("comfy-cli not on PATH (lifecycle commands won't work)") + + report = { + "verdict": verdict, + "reasons": reasons, + "host": args.host, + "comfy_cli": cli, + "server": server, + "checkpoints": ckpts, + "workflow_check": workflow_check, + "smoke_test": smoke, + } + emit_json(report) + + if verdict == "pass": + return 0 + if verdict == "warn": + return 1 if args.strict else 0 + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/image-creation/comfyui/scripts/run_batch.py b/image-creation/comfyui/scripts/run_batch.py new file mode 100644 index 0000000..7f5b159 --- /dev/null +++ b/image-creation/comfyui/scripts/run_batch.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +run_batch.py — Run a workflow many times, varying parameters per run. + +Two modes: + 1. --count N --randomize-seed + Submit N runs, each with a fresh random seed. Use for quick variations. + 2. --sweep '{"seed": [1,2,3], "steps": [20,30]}' + Cartesian product of values. With cloud subscription, runs in parallel + up to your tier's concurrent-job limit. + +Both modes write each run's outputs into output-dir/run_NNN/. + +Examples: + python3 run_batch.py --workflow flux_dev.json \ + --args '{"prompt": "a cat"}' \ + --count 8 --randomize-seed \ + --output-dir ./outputs/cat-batch + + python3 run_batch.py --workflow sdxl.json \ + --args '{"prompt": "abstract"}' \ + --sweep '{"seed": [1,2,3], "steps": [20, 40]}' \ + --output-dir ./outputs/sweep +""" + +from __future__ import annotations + +import argparse +import itertools +import json +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _common import ( # noqa: E402 + DEFAULT_LOCAL_HOST, ENV_API_KEY, coerce_seed, emit_json, log, + looks_like_video_workflow, resolve_api_key, unwrap_workflow, +) +from run_workflow import ( # noqa: E402 + ComfyRunner, download_outputs, inject_params, +) +from extract_schema import extract_schema # noqa: E402 + + +def expand_sweep(sweep: dict, base_args: dict, count: int, randomize_seed: bool) -> list[dict]: + """Generate a list of args dicts for each run.""" + if sweep: + # Cartesian product + keys = list(sweep.keys()) + values = [sweep[k] if isinstance(sweep[k], list) else [sweep[k]] for k in keys] + runs = [] + for combo in itertools.product(*values): + ar = dict(base_args) + for k, v in zip(keys, combo): + ar[k] = v + runs.append(ar) + return runs + # Count mode + runs = [] + for _ in range(count): + ar = dict(base_args) + if randomize_seed: + ar["seed"] = coerce_seed(None) + runs.append(ar) + return runs + + +def execute_one( + runner: ComfyRunner, workflow: dict, schema: dict, args: dict, + *, output_dir: Path, timeout: int, ws: bool, +) -> dict: + wf, warnings = inject_params(workflow, schema, args) + sub = runner.submit(wf) + if "_http_error" in sub: + return {"status": "error", "error": "submission HTTP error", + "details": sub.get("body"), "args": args} + pid = sub.get("prompt_id") + if not pid: + return {"status": "error", "error": "no prompt_id", "response": sub, "args": args} + if sub.get("node_errors"): + return {"status": "error", "error": "validation failed", + "node_errors": sub["node_errors"], "args": args} + + if ws: + result = runner.monitor_ws(pid, timeout=timeout) + else: + result = runner.poll_status(pid, timeout=timeout) + + if result["status"] != "success": + return { + "status": result["status"], + "prompt_id": pid, + "details": result.get("data"), + "args": args, + } + + outputs = result.get("outputs") or runner.get_outputs(pid) + downloaded = download_outputs(runner, outputs, output_dir, preserve_subfolder=False) + return { + "status": "success", + "prompt_id": pid, + "args": args, + "outputs": downloaded, + "warnings": warnings, + } + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Submit a workflow many times with varying parameters.", + ) + p.add_argument("--workflow", required=True) + p.add_argument("--args", default="{}", help="Base parameters JSON") + p.add_argument("--count", type=int, default=0, + help="Number of runs (use with --randomize-seed)") + p.add_argument("--sweep", default="", + help='JSON dict of param→list of values. Cartesian product. ' + 'e.g. \'{"seed":[1,2,3],"cfg":[5,8]}\'') + p.add_argument("--randomize-seed", action="store_true", + help="In --count mode, vary seed per run") + p.add_argument("--host", default=DEFAULT_LOCAL_HOST) + p.add_argument("--api-key", help=f"or set ${ENV_API_KEY}") + p.add_argument("--partner-key") + p.add_argument("--parallel", type=int, default=1, + help="Concurrent submissions (cloud: up to your tier limit). " + "Default 1 (sequential)") + p.add_argument("--output-dir", default="./outputs/batch") + p.add_argument("--timeout", type=int, default=0) + p.add_argument("--ws", action="store_true") + p.add_argument("--continue-on-error", action="store_true", + help="Don't stop the batch when a run fails") + args = p.parse_args(argv) + + if args.count <= 0 and not args.sweep: + emit_json({"error": "Specify --count N or --sweep '{...}'"}) + return 1 + + base_args = json.loads(args.args) if args.args.strip() else {} + sweep = json.loads(args.sweep) if args.sweep.strip() else {} + + # Validate sweep shape + if sweep: + if not isinstance(sweep, dict): + emit_json({"error": "--sweep must be a JSON object {param: [values]}"}) + return 1 + empty = [k for k, v in sweep.items() if isinstance(v, list) and len(v) == 0] + if empty: + emit_json({"error": f"--sweep parameters have empty value lists: {empty}"}) + return 1 + # If user passed BOTH --sweep and --count/--randomize-seed, --sweep wins + if args.count or args.randomize_seed: + log("--sweep set; ignoring --count / --randomize-seed (sweep defines the runs)") + + wf_path = Path(args.workflow).expanduser() + if not wf_path.exists(): + emit_json({"error": f"Workflow not found: {args.workflow}"}) + return 1 + try: + with wf_path.open() as f: + workflow = unwrap_workflow(json.load(f)) + except (ValueError, json.JSONDecodeError) as e: + emit_json({"error": str(e)}) + return 1 + + schema = extract_schema(workflow) + runs = expand_sweep(sweep, base_args, args.count, args.randomize_seed) + log(f"Planned {len(runs)} run(s)") + + api_key = resolve_api_key(args.api_key) + runner = ComfyRunner(host=args.host, api_key=api_key, partner_key=args.partner_key) + + ok, info = runner.check_server() + if not ok: + emit_json({"error": "Cannot reach server", "details": info, "host": args.host}) + return 1 + + timeout = args.timeout + if timeout <= 0: + timeout = 900 if looks_like_video_workflow(workflow) else 300 + + base_dir = Path(args.output_dir).expanduser() + base_dir.mkdir(parents=True, exist_ok=True) + + results: list[dict] = [] + failures = 0 + + if args.parallel > 1: + with ThreadPoolExecutor(max_workers=args.parallel) as ex: + future_to_idx = {} + for i, ar in enumerate(runs): + run_dir = base_dir / f"run_{i:04d}" + fut = ex.submit( + execute_one, runner, workflow, schema, ar, + output_dir=run_dir, timeout=timeout, ws=args.ws, + ) + future_to_idx[fut] = i + for fut in as_completed(future_to_idx): + i = future_to_idx[fut] + try: + r = fut.result() + except Exception as e: + r = {"status": "error", "error": str(e), "args": runs[i]} + r["index"] = i + results.append(r) + if r["status"] != "success": + failures += 1 + log(f" run {i} → {r['status']}: {r.get('error','?')}") + if not args.continue_on_error: + log(" --continue-on-error not set; aborting batch") + break + else: + log(f" run {i} → success: {len(r.get('outputs', []))} files") + else: + for i, ar in enumerate(runs): + run_dir = base_dir / f"run_{i:04d}" + r = execute_one(runner, workflow, schema, ar, + output_dir=run_dir, timeout=timeout, ws=args.ws) + r["index"] = i + results.append(r) + if r["status"] != "success": + failures += 1 + log(f" run {i} → {r['status']}: {r.get('error','?')}") + if not args.continue_on_error: + log(" --continue-on-error not set; aborting batch") + break + else: + log(f" run {i} → success: {len(r.get('outputs', []))} files") + + results.sort(key=lambda x: x.get("index", 0)) + emit_json({ + "status": "success" if failures == 0 else "partial", + "total": len(runs), + "completed": sum(1 for r in results if r["status"] == "success"), + "failed": failures, + "output_dir": str(base_dir), + "results": results, + }) + return 0 if failures == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/image-creation/comfyui/scripts/run_workflow.py b/image-creation/comfyui/scripts/run_workflow.py new file mode 100644 index 0000000..05afb1e --- /dev/null +++ b/image-creation/comfyui/scripts/run_workflow.py @@ -0,0 +1,796 @@ +#!/usr/bin/env python3 +""" +run_workflow.py — Inject parameters into a ComfyUI workflow, submit it, monitor +execution, and download outputs. + +Improvements over v1: + - Cloud-aware URL routing (handles /api prefix and /history_v2 / /experiment/models renames) + - API key from CLI flag OR $COMFY_CLOUD_API_KEY env var + - WebSocket progress monitoring (--ws), with HTTP polling fallback + - Streaming download (no whole-file buffering — handles GB-size video outputs) + - Path-traversal-safe output writes + - Subfolder-aware download paths (no silent overwrites) + - Retry with exponential backoff on transient errors + - Status-error correctly classified before "completed: true" + - Image upload helper (--input-image NAME=PATH) + - Auto-randomize seed when value is -1 or omitted on a randomize-seed flag + - Auto-extends timeout heuristically for video workflows + - Editor-format detection with helpful error + - Doesn't pollute extra_data.api_key_comfy_org with the cloud auth key + unless --partner-key is provided (correct semantic per cloud docs) + +Usage: + # Local server + python3 run_workflow.py --workflow workflow_api.json \ + --args '{"prompt": "a cat", "seed": 42}' \ + --output-dir ./outputs + + # Cloud server (API key from env var) + export COMFY_CLOUD_API_KEY="comfyui-xxxxxxx" + python3 run_workflow.py --workflow workflow_api.json \ + --args '{"prompt": "a cat"}' \ + --host https://cloud.comfy.org \ + --output-dir ./outputs + + # With image input (auto-uploads, then references) + python3 run_workflow.py --workflow img2img.json \ + --input-image image=./photo.png \ + --args '{"prompt": "make it cyberpunk"}' + + # WebSocket real-time progress + python3 run_workflow.py --workflow flux_dev.json \ + --args '{"prompt": "..."}' \ + --ws + +Stdlib-only by default (Python 3.10+). Will use `requests`/`websocket-client` +if installed for nicer behavior. +""" + +from __future__ import annotations + +import argparse +import copy +import json +import sys +import time +from pathlib import Path +from typing import Any +from urllib.parse import urlencode, urlparse + +# Local import — _common.py sits next to this script. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _common import ( # noqa: E402 + DEFAULT_LOCAL_HOST, ENV_API_KEY, + coerce_seed, emit_json, http_get, http_post, http_request, + is_cloud_host, is_link, log, looks_like_video_workflow, + media_type_from_filename, new_client_id, resolve_api_key, resolve_url, + safe_path_join, unwrap_workflow, +) + + +# ============================================================================= +# Runner +# ============================================================================= + +class WorkflowRunError(Exception): + """Raised when a workflow run fails (validation, execution, timeout).""" + + def __init__(self, status: str, message: str, **details: Any): + super().__init__(message) + self.status = status + self.message = message + self.details = details + + def to_dict(self) -> dict: + d = {"status": self.status, "error": self.message} + d.update(self.details) + return d + + +class ComfyRunner: + def __init__( + self, + host: str = DEFAULT_LOCAL_HOST, + api_key: str | None = None, + client_id: str | None = None, + partner_key: str | None = None, + ): + self.host = host.rstrip("/") + self.api_key = api_key + self.partner_key = partner_key + self.is_cloud = is_cloud_host(self.host) + self.client_id = client_id or new_client_id() + + @property + def headers(self) -> dict[str, str]: + h: dict[str, str] = {} + if self.api_key: + h["X-API-Key"] = self.api_key + return h + + def _url(self, path: str) -> str: + return resolve_url(self.host, path, is_cloud=self.is_cloud) + + # ---------- server health ---------- + def check_server(self) -> tuple[bool, dict | None]: + try: + r = http_get(self._url("/system_stats"), headers=self.headers, retries=2) + if r.status == 200: + try: + return True, r.json() + except Exception: + return True, None + return False, {"http_status": r.status, "body": r.text()[:500]} + except Exception as e: + return False, {"error": str(e)} + + # ---------- upload ---------- + def upload_image(self, path: Path, *, image_type: str = "input", overwrite: bool = True, + endpoint: str = "/upload/image", extra_form: dict | None = None) -> dict: + """Upload an image file via multipart. Returns server-side ref dict.""" + if not path.exists(): + raise FileNotFoundError(f"input image not found: {path}") + # Stream the file via a handle to avoid OOM on huge inputs (16MP+ photos). + with path.open("rb") as fh: + files = {"image": (path.name, fh)} + form = {"type": image_type} + if overwrite: + form["overwrite"] = "true" + if extra_form: + form.update({k: str(v) for k, v in extra_form.items()}) + r = http_request( + "POST", self._url(endpoint), + headers=self.headers, files=files, form=form, + timeout=300, retries=2, + ) + if r.status != 200: + raise WorkflowRunError( + "upload_failed", + f"Upload of {path.name} failed: HTTP {r.status}", + body=r.text()[:500], + ) + try: + return r.json() + except Exception: + return {"name": path.name} + + def upload_mask(self, path: Path, original_ref: dict) -> dict: + """Upload an inpaint mask, linked to a previously uploaded source image. + + `original_ref` should be the dict returned by `upload_image()` for the + source image (or `{"filename": ..., "subfolder": ..., "type": "input"}`). + """ + return self.upload_image( + path, + endpoint="/upload/mask", + extra_form={ + "subfolder": "clipspace", + "original_ref": json.dumps(original_ref), + }, + ) + + # ---------- submit ---------- + def submit(self, workflow: dict) -> dict: + payload: dict[str, Any] = {"prompt": workflow, "client_id": self.client_id} + if self.partner_key: + payload["extra_data"] = {"api_key_comfy_org": self.partner_key} + + r = http_post(self._url("/prompt"), headers=self.headers, json_body=payload, timeout=120) + try: + body = r.json() + except Exception: + body = {"raw": r.text()[:500]} + if r.status != 200: + return {"_http_error": r.status, "body": body} + return body + + # ---------- HTTP polling ---------- + def poll_status(self, prompt_id: str, *, timeout: float = 300.0, + initial_interval: float = 1.5, max_interval: float = 8.0) -> dict: + start = time.time() + interval = initial_interval + + while time.time() - start < timeout: + if self.is_cloud: + r = http_get( + self._url(f"/job/{prompt_id}/status"), + headers=self.headers, retries=2, timeout=30, + ) + if r.status == 200: + try: + data = r.json() + except Exception: + data = {} + s = data.get("status") + if s == "completed": + return {"status": "success", "data": data} + if s in {"failed",}: + return {"status": "error", "data": data} + if s == "cancelled": + return {"status": "cancelled", "data": data} + # pending / in_progress → continue + elif r.status == 404: + # Cloud sometimes 404s briefly between submit and dispatcher pickup + pass + else: + # transient error — retry loop covers it + pass + else: + # Local: /history/{id} grows once execution completes + r = http_get( + self._url(f"/history/{prompt_id}"), + headers=self.headers, retries=2, timeout=30, + ) + if r.status == 200: + try: + data = r.json() or {} + except Exception: + data = {} + entry = data.get(prompt_id) + if isinstance(entry, dict): + st = entry.get("status") or {} + # IMPORTANT: check error first — `completed: true` can coexist with errors + status_str = st.get("status_str") + if status_str == "error": + return {"status": "error", "data": entry} + if st.get("completed", False): + return {"status": "success", "outputs": entry.get("outputs", {})} + # not in history yet → continue polling + + time.sleep(interval) + interval = min(max_interval, interval * 1.4) + + return {"status": "timeout", "elapsed": time.time() - start} + + # ---------- WebSocket monitoring ---------- + def monitor_ws(self, prompt_id: str, *, timeout: float = 300.0, + on_progress: Any = None) -> dict: + """Connect to /ws and listen until execution_success / execution_error. + + Falls back to HTTP polling if `websocket-client` is not installed. + Returns same shape as poll_status. + """ + try: + import websocket # type: ignore[import-not-found] + except ImportError: + log("websocket-client not installed; falling back to HTTP polling") + return self.poll_status(prompt_id, timeout=timeout) + + # Build WS URL. Preserve any base-path components the user gave us + # (e.g. http://example.com/comfyui → ws://example.com/comfyui/ws). + parsed = urlparse(self.host) + scheme = "wss" if parsed.scheme == "https" else "ws" + netloc = parsed.netloc + base_path = parsed.path.rstrip("/") + ws_url = f"{scheme}://{netloc}{base_path}/ws?clientId={self.client_id}" + if self.is_cloud and self.api_key: + ws_url += f"&token={self.api_key}" + + outputs: dict[str, Any] = {} + error_payload: dict[str, Any] | None = None + success = False + seen_executed = False + + ws = websocket.create_connection(ws_url, timeout=timeout) + try: + ws.settimeout(timeout) + deadline = time.time() + timeout + while time.time() < deadline: + msg = ws.recv() + if isinstance(msg, bytes): + # Binary preview frame — ignore for now; ws_monitor.py prints them + continue + try: + payload = json.loads(msg) + except Exception: + continue + mtype = payload.get("type", "") + mdata = payload.get("data", {}) or {} + + # Filter to our job (cloud broadcasts; local filters via client_id) + pid = mdata.get("prompt_id") + if pid is not None and pid != prompt_id: + continue + + if mtype == "progress": + if callable(on_progress): + on_progress({ + "type": "progress", + "value": mdata.get("value"), + "max": mdata.get("max"), + "node": mdata.get("node"), + }) + elif mtype == "progress_state": + if callable(on_progress): + on_progress({"type": "progress_state", "nodes": mdata.get("nodes", {})}) + elif mtype == "executing": + node = mdata.get("node") + if callable(on_progress): + on_progress({"type": "executing", "node": node}) + # When `node` is None on a local server, that signals end-of-run + if node is None and not self.is_cloud and seen_executed: + success = True + break + elif mtype == "executed": + seen_executed = True + nid = mdata.get("node") + out = mdata.get("output") or {} + if nid: + outputs[nid] = out + elif mtype == "notification": + if callable(on_progress): + on_progress({"type": "notification", "message": mdata.get("value", "")}) + elif mtype == "execution_success": + success = True + break + elif mtype == "execution_error": + error_payload = mdata + break + elif mtype == "execution_interrupted": + error_payload = {"interrupted": True, **mdata} + break + finally: + try: + ws.close() + except Exception: + pass + + if error_payload is not None: + return {"status": "error", "data": error_payload} + if success: + return {"status": "success", "outputs": outputs} + return {"status": "timeout", "elapsed": timeout} + + # ---------- outputs ---------- + def get_outputs(self, prompt_id: str) -> dict: + if self.is_cloud: + # Try /jobs/{id} first (returns full job with outputs); fall back to /history_v2 + r = http_get(self._url(f"/jobs/{prompt_id}"), headers=self.headers, retries=2) + if r.status == 200: + try: + return (r.json() or {}).get("outputs", {}) or {} + except Exception: + pass + # Fallback + r = http_get(self._url(f"/history/{prompt_id}"), headers=self.headers, retries=2) + if r.status == 200: + try: + body = r.json() or {} + except Exception: + body = {} + if isinstance(body, dict) and prompt_id in body: + return body[prompt_id].get("outputs", {}) or {} + if isinstance(body, dict) and "outputs" in body: + return body["outputs"] or {} + return {} + # Local + r = http_get(self._url(f"/history/{prompt_id}"), headers=self.headers, retries=2) + if r.status != 200: + return {} + try: + body = r.json() or {} + except Exception: + return {} + entry = body.get(prompt_id) or {} + return entry.get("outputs", {}) or {} + + def download_output( + self, *, filename: str, subfolder: str, file_type: str, + output_dir: Path, preserve_subfolder: bool = True, overwrite: bool = False, + ) -> Path: + """Stream a single output to disk. Path-traversal-safe.""" + params = {"filename": filename, "subfolder": subfolder, "type": file_type} + url = self._url("/view") + "?" + urlencode(params) + + # Compute target path safely. If preserve_subfolder, include subfolder in the + # local path; otherwise put the file in output_dir flat. + target_parts: list[str] = [] + if preserve_subfolder and subfolder: + target_parts.extend(p for p in subfolder.split("/") if p and p not in {".", ".."}) + target_parts.append(filename) + out_path = safe_path_join(output_dir, *target_parts) + + if out_path.exists() and not overwrite: + stem, suffix = out_path.stem, out_path.suffix + i = 1 + while True: + candidate = out_path.with_name(f"{stem}_{i}{suffix}") + if not candidate.exists(): + out_path = candidate + break + i += 1 + + out_path.parent.mkdir(parents=True, exist_ok=True) + + # Stream download. Two-step for cloud: get the 302, then fetch signed URL + # so we don't accidentally send X-API-Key to the storage backend. + # The HTTP transport already strips X-API-Key on cross-host redirect + # via _strip_api_key_on_redirect, so a single follow_redirects=True call + # is safe AND simpler. + r = http_request( + "GET", url, headers=self.headers, + timeout=600, retries=3, follow_redirects=True, + stream=True, sink=out_path, + ) + if r.status != 200: + try: + if out_path.exists(): + out_path.unlink() + except Exception: + pass + raise WorkflowRunError( + "download_failed", + f"Download of {filename} failed: HTTP {r.status}", + url=url, + ) + return out_path + + # ---------- queue / cancel ---------- + def cancel(self, prompt_id: str | None = None) -> bool: + if prompt_id: + r = http_post( + self._url("/queue"), headers=self.headers, + json_body={"delete": [prompt_id]}, retries=1, + ) + return r.status == 200 + # Interrupt currently running + r = http_post(self._url("/interrupt"), headers=self.headers, retries=1) + return r.status == 200 + + +# ============================================================================= +# Schema / parameter injection +# ============================================================================= + +def _inline_schema(workflow: dict) -> dict: + """Generate schema using the sibling extract_schema module.""" + from extract_schema import extract_schema # noqa: WPS433 + return extract_schema(workflow) + + +def load_schema(schema_path: str | None, workflow: dict) -> dict: + if schema_path: + with open(schema_path) as f: + return json.load(f) + return _inline_schema(workflow) + + +def inject_params( + workflow: dict, schema: dict, args: dict, + *, randomize_seed_if_unset: bool = False, +) -> tuple[dict, list[str]]: + """Inject user args into the workflow. Returns (new_workflow, warnings).""" + wf = copy.deepcopy(workflow) + params = schema.get("parameters", {}) or {} + warnings: list[str] = [] + + # Auto-randomize seed when it's -1 in args, or when randomize_seed_if_unset + # and user didn't pass a seed. + if "seed" in params: + if "seed" in args and args["seed"] in {None, -1, "-1"}: + args = dict(args) + args["seed"] = coerce_seed(args["seed"]) + warnings.append(f"seed=-1 expanded to {args['seed']}") + elif randomize_seed_if_unset and "seed" not in args: + args = dict(args) + args["seed"] = coerce_seed(None) + warnings.append(f"seed auto-randomized to {args['seed']}") + + for name, value in args.items(): + if name not in params: + warnings.append(f"unknown parameter '{name}' (not in schema), skipping") + continue + m = params[name] + nid, field = m["node_id"], m["field"] + node = wf.get(nid) + if not isinstance(node, dict) or "inputs" not in node: + warnings.append(f"node '{nid}' for parameter '{name}' missing in workflow") + continue + # Refuse to overwrite a link with a literal — would silently break wiring + cur = node["inputs"].get(field) + if is_link(cur): + warnings.append( + f"parameter '{name}' targets {nid}.{field} which is currently a link; " + f"refusing to overwrite (set the schema to point at the source node instead)" + ) + continue + node["inputs"][field] = value + + return wf, warnings + + +# ============================================================================= +# Output download helper +# ============================================================================= + +def download_outputs( + runner: ComfyRunner, outputs: dict, output_dir: Path, + *, preserve_subfolder: bool = True, overwrite: bool = False, +) -> list[dict]: + """Walk the outputs dict and download every file. Cloud uses `video` (singular); + local uses `videos` (plural). We accept both.""" + output_dir.mkdir(parents=True, exist_ok=True) + downloaded: list[dict] = [] + + OUTPUT_KEYS = ("images", "gifs", "videos", "video", "audio", "files", "models", "3d") + + for node_id, node_output in (outputs or {}).items(): + if not isinstance(node_output, dict): + continue + for key in OUTPUT_KEYS: + entries = node_output.get(key) + if not entries: + continue + if not isinstance(entries, list): + entries = [entries] + for fi in entries: + if not isinstance(fi, dict): + continue + filename = fi.get("filename") or "" + if not filename: + continue + subfolder = fi.get("subfolder") or "" + file_type = fi.get("type") or "output" + try: + out_path = runner.download_output( + filename=filename, subfolder=subfolder, file_type=file_type, + output_dir=output_dir, preserve_subfolder=preserve_subfolder, + overwrite=overwrite, + ) + downloaded.append({ + "file": str(out_path), + "node_id": node_id, + "type": media_type_from_filename(filename), + "filename": filename, + "subfolder": subfolder, + "source_type": file_type, + }) + except Exception as e: + log(f"WARN: failed to download {filename}: {e}") + return downloaded + + +# ============================================================================= +# CLI +# ============================================================================= + +def parse_input_image_arg(spec: str) -> tuple[str, Path]: + """Parse `name=path` (or `path` alone, defaulting to name='image').""" + if "=" in spec: + name, path = spec.split("=", 1) + return name.strip(), Path(path).expanduser() + return "image", Path(spec).expanduser() + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Run a ComfyUI workflow with parameter injection.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument("--workflow", required=True, help="Path to workflow API JSON file") + p.add_argument("--args", default="{}", + help="JSON parameters to inject (or `@/path/to/args.json`)") + p.add_argument("--schema", help="Path to schema JSON (auto-generated if omitted)") + p.add_argument("--host", default=DEFAULT_LOCAL_HOST, help="ComfyUI server URL") + p.add_argument("--api-key", + help=f"API key for cloud (or set ${ENV_API_KEY} env var)") + p.add_argument("--partner-key", + help="Partner-node API key (extra_data.api_key_comfy_org). " + "Required for Flux Pro / Ideogram / etc. Defaults to --api-key if not set.") + p.add_argument("--output-dir", default="./outputs", help="Directory to save outputs") + p.add_argument("--timeout", type=int, default=0, + help="Max seconds to wait (0=auto: 300 / 900 for video workflows)") + p.add_argument("--input-image", action="append", default=[], + help="Upload local image before running. Format: `name=path` or `path`. " + "The `name` becomes the value injected into the matching schema parameter.") + p.add_argument("--randomize-seed", action="store_true", + help="If schema has a 'seed' parameter and --args didn't set one, randomize it") + p.add_argument("--ws", action="store_true", + help="Use WebSocket for real-time progress (requires `websocket-client`)") + p.add_argument("--no-download", action="store_true", help="Skip downloading outputs") + p.add_argument("--flat-output", action="store_true", + help="Don't preserve server-side subfolder structure when saving outputs") + p.add_argument("--overwrite", action="store_true", + help="Overwrite existing files instead of appending _1, _2, ...") + p.add_argument("--submit-only", action="store_true", + help="Submit and return prompt_id without waiting") + p.add_argument("--client-id", help="Override generated client_id (UUID)") + p.add_argument("--use-partner-key-as-auth", action="store_true", + help="(Compat) Use --partner-key value as cloud X-API-Key. Don't use unless you know why.") + + args = p.parse_args(argv) + + # ---- Load workflow ---- + wf_path = Path(args.workflow).expanduser() + if not wf_path.exists(): + emit_json({"error": f"Workflow file not found: {args.workflow}"}) + return 1 + try: + with wf_path.open() as f: + workflow_raw = json.load(f) + workflow = unwrap_workflow(workflow_raw) + except ValueError as e: + emit_json({"error": str(e)}) + return 1 + except json.JSONDecodeError as e: + emit_json({"error": f"Invalid JSON in workflow file: {e}"}) + return 1 + + # ---- Parse user args ---- + args_str = args.args + if args_str.startswith("@"): + try: + args_str = Path(args_str[1:]).read_text() + except OSError as e: + emit_json({"error": f"Cannot read args file: {e}"}) + return 1 + try: + user_args = json.loads(args_str) if args_str.strip() else {} + except json.JSONDecodeError as e: + emit_json({"error": f"Invalid --args JSON: {e}"}) + return 1 + if not isinstance(user_args, dict): + emit_json({"error": "--args must be a JSON object"}) + return 1 + + # ---- Resolve API key ---- + api_key = resolve_api_key(args.api_key) + partner_key = args.partner_key or None + if args.use_partner_key_as_auth and not api_key and partner_key: + api_key = partner_key + + # ---- Connect ---- + runner = ComfyRunner( + host=args.host, api_key=api_key, partner_key=partner_key, + client_id=args.client_id, + ) + + # Server reachability + ok, info = runner.check_server() + if not ok: + emit_json({ + "error": f"Cannot reach server at {args.host}", + "details": info, + "hint": ( + "Check `comfy launch --background` is running for local, " + f"or set ${ENV_API_KEY} for cloud." + ), + }) + return 1 + + # ---- Upload input images ---- + upload_warnings: list[str] = [] + for spec in args.input_image: + try: + param_name, path = parse_input_image_arg(spec) + except Exception as e: + emit_json({"error": f"Bad --input-image spec '{spec}': {e}"}) + return 1 + try: + ref = runner.upload_image(path) + except Exception as e: + emit_json({"error": f"Upload failed for {path}: {e}"}) + return 1 + # Register as a user arg so inject_params consumes it through the schema + uploaded_name = ref.get("name") or path.name + if param_name not in user_args: + user_args[param_name] = uploaded_name + + # ---- Inject params ---- + schema = load_schema(args.schema, workflow) + workflow, inj_warnings = inject_params( + workflow, schema, user_args, randomize_seed_if_unset=args.randomize_seed, + ) + warnings = upload_warnings + inj_warnings + for w in warnings: + log(f"WARN: {w}") + + # ---- Submit ---- + submit_resp = runner.submit(workflow) + if "_http_error" in submit_resp: + emit_json({ + "error": "Submission HTTP error", + "http_status": submit_resp["_http_error"], + "body": submit_resp.get("body"), + }) + return 1 + + if isinstance(submit_resp.get("error"), dict): + emit_json({ + "error": "Workflow validation failed", + "details": submit_resp["error"], + "node_errors": submit_resp.get("node_errors"), + }) + return 1 + + prompt_id = submit_resp.get("prompt_id") + if not prompt_id: + emit_json({"error": "No prompt_id in submit response", "response": submit_resp}) + return 1 + + node_errors = submit_resp.get("node_errors") or {} + if node_errors: + emit_json({"error": "Workflow validation failed", "node_errors": node_errors}) + return 1 + + if args.submit_only: + emit_json({"status": "submitted", "prompt_id": prompt_id, "warnings": warnings}) + return 0 + + # ---- Wait ---- + timeout = args.timeout + if timeout <= 0: + timeout = 900 if looks_like_video_workflow(workflow) else 300 + + log(f"Submitted: prompt_id={prompt_id}, waiting (timeout={timeout}s)…") + + def _on_progress(evt: dict) -> None: + t = evt.get("type") + if t == "progress": + log(f" step {evt.get('value')}/{evt.get('max')} on node {evt.get('node')}") + elif t == "executing": + node = evt.get("node") + if node: + log(f" executing node {node}") + + try: + if args.ws: + wait_result = runner.monitor_ws(prompt_id, timeout=timeout, on_progress=_on_progress) + else: + wait_result = runner.poll_status(prompt_id, timeout=timeout) + except KeyboardInterrupt: + log(f"Interrupted — cancelling job {prompt_id} on server…") + try: + runner.cancel(prompt_id) + except Exception as e: + log(f" (cancel request failed: {e})") + emit_json({ + "status": "interrupted", + "prompt_id": prompt_id, + "note": "Ctrl+C received; sent cancellation to server.", + }) + return 130 + + if wait_result["status"] == "timeout": + emit_json({ + "status": "timeout", + "prompt_id": prompt_id, + "elapsed": wait_result.get("elapsed"), + "hint": "Re-run with larger --timeout, or use --submit-only and check later.", + }) + return 1 + if wait_result["status"] == "error": + emit_json({"status": "error", "prompt_id": prompt_id, "details": wait_result.get("data")}) + return 1 + if wait_result["status"] == "cancelled": + emit_json({"status": "cancelled", "prompt_id": prompt_id}) + return 1 + + # ---- Outputs ---- + outputs = wait_result.get("outputs") + if not outputs: + outputs = runner.get_outputs(prompt_id) + + if args.no_download: + emit_json({ + "status": "success", "prompt_id": prompt_id, + "outputs": outputs, "warnings": warnings, + }) + return 0 + + downloaded = download_outputs( + runner, outputs, Path(args.output_dir).expanduser(), + preserve_subfolder=not args.flat_output, overwrite=args.overwrite, + ) + + emit_json({ + "status": "success", + "prompt_id": prompt_id, + "outputs": downloaded, + "warnings": warnings, + }) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/image-creation/comfyui/scripts/ws_monitor.py b/image-creation/comfyui/scripts/ws_monitor.py new file mode 100644 index 0000000..e2b6689 --- /dev/null +++ b/image-creation/comfyui/scripts/ws_monitor.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +""" +ws_monitor.py — Real-time ComfyUI WebSocket monitor. + +Connects to /ws and pretty-prints execution events: node start/finish, sampling +progress, cached nodes, errors. Optionally writes preview frames to disk. + +Useful for: + - Watching a long-running job in real time without parsing JSON yourself + - Saving in-progress preview frames for video / animation workflows + - Debugging "why is this hanging?" — see exactly which node is stuck + +Usage: + # Local — watch all jobs from this client_id + python3 ws_monitor.py + + # Cloud — watch a specific prompt_id + python3 ws_monitor.py --host https://cloud.comfy.org \ + --prompt-id abc-123-def + + # Save preview frames to ./previews/ + python3 ws_monitor.py --previews ./previews + +Requires: websocket-client (`pip install websocket-client`). +Falls back to a clear error message when not installed. +""" + +from __future__ import annotations + +import argparse +import json +import struct +import sys +from pathlib import Path +from urllib.parse import urlparse + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _common import ( # noqa: E402 + DEFAULT_LOCAL_HOST, ENV_API_KEY, log, new_client_id, resolve_api_key, is_cloud_host, +) + + +# Binary frame types from ComfyUI WebSocket protocol +BINARY_PREVIEW_IMAGE = 1 +BINARY_TEXT = 3 +BINARY_PREVIEW_IMAGE_WITH_METADATA = 4 + +# Image type codes inside PREVIEW_IMAGE +IMAGE_TYPE_JPEG = 1 +IMAGE_TYPE_PNG = 2 + +# ANSI escape codes (works on most modern terminals) +RESET = "\033[0m" +DIM = "\033[2m" +BOLD = "\033[1m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +RED = "\033[31m" +CYAN = "\033[36m" + + +def fmt_color(s: str, color: str, *, color_on: bool = True) -> str: + return f"{color}{s}{RESET}" if color_on else s + + +def parse_binary_frame(data: bytes) -> dict | None: + if len(data) < 8: + return None + type_code = struct.unpack(">I", data[0:4])[0] + if type_code == BINARY_PREVIEW_IMAGE: + image_type = struct.unpack(">I", data[4:8])[0] + ext = "jpg" if image_type == IMAGE_TYPE_JPEG else "png" if image_type == IMAGE_TYPE_PNG else "bin" + return { + "kind": "preview", + "image_type": image_type, + "ext": ext, + "image_bytes": data[8:], + } + if type_code == BINARY_PREVIEW_IMAGE_WITH_METADATA: + if len(data) < 12: + return None + meta_len = struct.unpack(">I", data[4:8])[0] + meta_end = 8 + meta_len + if len(data) < meta_end: + return None + try: + meta = json.loads(data[8:meta_end].decode("utf-8")) + except Exception: + meta = {"raw": data[8:meta_end][:200].decode("utf-8", "replace")} + return { + "kind": "preview_with_metadata", + "metadata": meta, + "image_bytes": data[meta_end:], + "ext": "png", + } + if type_code == BINARY_TEXT: + if len(data) < 8: + return None + nid_len = struct.unpack(">I", data[4:8])[0] + nid_end = 8 + nid_len + if len(data) < nid_end: + return None + return { + "kind": "text", + "node_id": data[8:nid_end].decode("utf-8", "replace"), + "text": data[nid_end:].decode("utf-8", "replace"), + } + return {"kind": "unknown", "type_code": type_code, "size": len(data)} + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Real-time ComfyUI WebSocket monitor") + p.add_argument("--host", default=DEFAULT_LOCAL_HOST, help="ComfyUI server URL") + p.add_argument("--api-key", help=f"API key for cloud (or set ${ENV_API_KEY} env var)") + p.add_argument("--client-id", default=None, help="Client ID (default: random UUID)") + p.add_argument("--prompt-id", default=None, + help="Filter to a specific prompt_id (default: all jobs)") + p.add_argument("--previews", default=None, + help="Directory to save in-progress preview frames") + p.add_argument("--no-color", action="store_true", help="Disable ANSI colour") + p.add_argument("--timeout", type=float, default=600.0, + help="Hard cap on monitor duration (default 600s)") + args = p.parse_args(argv) + + try: + import websocket # type: ignore[import-not-found] + except ImportError: + print(json.dumps({ + "error": "websocket-client not installed", + "install": "pip install websocket-client", + })) + return 1 + + api_key = resolve_api_key(args.api_key) + cloud = is_cloud_host(args.host) + client_id = args.client_id or new_client_id() + + # Build WS URL preserving any base-path component (e.g. behind reverse proxy). + parsed = urlparse(args.host if "://" in args.host else f"http://{args.host}") + scheme = "wss" if parsed.scheme == "https" else "ws" + netloc = parsed.netloc + base_path = parsed.path.rstrip("/") + ws_url = f"{scheme}://{netloc}{base_path}/ws?clientId={client_id}" + if cloud and api_key: + ws_url += f"&token={api_key}" + + color_on = not args.no_color and sys.stdout.isatty() + + preview_dir = Path(args.previews).expanduser() if args.previews else None + if preview_dir: + preview_dir.mkdir(parents=True, exist_ok=True) + log(f"Saving previews to {preview_dir}") + + log(f"Connecting to {ws_url} (client_id={client_id})") + if args.prompt_id: + log(f"Filtering messages to prompt_id={args.prompt_id}") + + ws = websocket.create_connection(ws_url, timeout=args.timeout) + ws.settimeout(args.timeout) + + preview_counter = 0 + try: + while True: + try: + msg = ws.recv() + except websocket.WebSocketTimeoutException: + log(f"Idle for {args.timeout}s — exiting") + return 0 + if isinstance(msg, bytes): + parsed = parse_binary_frame(msg) + if parsed is None: + continue + if parsed["kind"] in {"preview", "preview_with_metadata"} and preview_dir: + img_bytes = parsed.get("image_bytes", b"") + if img_bytes: + ext = parsed.get("ext", "png") + out = preview_dir / f"preview_{preview_counter:05d}.{ext}" + out.write_bytes(img_bytes) + preview_counter += 1 + log(f" [preview] saved {out.name} ({len(img_bytes)} bytes)") + continue + + try: + payload = json.loads(msg) + except Exception: + continue + mtype = payload.get("type", "") + mdata = payload.get("data", {}) or {} + pid = mdata.get("prompt_id") + + if args.prompt_id and pid and pid != args.prompt_id: + continue + + if mtype == "status": + qr = mdata.get("status", {}).get("exec_info", {}).get("queue_remaining", "?") + print(fmt_color(f"[status] queue_remaining={qr}", DIM, color_on=color_on)) + elif mtype == "execution_start": + print(fmt_color(f"[start] prompt_id={pid}", BOLD, color_on=color_on)) + elif mtype == "executing": + node = mdata.get("node") + if node: + print(fmt_color(f" [executing] node={node}", CYAN, color_on=color_on)) + else: + print(fmt_color(f" [executing] (workflow done) prompt_id={pid}", DIM, color_on=color_on)) + elif mtype == "progress": + v, m = mdata.get("value", 0), mdata.get("max", 0) + pct = (v / m * 100) if m else 0 + print(f" [progress] {v}/{m} ({pct:5.1f}%) node={mdata.get('node')}") + elif mtype == "progress_state": + # Newer extended progress message + nodes = mdata.get("nodes") or {} + running = [k for k, v in nodes.items() if v.get("running")] + if running: + print(fmt_color(f" [progress_state] running={running}", DIM, color_on=color_on)) + elif mtype == "executed": + node = mdata.get("node") + out = mdata.get("output") or {} + summary_parts = [] + for key in ("images", "video", "videos", "gifs", "audio", "files"): + if out.get(key): + summary_parts.append(f"{key}={len(out[key])}") + summary = ", ".join(summary_parts) if summary_parts else "(no files)" + print(fmt_color(f" [executed] node={node} {summary}", GREEN, color_on=color_on)) + elif mtype == "execution_cached": + cached = mdata.get("nodes") or [] + if cached: + print(fmt_color(f" [cached] {len(cached)} nodes skipped", DIM, color_on=color_on)) + elif mtype == "execution_success": + print(fmt_color(f"[success] prompt_id={pid}", GREEN + BOLD, color_on=color_on)) + if args.prompt_id: + return 0 + elif mtype == "execution_error": + exc_type = mdata.get("exception_type", "?") + exc_msg = mdata.get("exception_message", "?") + print(fmt_color(f"[error] {exc_type}: {exc_msg}", RED + BOLD, color_on=color_on)) + tb = mdata.get("traceback") + if tb: + if isinstance(tb, list): + for line in tb: + print(fmt_color(f" {line}", RED, color_on=color_on)) + else: + print(fmt_color(f" {tb}", RED, color_on=color_on)) + if args.prompt_id: + return 1 + elif mtype == "execution_interrupted": + print(fmt_color(f"[interrupted] prompt_id={pid}", YELLOW, color_on=color_on)) + if args.prompt_id: + return 1 + elif mtype == "notification": + v = mdata.get("value", "") + print(fmt_color(f"[notification] {v}", DIM, color_on=color_on)) + else: + # Unknown / lightly-used types: print compactly + print(fmt_color(f"[{mtype}] {json.dumps(mdata, default=str)[:200]}", DIM, color_on=color_on)) + + except KeyboardInterrupt: + log("Interrupted") + return 130 + finally: + try: + ws.close() + except Exception: + pass + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/image-creation/comfyui/tests/README.md b/image-creation/comfyui/tests/README.md new file mode 100644 index 0000000..833632a --- /dev/null +++ b/image-creation/comfyui/tests/README.md @@ -0,0 +1,50 @@ +# ComfyUI Skill Tests + +Pytest suite covering the skill's scripts. Pure-stdlib unit tests run +without any setup; cloud integration tests need a Comfy Cloud API key. + +## Running + +```bash +# Unit tests only (no network required) — runs in <1s +python3 -m pytest tests/ -c tests/pytest.ini -o addopts="-p no:xdist" + +# Including cloud integration tests +COMFY_CLOUD_API_KEY="comfyui-..." python3 -m pytest tests/ \ + -c tests/pytest.ini -o addopts="-p no:xdist" + +# Just cloud tests +COMFY_CLOUD_API_KEY="comfyui-..." python3 -m pytest tests/test_cloud_integration.py \ + -c tests/pytest.ini -o addopts="-p no:xdist" -v +``` + +The `-c` and `-o` overrides isolate this suite from any parent +`pyproject.toml` pytest config (e.g. the `-n auto` from a parent repo). + +## Test files + +| File | Coverage | +|------|----------| +| `test_common.py` | Cloud detection, URL routing, format validation, embeddings, paths, seeds, model-list parsing, folder aliases | +| `test_extract_schema.py` | Connection tracing, positive/negative prompt detection, dedup logic, embedding deps | +| `test_run_workflow.py` | Param injection (incl. -1 seed, link refusal), output download walk, runner construction | +| `test_check_deps.py` | Model-name fuzzy matching, install command suggestions | +| `test_cloud_integration.py` | Live cloud API contract tests (auto-skipped without API key) | + +## Adding tests + +When you change a script: + +1. Add a unit test if the change is pure logic (cloud detection, parsing, etc.) +2. Add a cloud integration test if the change depends on cloud API behavior + (use `pytestmark = pytest.mark.cloud` so it auto-skips without a key) +3. Workflow fixtures live in `conftest.py` (`sd15_workflow`, `flux_workflow`, + `video_workflow`) + +## Why the explicit `-c` / `-o`? + +The parent hermes-agent repo's `pyproject.toml` enables `pytest-xdist` by +default (`-n auto`). This suite is small enough that parallelism isn't +worth the complexity, and pytest-xdist isn't always installed in the user's +environment. The `-c tests/pytest.ini -o addopts="-p no:xdist"` flags make +the suite run identically regardless of the parent project's config. diff --git a/image-creation/comfyui/tests/conftest.py b/image-creation/comfyui/tests/conftest.py new file mode 100644 index 0000000..a800fa7 --- /dev/null +++ b/image-creation/comfyui/tests/conftest.py @@ -0,0 +1,64 @@ +"""Pytest configuration for the comfyui skill test suite. + +Adds `scripts/` to sys.path so tests can `from _common import ...`, and +provides a few common fixtures. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +SCRIPTS = ROOT / "scripts" +WORKFLOWS = ROOT / "workflows" + +sys.path.insert(0, str(SCRIPTS)) + + +@pytest.fixture +def sd15_workflow() -> dict: + return json.loads((WORKFLOWS / "sd15_txt2img.json").read_text()) + + +@pytest.fixture +def flux_workflow() -> dict: + return json.loads((WORKFLOWS / "flux_dev_txt2img.json").read_text()) + + +@pytest.fixture +def video_workflow() -> dict: + return json.loads((WORKFLOWS / "wan_video_t2v.json").read_text()) + + +@pytest.fixture +def workflows_dir() -> Path: + return WORKFLOWS + + +@pytest.fixture +def scripts_dir() -> Path: + return SCRIPTS + + +@pytest.fixture +def cloud_key() -> str | None: + """Cloud API key if set, otherwise None. + + Tests that need cloud connectivity should skip when this is None. + """ + return os.environ.get("COMFY_CLOUD_API_KEY") + + +def pytest_collection_modifyitems(config, items): + """Auto-skip cloud tests when no API key is set.""" + if os.environ.get("COMFY_CLOUD_API_KEY"): + return + skip_cloud = pytest.mark.skip(reason="Set COMFY_CLOUD_API_KEY to run cloud tests") + for item in items: + if "cloud" in item.keywords: + item.add_marker(skip_cloud) diff --git a/image-creation/comfyui/tests/pytest.ini b/image-creation/comfyui/tests/pytest.ini new file mode 100644 index 0000000..2111fe2 --- /dev/null +++ b/image-creation/comfyui/tests/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +markers = + cloud: tests that hit live Comfy Cloud API (require COMFY_CLOUD_API_KEY) +testpaths = . +addopts = -p no:xdist diff --git a/image-creation/comfyui/tests/test_check_deps.py b/image-creation/comfyui/tests/test_check_deps.py new file mode 100644 index 0000000..30116a7 --- /dev/null +++ b/image-creation/comfyui/tests/test_check_deps.py @@ -0,0 +1,68 @@ +"""Tests for check_deps.py — focuses on parsing logic that doesn't need a server.""" + +from __future__ import annotations + +from check_deps import ( + NODE_TO_PACKAGE, + model_present, + normalize_for_match, + suggest_install_command, +) + + +class TestNormalizeForMatch: + def test_basic(self): + s = normalize_for_match("model.safetensors") + assert "model.safetensors" in s + assert "model" in s + + def test_subfolder(self): + s = normalize_for_match("subdir/model.pt") + assert "subdir/model.pt" in s + assert "model.pt" in s + assert "model" in s + + +class TestModelPresent: + def test_exact_match(self): + assert model_present("a.safetensors", {"a.safetensors", "b.safetensors"}) is True + + def test_extension_difference(self): + # User said "model" but installed is "model.safetensors" + assert model_present("model", {"model.safetensors"}) is True + # Reverse direction — also matches + assert model_present("model.safetensors", {"model"}) is True + + def test_subfolder_match(self): + # Installed list has "subdir/model.safetensors", workflow asks "model.safetensors" + assert model_present("model.safetensors", {"subdir/model.safetensors"}) is True + + def test_missing(self): + assert model_present("missing.safetensors", {"a.safetensors", "b.safetensors"}) is False + + def test_empty_installed(self): + assert model_present("anything.safetensors", set()) is False + + +class TestSuggestInstallCommand: + def test_known_node(self): + cmd = suggest_install_command("VHS_VideoCombine") + assert cmd == "comfy node install comfyui-videohelpersuite" + + def test_unknown_node(self): + assert suggest_install_command("SomeRandomNodeName123") is None + + +class TestNodePackageMap: + def test_no_duplicates(self): + # Each node should map to exactly one package + keys = list(NODE_TO_PACKAGE.keys()) + assert len(keys) == len(set(keys)) + + def test_packages_are_safe_for_shell(self): + # Registry slugs must be alphanumerics + hyphens/underscores only + # (passed straight to `comfy node install `). + import re + safe = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]*$") + for pkg in NODE_TO_PACKAGE.values(): + assert safe.match(pkg), f"Unsafe package slug: {pkg!r}" diff --git a/image-creation/comfyui/tests/test_cloud_integration.py b/image-creation/comfyui/tests/test_cloud_integration.py new file mode 100644 index 0000000..0ce88ef --- /dev/null +++ b/image-creation/comfyui/tests/test_cloud_integration.py @@ -0,0 +1,95 @@ +"""Integration tests against the live Comfy Cloud API. + +These tests are auto-skipped when COMFY_CLOUD_API_KEY is not set. +They never SUBMIT workflows (would need a paid subscription) — they only +verify the read-only endpoints we rely on. +""" + +from __future__ import annotations + +import pytest + +from _common import http_get, parse_model_list, resolve_url + + +pytestmark = pytest.mark.cloud + + +class TestCloudEndpointsLive: + def test_system_stats_reachable(self, cloud_key): + url = resolve_url("https://cloud.comfy.org", "/system_stats") + r = http_get(url, headers={"X-API-Key": cloud_key}) + assert r.status == 200 + data = r.json() + assert "system" in data + + def test_models_endpoint_routed_to_experiment(self, cloud_key): + # We expect the skill to route /models/checkpoints → /api/experiment/models/checkpoints + url = resolve_url("https://cloud.comfy.org", "/models/checkpoints") + assert "/api/experiment/models/checkpoints" in url + r = http_get(url, headers={"X-API-Key": cloud_key}) + assert r.status == 200 + + def test_models_endpoint_returns_dicts(self, cloud_key): + url = resolve_url("https://cloud.comfy.org", "/models/checkpoints") + r = http_get(url, headers={"X-API-Key": cloud_key}) + data = r.json() + assert isinstance(data, list) + if data: + # Cloud format: list of dicts with `name` + assert isinstance(data[0], dict) + assert "name" in data[0] + # Our parser normalizes both + normalized = parse_model_list(data) + assert len(normalized) == len(data) + + def test_history_renamed_to_v2(self, cloud_key): + # /history → /api/history_v2 on cloud + url = resolve_url("https://cloud.comfy.org", "/history/some-fake-id") + assert "/api/history_v2/some-fake-id" in url + + def test_object_info_paid_tier(self, cloud_key): + # On free tier, /object_info returns 403 with a recognizable message + url = resolve_url("https://cloud.comfy.org", "/object_info") + r = http_get(url, headers={"X-API-Key": cloud_key}) + # Should be either 200 (paid) or 403 (free) — not 404 / 500 + assert r.status in {200, 403} + if r.status == 403: + # Body should mention the limitation + assert "free tier" in r.text().lower() or "subscription" in r.text().lower() + + +class TestCloudCheckDepsLive: + def test_check_deps_against_cloud(self, cloud_key, sd15_workflow): + from check_deps import check_deps + report = check_deps(sd15_workflow, host="https://cloud.comfy.org", api_key=cloud_key) + # Either node check passed OR was skipped (free tier) + assert "missing_models" in report + assert "is_cloud" in report and report["is_cloud"] is True + + def test_flux_workflow_models_resolved_via_aliases(self, cloud_key, flux_workflow): + """Flux uses unet/clip folders; cloud has them in diffusion_models/text_encoders. + With folder aliasing, the check should still find them.""" + from check_deps import check_deps + report = check_deps(flux_workflow, host="https://cloud.comfy.org", api_key=cloud_key) + # The exact required Flux files (flux1-dev.safetensors, t5xxl_fp16, clip_l, ae) + # are present on cloud; with folder aliasing, none should be missing. + # If this fails, either the cloud removed the model or the aliasing logic broke. + missing_filenames = {m["value"] for m in report["missing_models"]} + assert "ae.safetensors" not in missing_filenames, \ + "ae.safetensors should be on cloud's vae folder" + # t5xxl_fp16 / clip_l should be reachable via the clip → text_encoders alias + # flux1-dev.safetensors likewise via unet → diffusion_models + + +class TestHealthCheckLive: + def test_health_check_passes(self, cloud_key, capsys): + from health_check import main as health_main + rc = health_main(["--host", "https://cloud.comfy.org", "--api-key", cloud_key]) + captured = capsys.readouterr() + # Should produce JSON + import json + report = json.loads(captured.out) + assert report["server"]["reachable"] is True + assert report["checkpoints"]["queryable"] is True + assert report["checkpoints"]["count"] > 0 diff --git a/image-creation/comfyui/tests/test_common.py b/image-creation/comfyui/tests/test_common.py new file mode 100644 index 0000000..a5ce6a3 --- /dev/null +++ b/image-creation/comfyui/tests/test_common.py @@ -0,0 +1,443 @@ +"""Unit tests for _common.py — pure logic only, no network.""" + +from __future__ import annotations + + +import pytest + +from _common import ( + EMBEDDING_REGEX, + cloud_endpoint, + coerce_seed, + folder_aliases_for, + is_api_format, + is_cloud_host, + is_link, + iter_embedding_refs, + iter_model_deps, + iter_nodes, + looks_like_video_workflow, + media_type_from_filename, + parse_model_list, + resolve_url, + safe_path_join, + unwrap_workflow, +) + + +# ============================================================================= +# Cloud detection / URL routing +# ============================================================================= + +class TestCloudDetection: + def test_cloud_host_exact(self): + assert is_cloud_host("https://cloud.comfy.org") is True + assert is_cloud_host("https://cloud.comfy.org/foo/bar") is True + + def test_cloud_host_subdomain(self): + assert is_cloud_host("https://staging.cloud.comfy.org") is True + assert is_cloud_host("https://api.cloud.comfy.org") is True + + def test_local_not_cloud(self): + assert is_cloud_host("http://127.0.0.1:8188") is False + assert is_cloud_host("http://localhost:8188") is False + assert is_cloud_host("http://my-server.local:8188") is False + + def test_no_scheme(self): + # Defaults to http:// + assert is_cloud_host("cloud.comfy.org") is True + assert is_cloud_host("127.0.0.1:8188") is False + + +class TestCloudEndpointRename: + def test_history_renamed(self): + assert cloud_endpoint("/history") == "/history_v2" + assert cloud_endpoint("/history/abc-123") == "/history_v2/abc-123" + + def test_history_v2_preserved(self): + assert cloud_endpoint("/history_v2") == "/history_v2" + + def test_models_renamed(self): + assert cloud_endpoint("/models") == "/experiment/models" + assert cloud_endpoint("/models/checkpoints") == "/experiment/models/checkpoints" + assert cloud_endpoint("/models/loras") == "/experiment/models/loras" + + def test_other_paths_unchanged(self): + assert cloud_endpoint("/prompt") == "/prompt" + assert cloud_endpoint("/queue") == "/queue" + + +class TestResolveURL: + def test_local_no_prefix(self): + assert resolve_url("http://127.0.0.1:8188", "/prompt") == "http://127.0.0.1:8188/prompt" + + def test_cloud_adds_api_prefix(self): + assert resolve_url("https://cloud.comfy.org", "/prompt") == "https://cloud.comfy.org/api/prompt" + + def test_cloud_history_renamed(self): + assert resolve_url("https://cloud.comfy.org", "/history/abc") == "https://cloud.comfy.org/api/history_v2/abc" + + def test_cloud_models_renamed(self): + assert resolve_url("https://cloud.comfy.org", "/models/loras") == "https://cloud.comfy.org/api/experiment/models/loras" + + def test_cloud_already_has_api(self): + # Don't double-prefix + assert resolve_url("https://cloud.comfy.org", "/api/prompt") == "https://cloud.comfy.org/api/prompt" + + def test_trailing_slash_stripped(self): + assert resolve_url("http://127.0.0.1:8188/", "/prompt") == "http://127.0.0.1:8188/prompt" + + +# ============================================================================= +# Workflow validation +# ============================================================================= + +class TestAPIFormatDetection: + def test_valid_api(self, sd15_workflow): + assert is_api_format(sd15_workflow) is True + + def test_editor_format_rejected(self): + editor = {"nodes": [], "links": [], "version": 0.4} + assert is_api_format(editor) is False + + def test_empty_dict(self): + assert is_api_format({}) is False + + def test_non_dict(self): + assert is_api_format([]) is False + assert is_api_format(None) is False + assert is_api_format("string") is False + + def test_node_with_class_type(self): + wf = {"3": {"class_type": "KSampler", "inputs": {}}} + assert is_api_format(wf) is True + + +class TestUnwrapWorkflow: + def test_passthrough_api_format(self, sd15_workflow): + result = unwrap_workflow(sd15_workflow) + assert result is sd15_workflow + + def test_unwrap_prompt_key(self, sd15_workflow): + wrapped = {"prompt": sd15_workflow, "client_id": "abc"} + result = unwrap_workflow(wrapped) + assert result is sd15_workflow + + def test_editor_format_raises(self): + with pytest.raises(ValueError, match="editor format"): + unwrap_workflow({"nodes": [], "links": []}) + + def test_garbage_raises(self): + with pytest.raises(ValueError): + unwrap_workflow({"foo": "bar"}) + + +class TestIsLink: + def test_valid_link(self): + assert is_link(["3", 0]) is True + assert is_link(["10", 1]) is True + + def test_non_link(self): + assert is_link("string") is False + assert is_link(42) is False + assert is_link([]) is False + assert is_link(["3"]) is False # missing slot + assert is_link(["3", "0"]) is False # slot must be int + assert is_link([3, 0]) is False # node_id must be string + + +# ============================================================================= +# Workflow iterators +# ============================================================================= + +class TestIterators: + def test_iter_nodes(self, sd15_workflow): + nodes = dict(iter_nodes(sd15_workflow)) + assert "3" in nodes + assert nodes["3"]["class_type"] == "KSampler" + + def test_iter_nodes_skips_comments(self, sd15_workflow): + # _comment is not a node + nodes = dict(iter_nodes(sd15_workflow)) + assert "_comment" not in nodes + + def test_iter_model_deps(self, sd15_workflow): + deps = list(iter_model_deps(sd15_workflow)) + names = [d["value"] for d in deps] + assert "v1-5-pruned-emaonly.safetensors" in names + + def test_iter_model_deps_flux(self, flux_workflow): + deps = list(iter_model_deps(flux_workflow)) + names = {d["value"]: d["folder"] for d in deps} + assert names["flux1-dev.safetensors"] == "unet" + assert names["t5xxl_fp16.safetensors"] == "clip" + assert names["clip_l.safetensors"] == "clip" + assert names["ae.safetensors"] == "vae" + + +# ============================================================================= +# Embedding extraction +# ============================================================================= + +class TestEmbeddingRegex: + def test_basic_embedding(self): + m = EMBEDDING_REGEX.search("a cat, embedding:goodvibes, more text") + assert m is not None + assert m.group(1) == "goodvibes" + + def test_embedding_with_strength(self): + m = EMBEDDING_REGEX.search("embedding:bad-hands-5:1.2") + assert m is not None + assert m.group(1) == "bad-hands-5" + + def test_embedding_with_extension(self): + # Strips .pt / .safetensors / .bin + m = EMBEDDING_REGEX.search("embedding:my-emb.pt") + assert m is not None + assert m.group(1) == "my-emb" + + def test_embedding_in_parens(self): + m = EMBEDDING_REGEX.search("(embedding:foo:0.8)") + assert m is not None + assert m.group(1) == "foo" + + def test_multiple_in_one_string(self): + text = "a cat, embedding:foo:1.2, and embedding:bar" + matches = [m.group(1) for m in EMBEDDING_REGEX.finditer(text)] + assert matches == ["foo", "bar"] + + def test_no_false_positive_on_word_embedding(self): + # "embedding " (with space, no colon) should not match + m = EMBEDDING_REGEX.search("the embedding is great") + assert m is None + + +class TestIterEmbeddingRefs: + def test_finds_in_clip_text_encode(self): + wf = { + "1": {"class_type": "CLIPTextEncode", + "inputs": {"text": "embedding:foo, embedding:bar:0.5", "clip": ["2", 0]}}, + "2": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}}, + } + refs = list(iter_embedding_refs(wf)) + names = [name for _, name in refs] + assert names == ["foo", "bar"] + + def test_ignores_non_prompt_fields(self): + wf = { + "1": {"class_type": "CheckpointLoaderSimple", + "inputs": {"ckpt_name": "embedding:foo.safetensors"}}, + } + refs = list(iter_embedding_refs(wf)) + # ckpt_name is not a prompt field — ignored + assert refs == [] + + +# ============================================================================= +# Path safety +# ============================================================================= + +class TestSafePathJoin: + def test_normal_join(self, tmp_path): + p = safe_path_join(tmp_path, "subdir", "file.png") + assert p.is_relative_to(tmp_path) + + def test_blocks_traversal(self, tmp_path): + with pytest.raises(ValueError, match="path traversal"): + safe_path_join(tmp_path, "..", "..", "etc", "passwd") + + def test_blocks_absolute(self, tmp_path): + with pytest.raises(ValueError): + safe_path_join(tmp_path, "/etc/passwd") + + def test_subfolder_with_filename(self, tmp_path): + p = safe_path_join(tmp_path, "outputs", "img.png") + assert p.name == "img.png" + assert p.parent.name == "outputs" + + +# ============================================================================= +# Seed coercion +# ============================================================================= + +class TestCoerceSeed: + def test_explicit_int(self): + assert coerce_seed(42) == 42 + assert coerce_seed(0) == 0 + + def test_minus_one_randomizes(self): + s = coerce_seed(-1) + assert isinstance(s, int) + assert 0 <= s < 2**63 + + def test_none_randomizes(self): + s = coerce_seed(None) + assert isinstance(s, int) + + def test_string_int(self): + # str() that converts cleanly is allowed (relaxed) + assert coerce_seed("12345") == 12345 + + def test_string_minus_one_randomizes(self): + # CLI / JSON sometimes carries seed as a string. + s = coerce_seed("-1") + assert isinstance(s, int) + assert 0 <= s < 2**63 + # And whitespace tolerated + s2 = coerce_seed(" -1 ") + assert isinstance(s2, int) + assert 0 <= s2 < 2**63 + + +# ============================================================================= +# Model list normalization (cloud format) +# ============================================================================= + +class TestParseModelList: + def test_local_format_strings(self): + result = parse_model_list(["a.safetensors", "b.safetensors"]) + assert result == {"a.safetensors", "b.safetensors"} + + def test_cloud_format_dicts(self): + result = parse_model_list([ + {"name": "a.safetensors", "pathIndex": 0}, + {"name": "b.safetensors", "pathIndex": 1}, + ]) + assert result == {"a.safetensors", "b.safetensors"} + + def test_empty(self): + assert parse_model_list([]) == set() + + def test_garbage(self): + assert parse_model_list("not a list") == set() + assert parse_model_list(None) == set() + + def test_mixed_format(self): + result = parse_model_list([ + "string-form.safetensors", + {"name": "dict-form.safetensors"}, + ]) + assert result == {"string-form.safetensors", "dict-form.safetensors"} + + +# ============================================================================= +# Folder aliases +# ============================================================================= + +class TestFolderAliases: + def test_unet_aliases_diffusion_models(self): + aliases = folder_aliases_for("unet") + assert "unet" in aliases + assert "diffusion_models" in aliases + + def test_clip_aliases_text_encoders(self): + aliases = folder_aliases_for("clip") + assert "clip" in aliases + assert "text_encoders" in aliases + + def test_unknown_folder_returns_self(self): + assert folder_aliases_for("checkpoints") == ["checkpoints"] + + def test_primary_first(self): + # Order matters: primary should be first for human-friendly fix hints + assert folder_aliases_for("unet")[0] == "unet" + assert folder_aliases_for("diffusion_models")[0] == "diffusion_models" + + +# ============================================================================= +# Media-type detection +# ============================================================================= + +class TestMediaType: + def test_video_extensions(self): + assert media_type_from_filename("vid.mp4") == "video" + assert media_type_from_filename("foo.webm") == "video" + assert media_type_from_filename("bar.gif") == "video" + + def test_audio_extensions(self): + assert media_type_from_filename("song.wav") == "audio" + assert media_type_from_filename("music.mp3") == "audio" + + def test_image_default(self): + assert media_type_from_filename("pic.png") == "image" + assert media_type_from_filename("image.jpg") == "image" + assert media_type_from_filename("unknown.xyz") == "image" + + def test_3d(self): + assert media_type_from_filename("model.glb") == "3d" + assert media_type_from_filename("scene.gltf") == "3d" + + +# ============================================================================= +# Cross-host header stripping (security) +# ============================================================================= + +class TestRedirectHeaderStripping: + """Verify X-API-Key is dropped when redirect crosses to a different host + (e.g. cloud /api/view → S3 signed URL). Critical to prevent leaking auth + tokens to the storage backend. + """ + + def _build_session(self): + from _common import _StripSensitiveOnRedirectSession, HAS_REQUESTS + if not HAS_REQUESTS: + import pytest + pytest.skip("requests not installed") + return _StripSensitiveOnRedirectSession() + + def test_strips_x_api_key_cross_host(self): + import requests + s = self._build_session() + prep = requests.PreparedRequest() + prep.prepare(method="GET", url="https://other.example.com/file", + headers={"X-API-Key": "leak", "Authorization": "Bearer x"}) + resp = requests.Response() + orig = requests.PreparedRequest() + orig.prepare(method="GET", url="https://cloud.comfy.org/api/view", headers={}) + resp.request = orig + s.rebuild_auth(prep, resp) + assert "X-API-Key" not in prep.headers + assert "Authorization" not in prep.headers + + def test_preserves_x_api_key_same_host(self): + import requests + s = self._build_session() + prep = requests.PreparedRequest() + prep.prepare(method="GET", url="https://cloud.comfy.org/foo", + headers={"X-API-Key": "keep"}) + resp = requests.Response() + orig = requests.PreparedRequest() + orig.prepare(method="GET", url="https://cloud.comfy.org/bar", headers={}) + resp.request = orig + s.rebuild_auth(prep, resp) + assert prep.headers.get("X-API-Key") == "keep" + + def test_strips_cookie_cross_host(self): + import requests + s = self._build_session() + prep = requests.PreparedRequest() + prep.prepare(method="GET", url="https://other.example.com/x", + headers={"Cookie": "session=secret"}) + resp = requests.Response() + orig = requests.PreparedRequest() + orig.prepare(method="GET", url="https://cloud.comfy.org/foo", headers={}) + resp.request = orig + s.rebuild_auth(prep, resp) + assert "Cookie" not in prep.headers + + +# ============================================================================= +# Video workflow detection +# ============================================================================= + +class TestVideoWorkflow: + def test_image_workflow(self, sd15_workflow): + assert looks_like_video_workflow(sd15_workflow) is False + + def test_animatediff_workflow(self, workflows_dir): + import json + wf = json.loads((workflows_dir / "animatediff_video.json").read_text()) + assert looks_like_video_workflow(wf) is True + + def test_wan_workflow(self, video_workflow): + assert looks_like_video_workflow(video_workflow) is True diff --git a/image-creation/comfyui/tests/test_extract_schema.py b/image-creation/comfyui/tests/test_extract_schema.py new file mode 100644 index 0000000..c4557ba --- /dev/null +++ b/image-creation/comfyui/tests/test_extract_schema.py @@ -0,0 +1,184 @@ +"""Tests for extract_schema.py.""" + +from __future__ import annotations + + +from extract_schema import ( + extract_schema, + find_negative_prompt_node, + find_positive_prompt_node, + trace_to_node, +) + + +# ============================================================================= +# Connection tracing +# ============================================================================= + +class TestConnectionTracing: + def test_direct_link(self): + wf = { + "1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}}, + "2": {"class_type": "KSampler", + "inputs": {"positive": ["1", 0], "negative": ["1", 0]}}, + } + assert trace_to_node(wf, ["1", 0]) == "1" + + def test_through_reroute(self): + wf = { + "1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}}, + "2": {"class_type": "Reroute", "inputs": {"input": ["1", 0]}}, + "3": {"class_type": "Reroute", "inputs": {"input": ["2", 0]}}, + } + assert trace_to_node(wf, ["3", 0]) == "1" + + def test_circular_safe(self): + wf = { + "1": {"class_type": "Reroute", "inputs": {"input": ["2", 0]}}, + "2": {"class_type": "Reroute", "inputs": {"input": ["1", 0]}}, + } + # Should hit max_hops without infinite loop + result = trace_to_node(wf, ["1", 0], max_hops=5) + assert result in {"1", "2"} # any node, just don't hang + + +class TestPositiveNegativeDetection: + def test_basic(self, sd15_workflow): + # In sd15_workflow.json node 6 is positive, node 7 is negative + assert find_positive_prompt_node(sd15_workflow) == "6" + assert find_negative_prompt_node(sd15_workflow) == "7" + + def test_swapped_order(self): + wf = { + "3": {"class_type": "KSampler", + "inputs": { + "positive": ["7", 0], "negative": ["6", 0], + "model": ["4", 0], "latent_image": ["5", 0], + "seed": 1, "steps": 20, "cfg": 7.5, + "sampler_name": "euler", "scheduler": "normal", "denoise": 1.0, + }}, + "4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}}, + "5": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}}, + "6": {"class_type": "CLIPTextEncode", "inputs": {"text": "ugly", "clip": ["4", 1]}}, + "7": {"class_type": "CLIPTextEncode", "inputs": {"text": "beautiful", "clip": ["4", 1]}}, + } + # Now 7 is the positive (despite higher node ID) + assert find_positive_prompt_node(wf) == "7" + assert find_negative_prompt_node(wf) == "6" + + +# ============================================================================= +# Schema extraction +# ============================================================================= + +class TestExtractSchema: + def test_basic_sd15(self, sd15_workflow): + schema = extract_schema(sd15_workflow) + params = schema["parameters"] + assert "prompt" in params + assert "negative_prompt" in params + assert "seed" in params + assert "steps" in params + assert "cfg" in params + assert "width" in params + assert "height" in params + + def test_prompt_value_correct(self, sd15_workflow): + schema = extract_schema(sd15_workflow) + # The positive prompt in the example is the landscape one + assert "landscape" in schema["parameters"]["prompt"]["value"] + assert "ugly" in schema["parameters"]["negative_prompt"]["value"] + + def test_model_dependencies(self, sd15_workflow): + schema = extract_schema(sd15_workflow) + deps = schema["model_dependencies"] + ckpts = [d["value"] for d in deps if d["folder"] == "checkpoints"] + assert "v1-5-pruned-emaonly.safetensors" in ckpts + + def test_output_nodes(self, sd15_workflow): + schema = extract_schema(sd15_workflow) + assert "9" in schema["output_nodes"] + + def test_summary(self, sd15_workflow): + schema = extract_schema(sd15_workflow) + s = schema["summary"] + assert s["has_negative_prompt"] is True + assert s["has_seed"] is True + assert s["is_video_workflow"] is False + assert s["parameter_count"] > 5 + + def test_flux_workflow(self, flux_workflow): + schema = extract_schema(flux_workflow) + # Flux uses RandomNoise for seed + assert schema["summary"]["has_seed"] is True + # Flux has only positive prompt (no negative encoder) + assert schema["summary"]["has_negative_prompt"] is False + + def test_video_detected(self, video_workflow): + schema = extract_schema(video_workflow) + assert schema["summary"]["is_video_workflow"] is True + + +class TestEmbeddingDeps: + def test_extract_from_prompt(self): + wf = { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}}, + "5": {"class_type": "EmptyLatentImage", + "inputs": {"width": 512, "height": 512, "batch_size": 1}}, + "6": {"class_type": "CLIPTextEncode", + "inputs": { + "text": "a cat, embedding:goodvibes, embedding:art:1.2", + "clip": ["1", 1] + }}, + "7": {"class_type": "CLIPTextEncode", + "inputs": { + "text": "ugly, embedding:badhands", + "clip": ["1", 1] + }}, + "3": {"class_type": "KSampler", + "inputs": { + "positive": ["6", 0], "negative": ["7", 0], + "model": ["1", 0], "latent_image": ["5", 0], + "seed": 1, "steps": 20, "cfg": 7.5, + "sampler_name": "euler", "scheduler": "normal", "denoise": 1.0, + }}, + "9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x", "images": ["3", 0]}}, + } + schema = extract_schema(wf) + names = [d["embedding_name"] for d in schema["embedding_dependencies"]] + assert sorted(names) == ["art", "badhands", "goodvibes"] + + +class TestDuplicateDeduplication: + def test_two_ksamplers_get_unique_names(self): + wf = { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}}, + "5": {"class_type": "EmptyLatentImage", + "inputs": {"width": 512, "height": 512, "batch_size": 1}}, + "6": {"class_type": "CLIPTextEncode", "inputs": {"text": "a", "clip": ["1", 1]}}, + "7": {"class_type": "CLIPTextEncode", "inputs": {"text": "b", "clip": ["1", 1]}}, + "3": {"class_type": "KSampler", + "inputs": { + "positive": ["6", 0], "negative": ["7", 0], + "model": ["1", 0], "latent_image": ["5", 0], + "seed": 42, "steps": 20, "cfg": 7.5, + "sampler_name": "euler", "scheduler": "normal", "denoise": 1.0, + }}, + "4": {"class_type": "KSampler", + "inputs": { + "positive": ["6", 0], "negative": ["7", 0], + "model": ["1", 0], "latent_image": ["5", 0], + "seed": 99, "steps": 30, "cfg": 8.0, + "sampler_name": "euler", "scheduler": "normal", "denoise": 0.6, + }}, + "9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x", "images": ["3", 0]}}, + } + schema = extract_schema(wf) + params = schema["parameters"] + # Both seeds present with disambiguated names + seed_keys = [k for k in params if "seed" in k] + # Symmetric: both renamed (no bare "seed") + assert "seed" not in params + assert "seed_3" in params and "seed_4" in params + assert params["seed_3"]["value"] == 42 + assert params["seed_4"]["value"] == 99 diff --git a/image-creation/comfyui/tests/test_run_workflow.py b/image-creation/comfyui/tests/test_run_workflow.py new file mode 100644 index 0000000..69957dd --- /dev/null +++ b/image-creation/comfyui/tests/test_run_workflow.py @@ -0,0 +1,210 @@ +"""Tests for run_workflow.py — focuses on logic that doesn't require a server.""" + +from __future__ import annotations + + + +from extract_schema import extract_schema +from run_workflow import ( + ComfyRunner, + download_outputs, + inject_params, + parse_input_image_arg, +) + + +class TestParseInputImageArg: + def test_with_name(self, tmp_path): + f = tmp_path / "x.png" + f.write_text("x") + n, p = parse_input_image_arg(f"image={f}") + assert n == "image" + assert p == f + + def test_without_name_defaults(self, tmp_path): + f = tmp_path / "x.png" + f.write_text("x") + n, p = parse_input_image_arg(str(f)) + assert n == "image" + + def test_custom_name(self, tmp_path): + f = tmp_path / "x.png" + f.write_text("x") + n, p = parse_input_image_arg(f"mask_image={f}") + assert n == "mask_image" + + +class TestInjectParams: + def test_basic_injection(self, sd15_workflow): + schema = extract_schema(sd15_workflow) + wf, warnings = inject_params(sd15_workflow, schema, { + "prompt": "new prompt", + "seed": 999, + "steps": 25, + }) + assert wf["6"]["inputs"]["text"] == "new prompt" + assert wf["3"]["inputs"]["seed"] == 999 + assert wf["3"]["inputs"]["steps"] == 25 + assert warnings == [] + + def test_unknown_param_warns(self, sd15_workflow): + schema = extract_schema(sd15_workflow) + _, warnings = inject_params(sd15_workflow, schema, {"foobar": "x"}) + assert any("foobar" in w for w in warnings) + + def test_seed_minus_one_randomizes(self, sd15_workflow): + schema = extract_schema(sd15_workflow) + wf, warnings = inject_params(sd15_workflow, schema, {"seed": -1}) + assert wf["3"]["inputs"]["seed"] != -1 + assert isinstance(wf["3"]["inputs"]["seed"], int) + assert any("expanded" in w.lower() for w in warnings) + + def test_randomize_seed_when_unset(self, sd15_workflow): + schema = extract_schema(sd15_workflow) + original = sd15_workflow["3"]["inputs"]["seed"] + wf, warnings = inject_params(sd15_workflow, schema, {}, randomize_seed_if_unset=True) + assert wf["3"]["inputs"]["seed"] != original + assert isinstance(wf["3"]["inputs"]["seed"], int) + + def test_does_not_mutate_original(self, sd15_workflow): + schema = extract_schema(sd15_workflow) + original_text = sd15_workflow["6"]["inputs"]["text"] + inject_params(sd15_workflow, schema, {"prompt": "MUTATED"}) + assert sd15_workflow["6"]["inputs"]["text"] == original_text + + def test_refuses_to_overwrite_link(self): + wf = { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}}, + "5": {"class_type": "EmptyLatentImage", + "inputs": {"width": 512, "height": 512, "batch_size": 1}}, + "6": {"class_type": "CLIPTextEncode", + "inputs": {"text": ["3", 0], "clip": ["1", 1]}}, # text is a link! + "3": {"class_type": "KSampler", + "inputs": {"seed": 1, "steps": 20, "cfg": 7.5, + "sampler_name": "euler", "scheduler": "normal", "denoise": 1.0, + "model": ["1", 0], "positive": ["6", 0], "negative": ["6", 0], + "latent_image": ["5", 0]}}, + "9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x", "images": ["3", 0]}}, + } + # Manually create a schema that has prompt pointing at 6.text + schema = { + "parameters": { + "prompt": {"node_id": "6", "field": "text", "type": "string", "value": ""}, + } + } + wf2, warnings = inject_params(wf, schema, {"prompt": "literal value"}) + # The link should NOT have been overwritten + assert wf2["6"]["inputs"]["text"] == ["3", 0] + assert any("link" in w.lower() for w in warnings) + + +# ============================================================================= +# Output download walk +# ============================================================================= + +class TestDownloadOutputsWalk: + """Test that download_outputs walks the structure correctly.""" + + def test_handles_videos_plural(self, tmp_path, monkeypatch): + """Local ComfyUI uses 'videos'/'gifs' (plural) keys.""" + downloads = [] + + class FakeRunner: + def download_output(self, *, filename, subfolder, file_type, output_dir, preserve_subfolder, overwrite): + downloads.append((filename, subfolder, file_type)) + p = output_dir / filename + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"x") + return p + + outputs = { + "9": {"images": [{"filename": "img1.png", "subfolder": "", "type": "output"}]}, + "10": {"videos": [{"filename": "vid1.mp4", "subfolder": "", "type": "output"}]}, + "11": {"gifs": [{"filename": "anim1.gif", "subfolder": "", "type": "output"}]}, + } + + result = download_outputs(FakeRunner(), outputs, tmp_path) + files = sorted(d["filename"] for d in result) + assert files == ["anim1.gif", "img1.png", "vid1.mp4"] + + def test_handles_video_singular_cloud(self, tmp_path): + """Cloud uses 'video' (singular).""" + class FakeRunner: + def download_output(self, *, filename, subfolder, file_type, output_dir, preserve_subfolder, overwrite): + p = output_dir / filename + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"x") + return p + + outputs = { + "10": {"video": [{"filename": "cloud.mp4", "subfolder": "", "type": "output"}]}, + } + result = download_outputs(FakeRunner(), outputs, tmp_path) + assert len(result) == 1 + assert result[0]["filename"] == "cloud.mp4" + + def test_preserves_subfolder(self, tmp_path): + """When preserve_subfolder=True, server subfolder becomes local subdir.""" + class FakeRunner: + def download_output(self, *, filename, subfolder, file_type, output_dir, preserve_subfolder, overwrite): + if preserve_subfolder and subfolder: + p = output_dir / subfolder / filename + else: + p = output_dir / filename + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"x") + return p + + outputs = { + "9": {"images": [ + {"filename": "img.png", "subfolder": "myrun", "type": "output"}, + {"filename": "img.png", "subfolder": "otherrun", "type": "output"}, + ]}, + } + result = download_outputs(FakeRunner(), outputs, tmp_path, preserve_subfolder=True) + files = [d["file"] for d in result] + assert any("myrun" in f for f in files) + assert any("otherrun" in f for f in files) + # Both must exist (no collision) + assert len({str(f) for f in files}) == 2 + + +# ============================================================================= +# ComfyRunner construction +# ============================================================================= + +class TestRunnerConstruction: + def test_local_default(self): + r = ComfyRunner() + assert r.is_cloud is False + assert r.host == "http://127.0.0.1:8188" + + def test_cloud_detection(self): + r = ComfyRunner(host="https://cloud.comfy.org", api_key="abc") + assert r.is_cloud is True + assert "X-API-Key" in r.headers + + def test_cloud_subdomain_detected(self): + r = ComfyRunner(host="https://staging.cloud.comfy.org", api_key="abc") + assert r.is_cloud is True + + def test_partner_key_does_not_pollute_extra_data(self): + r = ComfyRunner(host="https://cloud.comfy.org", api_key="auth-key") + # No partner-key set → no extra_data should appear in submitted prompt + # (This is a static check; runtime check happens in submit()) + assert r.partner_key is None + + def test_url_routing_local(self): + r = ComfyRunner() + url = r._url("/prompt") + assert url == "http://127.0.0.1:8188/prompt" + + def test_url_routing_cloud(self): + r = ComfyRunner(host="https://cloud.comfy.org", api_key="x") + url = r._url("/prompt") + assert url == "https://cloud.comfy.org/api/prompt" + + def test_url_routing_cloud_history_renamed(self): + r = ComfyRunner(host="https://cloud.comfy.org", api_key="x") + url = r._url("/history/abc-123") + assert url == "https://cloud.comfy.org/api/history_v2/abc-123" diff --git a/image-creation/comfyui/workflows/README.md b/image-creation/comfyui/workflows/README.md new file mode 100644 index 0000000..f3f40c2 --- /dev/null +++ b/image-creation/comfyui/workflows/README.md @@ -0,0 +1,86 @@ +# Example Workflows + +These are starter API-format workflows for the most common tasks. They're +ready to run with `scripts/run_workflow.py` once you've installed (or have +cloud access to) the listed models. + +| File | Purpose | Required models | Min VRAM | +|------|---------|-----------------|----------| +| `sd15_txt2img.json` | SD 1.5 text-to-image (512×512) | SD1.5 checkpoint, e.g. `v1-5-pruned-emaonly.safetensors` | 4 GB | +| `sdxl_txt2img.json` | SDXL text-to-image (1024×1024) | `sd_xl_base_1.0.safetensors` | 8 GB | +| `flux_dev_txt2img.json` | Flux Dev text-to-image (1024×1024) | `flux1-dev.safetensors`, `t5xxl_fp16.safetensors`, `clip_l.safetensors`, `ae.safetensors` | 24 GB (or use `flux1-dev-fp8`) | +| `sdxl_img2img.json` | SDXL image-to-image | SDXL checkpoint | 8 GB | +| `sdxl_inpaint.json` | SDXL inpainting (image + mask) | SDXL checkpoint | 8 GB | +| `upscale_4x.json` | Standalone 4× ESRGAN upscale | `4x-UltraSharp.pth` (or any upscaler) | 4 GB | +| `animatediff_video.json` | AnimateDiff text-to-video (16 frames) | SD1.5 checkpoint, `mm_sd_v15_v2.ckpt` motion module | 8 GB | +| `wan_video_t2v.json` | Wan 2.x text-to-video (~33 frames) | `wan2.2_t2v_1.3B_fp16.safetensors`, `umt5_xxl_fp16.safetensors`, `wan_2.1_vae.safetensors` | 24 GB | + +## Quick start + +```bash +# Run a workflow with prompt injection +python3 ../scripts/run_workflow.py \ + --workflow sdxl_txt2img.json \ + --args '{"prompt": "majestic eagle in flight", "seed": 12345, "steps": 35}' \ + --output-dir ./out + +# Img2img: upload an input image first via the script's helper +python3 ../scripts/run_workflow.py \ + --workflow sdxl_img2img.json \ + --input-image image=./photo.png \ + --args '{"prompt": "make it watercolor", "denoise": 0.6}' \ + --output-dir ./out + +# Cloud (set API key once) +export COMFY_CLOUD_API_KEY="comfyui-..." +python3 ../scripts/run_workflow.py \ + --workflow flux_dev_txt2img.json \ + --args '{"prompt": "a fox in a misty forest"}' \ + --host https://cloud.comfy.org \ + --output-dir ./out + +# What can I tweak in this workflow? +python3 ../scripts/extract_schema.py sdxl_txt2img.json --summary-only + +# Are all required models / nodes installed? +python3 ../scripts/check_deps.py wan_video_t2v.json +``` + +## Notes + +- **Inpaint masks**: white pixels = "regenerate this region", black = preserve. + ComfyUI's `LoadImageMask` reads the **red channel** by default; export your + mask as a single-channel image or as a normal RGB where red==intensity. + +- **Denoise strength** in img2img: `0.0` = output identical to input, + `1.0` = ignore input entirely. Sweet spot is usually 0.4–0.7. + +- **Flux Dev** needs ~24 GB VRAM in its base form. The `flux1-dev-fp8.safetensors` + variant (already on Comfy Cloud) cuts that roughly in half. + +- **Video workflows** can take many minutes. The skill auto-detects video + output nodes and bumps the default timeout to 900s. Override with `--timeout 1800`. + +- These JSON files are deliberately **API format** (top-level keys are node IDs + with `class_type`), not editor format. To open them in ComfyUI's web UI for + visual editing, use `Workflow → Load (API Format)` or `Workflow → Open` and + follow the prompt. + +## Cloud vs local model names + +Comfy Cloud's preinstalled checkpoints sometimes have a `-fp16` suffix +(`v1-5-pruned-emaonly-fp16.safetensors`) while the canonical local download +keeps the original name (`v1-5-pruned-emaonly.safetensors`). The example +workflows use the local-canonical names. When running on cloud, override with: + +```bash +python3 ../scripts/run_workflow.py \ + --workflow sd15_txt2img.json \ + --args '{"ckpt_name": "v1-5-pruned-emaonly-fp16.safetensors", "prompt": "..."}' \ + --host https://cloud.comfy.org +``` + +The `ckpt_name`, `vae_name`, `lora_name`, `unet_name`, etc. are all exposed +as controllable parameters by `extract_schema.py` — discover what's installed +with `comfy model list` (local) or `curl /api/experiment/models/checkpoints` +(cloud). diff --git a/image-creation/comfyui/workflows/animatediff_video.json b/image-creation/comfyui/workflows/animatediff_video.json new file mode 100644 index 0000000..cc2b296 --- /dev/null +++ b/image-creation/comfyui/workflows/animatediff_video.json @@ -0,0 +1,64 @@ +{ + "_comment": "AnimateDiff text-to-video at 16 frames. Required: comfyui-animatediff-evolved + comfyui-videohelpersuite custom nodes; SD1.5 checkpoint; AnimateDiff motion module (e.g. mm_sd_v15_v2.ckpt in models/animatediff_models/). Outputs a webp animation.", + "3": { + "class_type": "KSampler", + "_meta": {"title": "KSampler"}, + "inputs": { + "seed": 42, "steps": 25, "cfg": 7.5, + "sampler_name": "dpmpp_sde", "scheduler": "karras", "denoise": 1.0, + "model": ["10", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["5", 0] + } + }, + "4": { + "class_type": "CheckpointLoaderSimple", + "_meta": {"title": "Checkpoint"}, + "inputs": {"ckpt_name": "v1-5-pruned-emaonly.safetensors"} + }, + "5": { + "class_type": "EmptyLatentImage", + "_meta": {"title": "Latent (16 frames)"}, + "inputs": {"width": 512, "height": 512, "batch_size": 16} + }, + "6": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Positive Prompt"}, + "inputs": {"text": "a hot air balloon drifting over a mountain valley, sunset, cinematic", "clip": ["4", 1]} + }, + "7": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Negative Prompt"}, + "inputs": {"text": "low quality, blurry, deformed, watermark", "clip": ["4", 1]} + }, + "8": { + "class_type": "VAEDecode", + "_meta": {"title": "VAE Decode"}, + "inputs": {"samples": ["3", 0], "vae": ["4", 2]} + }, + "9": { + "class_type": "VHS_VideoCombine", + "_meta": {"title": "Video Combine"}, + "inputs": { + "frame_rate": 8.0, + "loop_count": 0, + "filename_prefix": "animatediff", + "format": "video/h264-mp4", + "pingpong": false, + "save_output": true, + "images": ["8", 0] + } + }, + "10": { + "class_type": "ADE_AnimateDiffLoaderWithContext", + "_meta": {"title": "AnimateDiff Loader"}, + "inputs": { + "model": ["4", 0], + "model_name": "mm_sd_v15_v2.ckpt", + "beta_schedule": "sqrt_linear (AnimateDiff)", + "motion_scale": 1.0, + "apply_v2_models_properly": true + } + } +} diff --git a/image-creation/comfyui/workflows/flux_dev_txt2img.json b/image-creation/comfyui/workflows/flux_dev_txt2img.json new file mode 100644 index 0000000..1791280 --- /dev/null +++ b/image-creation/comfyui/workflows/flux_dev_txt2img.json @@ -0,0 +1,78 @@ +{ + "_comment": "Flux Dev text-to-image using the modern sampler chain (BasicScheduler/Guider/SamplerCustomAdvanced). Required: flux1-dev.safetensors (UNET), t5xxl_fp16.safetensors + clip_l.safetensors (CLIP), ae.safetensors (VAE).", + "6": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Prompt"}, + "inputs": {"text": "a serene mountain landscape at golden hour, photorealistic", "clip": ["11", 0]} + }, + "8": { + "class_type": "VAEDecode", + "_meta": {"title": "VAE Decode"}, + "inputs": {"samples": ["13", 0], "vae": ["10", 0]} + }, + "9": { + "class_type": "SaveImage", + "_meta": {"title": "Save Image"}, + "inputs": {"filename_prefix": "flux_dev", "images": ["8", 0]} + }, + "10": { + "class_type": "VAELoader", + "_meta": {"title": "VAE"}, + "inputs": {"vae_name": "ae.safetensors"} + }, + "11": { + "class_type": "DualCLIPLoader", + "_meta": {"title": "DualCLIPLoader"}, + "inputs": { + "clip_name1": "t5xxl_fp16.safetensors", + "clip_name2": "clip_l.safetensors", + "type": "flux" + } + }, + "12": { + "class_type": "UNETLoader", + "_meta": {"title": "UNET Loader"}, + "inputs": {"unet_name": "flux1-dev.safetensors", "weight_dtype": "default"} + }, + "13": { + "class_type": "SamplerCustomAdvanced", + "_meta": {"title": "Sampler Custom"}, + "inputs": { + "noise": ["25", 0], + "guider": ["22", 0], + "sampler": ["16", 0], + "sigmas": ["17", 0], + "latent_image": ["27", 0] + } + }, + "16": { + "class_type": "KSamplerSelect", + "_meta": {"title": "Sampler Select"}, + "inputs": {"sampler_name": "euler"} + }, + "17": { + "class_type": "BasicScheduler", + "_meta": {"title": "Scheduler"}, + "inputs": { + "scheduler": "simple", + "steps": 20, + "denoise": 1.0, + "model": ["12", 0] + } + }, + "22": { + "class_type": "BasicGuider", + "_meta": {"title": "Guider"}, + "inputs": {"model": ["12", 0], "conditioning": ["6", 0]} + }, + "25": { + "class_type": "RandomNoise", + "_meta": {"title": "Noise"}, + "inputs": {"noise_seed": 42} + }, + "27": { + "class_type": "EmptySD3LatentImage", + "_meta": {"title": "Latent"}, + "inputs": {"width": 1024, "height": 1024, "batch_size": 1} + } +} diff --git a/image-creation/comfyui/workflows/sd15_txt2img.json b/image-creation/comfyui/workflows/sd15_txt2img.json new file mode 100644 index 0000000..f67eb79 --- /dev/null +++ b/image-creation/comfyui/workflows/sd15_txt2img.json @@ -0,0 +1,49 @@ +{ + "_comment": "SD 1.5 text-to-image. Smallest model, fastest. Required model: v1-5-pruned-emaonly.safetensors (or any SD1.5 checkpoint)", + "3": { + "class_type": "KSampler", + "_meta": {"title": "KSampler"}, + "inputs": { + "seed": 156680208700286, + "steps": 20, + "cfg": 8.0, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1.0, + "model": ["4", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["5", 0] + } + }, + "4": { + "class_type": "CheckpointLoaderSimple", + "_meta": {"title": "Load Checkpoint"}, + "inputs": {"ckpt_name": "v1-5-pruned-emaonly.safetensors"} + }, + "5": { + "class_type": "EmptyLatentImage", + "_meta": {"title": "Empty Latent"}, + "inputs": {"width": 512, "height": 512, "batch_size": 1} + }, + "6": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Positive Prompt"}, + "inputs": {"text": "a beautiful landscape painting, masterpiece, highly detailed", "clip": ["4", 1]} + }, + "7": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Negative Prompt"}, + "inputs": {"text": "ugly, blurry, low quality, deformed", "clip": ["4", 1]} + }, + "8": { + "class_type": "VAEDecode", + "_meta": {"title": "VAE Decode"}, + "inputs": {"samples": ["3", 0], "vae": ["4", 2]} + }, + "9": { + "class_type": "SaveImage", + "_meta": {"title": "Save Image"}, + "inputs": {"filename_prefix": "sd15", "images": ["8", 0]} + } +} diff --git a/image-creation/comfyui/workflows/sdxl_img2img.json b/image-creation/comfyui/workflows/sdxl_img2img.json new file mode 100644 index 0000000..a835567 --- /dev/null +++ b/image-creation/comfyui/workflows/sdxl_img2img.json @@ -0,0 +1,54 @@ +{ + "_comment": "SDXL img2img: load an input image, encode to latent, denoise partially. Use --input-image image=./photo.png with run_workflow.py. Lower 'denoise' value preserves more of the source image.", + "1": { + "class_type": "LoadImage", + "_meta": {"title": "Load Source Image"}, + "inputs": {"image": "REPLACE_WITH_UPLOADED_FILENAME.png"} + }, + "3": { + "class_type": "KSampler", + "_meta": {"title": "KSampler"}, + "inputs": { + "seed": 42, + "steps": 30, + "cfg": 7.5, + "sampler_name": "dpmpp_2m", + "scheduler": "karras", + "denoise": 0.65, + "model": ["4", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["12", 0] + } + }, + "4": { + "class_type": "CheckpointLoaderSimple", + "_meta": {"title": "Load SDXL Base"}, + "inputs": {"ckpt_name": "sd_xl_base_1.0.safetensors"} + }, + "6": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Positive Prompt"}, + "inputs": {"text": "make it cyberpunk, neon lights, futuristic", "clip": ["4", 1]} + }, + "7": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Negative Prompt"}, + "inputs": {"text": "ugly, blurry, low quality, deformed", "clip": ["4", 1]} + }, + "8": { + "class_type": "VAEDecode", + "_meta": {"title": "VAE Decode"}, + "inputs": {"samples": ["3", 0], "vae": ["4", 2]} + }, + "9": { + "class_type": "SaveImage", + "_meta": {"title": "Save Image"}, + "inputs": {"filename_prefix": "sdxl_img2img", "images": ["8", 0]} + }, + "12": { + "class_type": "VAEEncode", + "_meta": {"title": "VAE Encode"}, + "inputs": {"pixels": ["1", 0], "vae": ["4", 2]} + } +} diff --git a/image-creation/comfyui/workflows/sdxl_inpaint.json b/image-creation/comfyui/workflows/sdxl_inpaint.json new file mode 100644 index 0000000..20e50cc --- /dev/null +++ b/image-creation/comfyui/workflows/sdxl_inpaint.json @@ -0,0 +1,59 @@ +{ + "_comment": "SDXL inpainting: given an image + mask, regenerate the masked region. Upload both: --input-image image=./photo.png --input-image mask_image=./mask.png. White pixels in mask = regenerate; black = preserve.", + "1": { + "class_type": "LoadImage", + "_meta": {"title": "Load Source"}, + "inputs": {"image": "REPLACE_WITH_UPLOADED_FILENAME.png"} + }, + "2": { + "class_type": "LoadImageMask", + "_meta": {"title": "Load Mask"}, + "inputs": {"image": "REPLACE_WITH_UPLOADED_MASK.png", "channel": "red"} + }, + "3": { + "class_type": "KSampler", + "_meta": {"title": "KSampler"}, + "inputs": { + "seed": 42, + "steps": 30, + "cfg": 7.5, + "sampler_name": "dpmpp_2m", + "scheduler": "karras", + "denoise": 1.0, + "model": ["4", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["12", 0] + } + }, + "4": { + "class_type": "CheckpointLoaderSimple", + "_meta": {"title": "Checkpoint"}, + "inputs": {"ckpt_name": "sd_xl_base_1.0.safetensors"} + }, + "6": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Positive Prompt"}, + "inputs": {"text": "fill with blooming flowers, photorealistic", "clip": ["4", 1]} + }, + "7": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Negative Prompt"}, + "inputs": {"text": "ugly, blurry, deformed, bad anatomy", "clip": ["4", 1]} + }, + "8": { + "class_type": "VAEDecode", + "_meta": {"title": "VAE Decode"}, + "inputs": {"samples": ["3", 0], "vae": ["4", 2]} + }, + "9": { + "class_type": "SaveImage", + "_meta": {"title": "Save"}, + "inputs": {"filename_prefix": "sdxl_inpaint", "images": ["8", 0]} + }, + "12": { + "class_type": "VAEEncodeForInpaint", + "_meta": {"title": "VAE Encode for Inpaint"}, + "inputs": {"pixels": ["1", 0], "mask": ["2", 0], "vae": ["4", 2], "grow_mask_by": 6} + } +} diff --git a/image-creation/comfyui/workflows/sdxl_txt2img.json b/image-creation/comfyui/workflows/sdxl_txt2img.json new file mode 100644 index 0000000..cb590b4 --- /dev/null +++ b/image-creation/comfyui/workflows/sdxl_txt2img.json @@ -0,0 +1,49 @@ +{ + "_comment": "SDXL text-to-image at 1024x1024. Required model: sd_xl_base_1.0.safetensors (or any SDXL checkpoint).", + "3": { + "class_type": "KSampler", + "_meta": {"title": "KSampler"}, + "inputs": { + "seed": 42, + "steps": 30, + "cfg": 7.5, + "sampler_name": "dpmpp_2m", + "scheduler": "karras", + "denoise": 1.0, + "model": ["4", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["5", 0] + } + }, + "4": { + "class_type": "CheckpointLoaderSimple", + "_meta": {"title": "Load SDXL Base"}, + "inputs": {"ckpt_name": "sd_xl_base_1.0.safetensors"} + }, + "5": { + "class_type": "EmptyLatentImage", + "_meta": {"title": "Empty Latent"}, + "inputs": {"width": 1024, "height": 1024, "batch_size": 1} + }, + "6": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Positive Prompt"}, + "inputs": {"text": "cinematic photograph, dramatic lighting, intricate detail", "clip": ["4", 1]} + }, + "7": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Negative Prompt"}, + "inputs": {"text": "ugly, blurry, low quality, deformed, watermark", "clip": ["4", 1]} + }, + "8": { + "class_type": "VAEDecode", + "_meta": {"title": "VAE Decode"}, + "inputs": {"samples": ["3", 0], "vae": ["4", 2]} + }, + "9": { + "class_type": "SaveImage", + "_meta": {"title": "Save Image"}, + "inputs": {"filename_prefix": "sdxl", "images": ["8", 0]} + } +} diff --git a/image-creation/comfyui/workflows/upscale_4x.json b/image-creation/comfyui/workflows/upscale_4x.json new file mode 100644 index 0000000..91ad7eb --- /dev/null +++ b/image-creation/comfyui/workflows/upscale_4x.json @@ -0,0 +1,27 @@ +{ + "_comment": "Standalone 4x upscale of an input image using ESRGAN. Required model: 4x-UltraSharp.pth (or any upscaler in models/upscale_models/). Upload with --input-image image=./photo.png.", + "1": { + "class_type": "LoadImage", + "_meta": {"title": "Load Image"}, + "inputs": {"image": "REPLACE_WITH_UPLOADED_FILENAME.png"} + }, + "2": { + "class_type": "UpscaleModelLoader", + "_meta": {"title": "Load Upscale Model"}, + "inputs": {"model_name": "4x-UltraSharp.pth"} + }, + "3": { + "class_type": "ImageUpscaleWithModel", + "_meta": {"title": "Upscale Image (with Model)"}, + "inputs": { + "upscale_method": "lanczos", + "upscale_model": ["2", 0], + "image": ["1", 0] + } + }, + "4": { + "class_type": "SaveImage", + "_meta": {"title": "Save"}, + "inputs": {"filename_prefix": "upscaled_4x", "images": ["3", 0]} + } +} diff --git a/image-creation/comfyui/workflows/wan_video_t2v.json b/image-creation/comfyui/workflows/wan_video_t2v.json new file mode 100644 index 0000000..7514e3a --- /dev/null +++ b/image-creation/comfyui/workflows/wan_video_t2v.json @@ -0,0 +1,69 @@ +{ + "_comment": "Wan 2.1 text-to-video. Cloud: confirmed available. Local: download wan2.1_t2v_1.3B_fp16.safetensors → models/diffusion_models/ (or models/unet/), umt5_xxl_fp16.safetensors → models/text_encoders/ (or models/clip/), wan_2.1_vae.safetensors → models/vae/. Output: MP4. Large model — only on cloud or 24 GB+ local GPU.", + "6": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Prompt"}, + "inputs": { + "text": "a graceful crane taking flight from a misty lake at dawn, slow motion, 4k", + "clip": ["38", 0] + } + }, + "7": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "Negative Prompt"}, + "inputs": { + "text": "static, blurry, watermark, low quality", + "clip": ["38", 0] + } + }, + "8": { + "class_type": "VAEDecode", + "_meta": {"title": "VAE Decode"}, + "inputs": {"samples": ["3", 0], "vae": ["39", 0]} + }, + "37": { + "class_type": "UNETLoader", + "_meta": {"title": "Wan UNET"}, + "inputs": {"unet_name": "wan2.1_t2v_1.3B_fp16.safetensors", "weight_dtype": "default"} + }, + "38": { + "class_type": "CLIPLoader", + "_meta": {"title": "Wan CLIP"}, + "inputs": {"clip_name": "umt5_xxl_fp16.safetensors", "type": "wan"} + }, + "39": { + "class_type": "VAELoader", + "_meta": {"title": "Wan VAE"}, + "inputs": {"vae_name": "wan_2.1_vae.safetensors"} + }, + "3": { + "class_type": "KSampler", + "_meta": {"title": "KSampler"}, + "inputs": { + "seed": 42, "steps": 30, "cfg": 6.0, + "sampler_name": "uni_pc", "scheduler": "simple", "denoise": 1.0, + "model": ["37", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["40", 0] + } + }, + "40": { + "class_type": "EmptyHunyuanLatentVideo", + "_meta": {"title": "Latent Video (33 frames)"}, + "inputs": {"width": 832, "height": 480, "length": 33, "batch_size": 1} + }, + "9": { + "class_type": "VHS_VideoCombine", + "_meta": {"title": "Video Combine"}, + "inputs": { + "frame_rate": 16.0, + "loop_count": 0, + "filename_prefix": "wan_t2v", + "format": "video/h264-mp4", + "pingpong": false, + "save_output": true, + "images": ["8", 0] + } + } +}