fix(skills/comfyui): bug fixes, cloud parity, expanded coverage, examples, tests

The audit of v4.1 surfaced ~70 issues across the five scripts and three
reference docs — most user-visible (silent file overwrites, status-error
misclassified as success, X-API-Key leaked to S3 on /api/view redirect,
Cloud endpoints that 404 because they were renamed). v5.0.0 fixes those
and fills the gaps that previously forced users to write their own glue
(WebSocket monitoring, batch/sweep, img2img upload helper, dep auto-fix,
log fetch, health check, example workflows).

Critical fixes
- run_workflow.py: poll_status now checks status_str==error BEFORE
  completed:true, so a failed run no longer reports success
- run_workflow.py: download_output streams to disk via safe_path_join,
  preserves server subfolder structure (no silent overwrites), and
  retries with exponential backoff
- run_workflow.py: refuses to overwrite a link with a literal in
  inject_params (would silently break wiring)
- _common.py: _StripSensitiveOnRedirectSession (subclasses
  requests.Session.rebuild_auth) drops X-API-Key/Cookie on cross-host
  redirects — fixes a real key-leak path through Cloud's signed-URL
  download flow. Tested
- Cloud routing (verified live): /history → /history_v2,
  /models/<f> → /experiment/models/<f>, plus folder aliases for the
  unet ↔ diffusion_models and clip ↔ text_encoders rename
- check_deps.py: distinguishes 200/empty vs 404 folder_not_found vs
  403 free-tier; emits concrete fix_command per missing dep
- extract_schema.py: prompt vs negative_prompt determined by tracing
  KSampler.{positive,negative} connections (incl. through Reroute /
  Primitive nodes) instead of meta-title heuristic; symmetric
  duplicate-name resolution; cycle-safe trace_to_node
- hardware_check.py: multi-GPU pick-best, Apple variant detection,
  Rosetta detection, WSL2, ROCm --json, disk-space check, optional
  PyTorch probe; powershell preferred over deprecated wmic
- comfyui_setup.sh: prefers pipx → uvx → pip --user (with PEP-668
  fallback); idempotent — skips relaunch if server already up;
  configurable port/workspace; persistent log; SIGINT trap

New scripts
- run_batch.py — count or sweep (cartesian product), parallel up to
  cloud tier limit
- ws_monitor.py — real-time WebSocket viewer; saves preview frames
- auto_fix_deps.py — runs comfy node install / model download for
  whatever check_deps reports missing (with --dry-run)
- health_check.py — single command that runs the verification checklist
  (comfy-cli + server + checkpoints + optional smoke test that cancels
  itself to avoid burning compute)
- fetch_logs.py — pull traceback / status messages for a prompt_id

Coverage expansion
- Param patterns now cover Flux (BasicScheduler, BasicGuider,
  RandomNoise, ModelSamplingFlux), SD3, Wan/Hunyuan/LTX video,
  IPAdapter, rgthree, easy-use, AnimateDiff
- Embedding refs in CLIPTextEncode strings extracted as model deps
- ckpt_name / vae_name / lora_name / unet_name now controllable so
  workflows can be retargeted per run

Examples
- workflows/{sd15,sdxl,flux_dev}_txt2img.json
- workflows/sdxl_{img2img,inpaint}.json
- workflows/upscale_4x.json
- workflows/{animatediff_video,wan_video_t2v}.json + README

Tests
- 117 tests (105 unit + 8 cloud integration + 4 cross-host security)
- Cloud tests auto-skip without COMFY_CLOUD_API_KEY; verified end-to-end
  against live cloud API

Backwards compatibility
- All existing CLI flags continue to work; new behavior is opt-in
  (--ws, --input-image, --randomize-seed, --flat-output, etc.)
This commit is contained in:
SHL0MS 2026-04-29 20:50:52 -04:00 committed by Teknium
parent 7d48a16f14
commit a7780fe05f
32 changed files with 6117 additions and 1372 deletions

View file

@ -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/<folder>` 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)
"IPAdapterModelLoader": [("ipadapter_file", "ipadapter")],
"IPAdapterUnifiedLoader": [("preset", "ipadapter")],
"IPAdapterInsightFaceLoader": [("provider", "insightface")],
"InsightFaceLoader": [("provider", "insightface")],
"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"),
("BasicGuider", "cfg", "cfg"),
("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"),
("SamplerCustomAdvanced", "noise_seed", "seed"),
# ---- 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/<f> -> /experiment/models/<f>
/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)

View file

@ -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 <package>`
- 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())

495
skills/creative/comfyui/scripts/check_deps.py Normal file → Executable file
View file

@ -1,182 +1,417 @@
#!/usr/bin/env python3
"""
check_deps.py Check if a ComfyUI workflow's dependencies (custom nodes and models) are installed.
check_deps.py Verify a ComfyUI workflow's dependencies (custom nodes, models,
embeddings) against a running server.
Queries the running ComfyUI server for installed nodes (via /object_info) and models
(via /models/{folder}), then diffs against what the workflow requires.
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 <name>`)
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 --api-key KEY
python3 check_deps.py workflow_api.json --host https://cloud.comfy.org
Output format:
{
"is_ready": true/false,
"missing_nodes": ["NodeClassName", ...],
"missing_models": [{"class_type": "...", "field": "...", "value": "...", "folder": "..."}],
"installed_nodes_count": 123,
"required_nodes": ["KSampler", "CLIPTextEncode", ...]
}
Requires: Python 3.10+, requests (or urllib as fallback)
Stdlib-only. Python 3.10+.
"""
from __future__ import annotations
import argparse
import json
import sys
import argparse
from pathlib import Path
from urllib.parse import urljoin, urlparse
try:
import requests
HAS_REQUESTS = True
except ImportError:
HAS_REQUESTS = False
import urllib.request
import urllib.error
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 model loader node types and which folder they reference
MODEL_LOADERS = {
"CheckpointLoaderSimple": ("ckpt_name", "checkpoints"),
"CheckpointLoader": ("ckpt_name", "checkpoints"),
"unCLIPCheckpointLoader": ("ckpt_name", "checkpoints"),
"LoraLoader": ("lora_name", "loras"),
"LoraLoaderModelOnly": ("lora_name", "loras"),
"VAELoader": ("vae_name", "vae"),
"ControlNetLoader": ("control_net_name", "controlnet"),
"DiffControlNetLoader": ("control_net_name", "controlnet"),
"CLIPLoader": ("clip_name", "clip"),
"DualCLIPLoader": ("clip_name1", "clip"),
"UNETLoader": ("unet_name", "unet"),
"DiffusionModelLoader": ("model_name", "diffusion_models"),
"UpscaleModelLoader": ("model_name", "upscale_models"),
"CLIPVisionLoader": ("clip_name", "clip_vision"),
"StyleModelLoader": ("style_model_name", "style_models"),
"GLIGENLoader": ("gligen_name", "gligen"),
"HypernetworkLoader": ("hypernetwork_name", "hypernetworks"),
# 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
"Power Lora Loader (rgthree)": "rgthree-comfy",
"Image Comparer (rgthree)": "rgthree-comfy",
"Seed (rgthree)": "rgthree-comfy",
"Reroute (rgthree)": "rgthree-comfy",
"Display Any (rgthree)": "rgthree-comfy",
# Impact pack
"FaceDetailer": "comfyui-impact-pack",
"DetailerForEach": "comfyui-impact-pack",
"UltralyticsDetectorProvider": "comfyui-impact-pack",
"BboxDetectorSEGS": "comfyui-impact-pack",
"SAMLoader": "comfyui-impact-pack",
"ImpactWildcardProcessor": "comfyui-impact-pack",
# 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
"Canny": "comfyui_controlnet_aux",
"DWPreprocessor": "comfyui_controlnet_aux",
"OpenposePreprocessor": "comfyui_controlnet_aux",
"DepthAnythingPreprocessor": "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
"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
"UNETLoaderGGUF": "comfyui-gguf",
"DualCLIPLoaderGGUF": "comfyui-gguf",
# Florence2
"Florence2Run": "comfyui-florence2",
# WAS
"Image Filter Adjustments": "was-node-suite-comfyui",
# Photomaker
"PhotoMakerLoader": "comfyui-photomaker-plus",
# Wan / Hunyuan video
"WanVideoSampler": "comfyui-wanvideowrapper",
"WanVideoModelLoader": "comfyui-wanvideowrapper",
"HunyuanVideoSampler": "comfyui-hunyuanvideowrapper",
"HunyuanVideoModelLoader": "comfyui-hunyuanvideowrapper",
}
def http_get(url: str, headers: dict = None) -> tuple:
"""GET request, returns (status_code, body_text)."""
if HAS_REQUESTS:
r = requests.get(url, headers=headers or {}, timeout=30)
return r.status_code, r.text
else:
req = urllib.request.Request(url, headers=headers or {})
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:
resp = urllib.request.urlopen(req, timeout=30)
return resp.status, resp.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
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 check_deps(workflow_path: str, host: str = "http://127.0.0.1:8188", api_key: str = None):
"""Check workflow dependencies against a running server."""
# Load workflow
with open(workflow_path) as f:
workflow = json.load(f)
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"}
# Validate format
if "nodes" in workflow and "links" in workflow:
return {"error": "Workflow is in editor format, not API format."}
headers = {}
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 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
parsed_host = urlparse(host)
hostname = (parsed_host.hostname or "").lower()
is_cloud_host = hostname == "cloud.comfy.org" or hostname.endswith(".cloud.comfy.org")
is_cloud = is_cloud_host or api_key is not None
is_cloud = is_cloud_host(host)
base = host.rstrip("/")
# Get installed node types
object_info_url = f"{base}/api/object_info" if is_cloud else f"{base}/object_info"
status, body = http_get(object_info_url, headers)
if status != 200:
return {"error": f"Cannot reach server at {host}. Is ComfyUI running? HTTP {status}"}
# ---- 1. Required nodes ----
required_nodes: set[str] = set()
for _, node in iter_nodes(workflow):
required_nodes.add(node["class_type"])
installed_nodes = set(json.loads(body).keys())
object_info_url = resolve_url(base, "/object_info", is_cloud=is_cloud)
installed_nodes, obj_err = fetch_object_info(object_info_url, headers)
# Find required node types from workflow
required_nodes = set()
for node_id, node in workflow.items():
if isinstance(node, dict) and "class_type" in node:
required_nodes.add(node["class_type"])
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)
if cmd:
entry["fix_command"] = cmd
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)
missing_nodes = sorted(required_nodes - installed_nodes)
# ---- 2. Required models ----
model_cache: dict[str, tuple[set[str] | None, dict | None]] = {}
missing_models: list[dict] = []
folder_errors: dict[str, dict] = {}
# Check model dependencies
missing_models = []
model_cache = {} # folder → set of installed model filenames
for node_id, node in workflow.items():
if not isinstance(node, dict) or "class_type" not in node:
continue
class_type = node["class_type"]
if class_type not in MODEL_LOADERS:
continue
field, folder = MODEL_LOADERS[class_type]
inputs = node.get("inputs", {})
model_name = inputs.get(field)
if not model_name or not isinstance(model_name, str):
continue
# Fetch installed models for this folder (cached)
for dep in iter_model_deps(workflow):
folder = dep["folder"]
if folder not in model_cache:
models_url = f"{base}/api/models/{folder}" if is_cloud else f"{base}/models/{folder}"
s, b = http_get(models_url, headers)
if s == 200:
model_cache[folder] = set(json.loads(b))
else:
model_cache[folder] = set()
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 <URL> --relative-path models/{folder} "
f"--filename {dep['value']!r}"
)
missing_models.append(entry)
if model_name not in model_cache[folder]:
missing_models.append({
"node_id": node_id,
"class_type": class_type,
"field": field,
"value": model_name,
"folder": folder,
# ---- 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 <URL> "
f"--relative-path models/embeddings`"
),
})
is_ready = len(missing_nodes) == 0 and len(missing_models) == 0
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,
"installed_nodes_count": len(installed_nodes),
"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():
parser = argparse.ArgumentParser(description="Check ComfyUI workflow dependencies")
parser.add_argument("workflow", help="Path to workflow API JSON file")
parser.add_argument("--host", default="http://127.0.0.1:8188", help="ComfyUI server URL")
parser.add_argument("--port", type=int, help="Server port (overrides --host port)")
parser.add_argument("--api-key", help="API key for cloud")
args = parser.parse_args()
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)
# Handle --port override
host = args.host
if args.port and ":" not in host.split("//")[-1]:
host = f"{host}:{args.port}"
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))
result = check_deps(args.workflow, host=host, api_key=args.api_key)
print(json.dumps(result, indent=2))
api_key = resolve_api_key(args.api_key)
if result.get("error"):
sys.exit(1)
if not result.get("is_ready", False):
sys.exit(1)
sys.exit(0)
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__":
main()
sys.exit(main())

View file

@ -1,113 +1,263 @@
#!/usr/bin/env bash
# ComfyUI Setup — Install, launch, and verify using the official comfy-cli.
# Usage: bash scripts/comfyui_setup.sh [--nvidia|--amd|--m-series|--cpu]
#
# If no flag is passed, runs hardware_check.py to detect the right one
# automatically, and refuses to install locally when the verdict is "cloud"
# (no usable GPU, too little VRAM, Intel Mac, etc.) — pointing the user
# at Comfy Cloud instead.
# 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.<pid>.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)
#
# Prerequisites: Python 3.10+, pip
# What it does:
# 0. Hardware check (skipped if a flag was passed explicitly)
# 1. Installs comfy-cli (if not present)
# 2. Disables analytics tracking
# 3. Installs ComfyUI + ComfyUI-Manager
# 4. Launches server in background
# 5. Verifies server is reachable
# 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=()
# Step 0: Hardware check (auto-detect GPU flag when none was provided)
if [ $# -ge 1 ]; then
GPU_FLAG="$1"
echo "==> GPU flag: $GPU_FLAG (user-supplied, skipping hardware check)"
else
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
echo "==> hardware_check.py not found, defaulting to --nvidia"
log "hardware_check.py not found — defaulting to --nvidia"
GPU_FLAG="--nvidia"
else
echo "==> Running hardware check..."
log "Running hardware check…"
set +e
HW_JSON="$(python3 "$HARDWARE_CHECK" --json)"
HW_JSON="$(python3 "$HARDWARE_CHECK" --json 2>>"$LOG_FILE")"
HW_EXIT=$?
set -e
echo "$HW_JSON"
echo ""
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" ]; then
echo ""
echo "==> Hardware check: this machine is not suitable for local ComfyUI."
echo " Recommended: Comfy Cloud — https://platform.comfy.org"
echo ""
echo " If you want to override and install anyway, re-run with an"
echo " explicit flag: bash $0 --nvidia|--amd|--m-series|--cpu"
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
echo "==> Hardware check couldn't pick a comfy-cli flag. Defaulting to --nvidia."
echo " (For Intel Arc or unsupported hardware, use the manual install path.)"
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
if [ "$VERDICT" = "marginal" ]; then
echo "==> Hardware check: verdict is MARGINAL."
echo " SD1.5 should work; SDXL/Flux may be slow or OOM."
echo " Consider Comfy Cloud for heavier workflows: https://platform.comfy.org"
echo ""
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/<ver>/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
echo "==> ComfyUI Setup"
echo " GPU flag: $GPU_FLAG"
echo ""
# --- Step 2: Disable analytics tracking (avoid interactive prompt) ---
log "Disabling analytics tracking…"
$COMFY_BIN --skip-prompt tracking disable >>"$LOG_FILE" 2>&1 || true
# Step 1: Install comfy-cli
if command -v comfy >/dev/null 2>&1; then
echo "==> comfy-cli already installed: $(comfy -v 2>/dev/null || echo 'unknown version')"
else
echo "==> Installing comfy-cli..."
pip install comfy-cli
# --- Step 3: Install ComfyUI ---
WORKSPACE_ARG=()
if [ -n "$WORKSPACE" ]; then
WORKSPACE_ARG=(--workspace "$WORKSPACE")
fi
# Step 2: Disable tracking (avoid interactive prompt)
echo "==> Disabling analytics tracking..."
comfy --skip-prompt tracking disable 2>/dev/null || true
# Step 3: Install ComfyUI
if comfy which 2>/dev/null | grep -q "ComfyUI"; then
echo "==> ComfyUI already installed at: $(comfy which 2>/dev/null)"
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
echo "==> Installing ComfyUI ($GPU_FLAG)..."
comfy --skip-prompt install $GPU_FLAG
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
# Step 4: Launch in background
echo "==> Launching ComfyUI in background..."
comfy launch --background 2>/dev/null || {
echo "==> Background launch failed. Trying foreground check..."
echo " You may need to run: comfy launch"
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 5: Wait for server to be ready
echo "==> Waiting for server..."
MAX_WAIT=30
# --- Step 6: Wait for server ---
log "Waiting for server…"
MAX_WAIT=60
ELAPSED=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
if curl -s http://127.0.0.1:8188/system_stats >/dev/null 2>&1; then
echo "==> Server is running!"
curl -s http://127.0.0.1:8188/system_stats | python3 -m json.tool 2>/dev/null || true
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
@ -115,17 +265,22 @@ while [ $ELAPSED -lt $MAX_WAIT ]; do
done
if [ $ELAPSED -ge $MAX_WAIT ]; then
echo "==> Server did not start within ${MAX_WAIT}s."
echo " Check logs with: comfy launch (foreground) to see errors."
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
echo ""
echo "==> Setup complete!"
echo " Server: http://127.0.0.1:8188"
echo " Web UI: http://127.0.0.1:8188 (open in browser)"
echo " Stop: comfy stop"
echo ""
echo " Next steps:"
echo " - Download a model: comfy model download --url <URL> --relative-path models/checkpoints"
echo " - Run a workflow: python3 scripts/run_workflow.py --workflow <file.json> --args '{...}'"
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 <URL> --relative-path models/checkpoints"
log " - Run a workflow: python3 $SCRIPT_DIR/run_workflow.py --workflow <file.json> --args '{...}'"
# Disable trap on success path
trap - EXIT

399
skills/creative/comfyui/scripts/extract_schema.py Normal file → Executable file
View file

@ -1,100 +1,51 @@
#!/usr/bin/env python3
"""
extract_schema.py Analyze a ComfyUI API-format workflow and extract controllable parameters.
extract_schema.py Analyze a ComfyUI API-format workflow and extract
controllable parameters.
Reads a workflow JSON, identifies user-facing parameters (prompts, seed, dimensions, etc.)
by scanning node types and field names, and outputs a schema mapping.
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
Output format:
{
"parameters": {
"prompt": {"node_id": "6", "field": "text", "type": "string", "value": "..."},
"seed": {"node_id": "3", "field": "seed", "type": "int", "value": 42},
...
},
"output_nodes": ["9"],
"model_dependencies": [
{"node_id": "4", "class_type": "CheckpointLoaderSimple", "field": "ckpt_name", "value": "..."}
]
}
Requires: Python 3.10+ (stdlib only)
Stdlib-only. Python 3.10+.
"""
from __future__ import annotations
import argparse
import json
import sys
import argparse
from pathlib import Path
from typing import Any
# Known parameter patterns: (class_type, field_name) → friendly_name
PARAM_PATTERNS = [
# Prompts
("CLIPTextEncode", "text", "prompt"),
("CLIPTextEncodeSDXL", "text_g", "prompt"),
("CLIPTextEncodeSDXL", "text_l", "prompt_l"),
# 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"),
# Dimensions
("EmptyLatentImage", "width", "width"),
("EmptyLatentImage", "height", "height"),
("EmptyLatentImage", "batch_size", "batch_size"),
# Image input
("LoadImage", "image", "image"),
("LoadImageMask", "image", "mask_image"),
# LoRA
("LoraLoader", "lora_name", "lora_name"),
("LoraLoader", "strength_model", "lora_strength"),
# Output
("SaveImage", "filename_prefix", "filename_prefix"),
]
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,
)
# Node types that produce output files
OUTPUT_NODES = {"SaveImage", "PreviewImage", "VHS_VideoCombine", "SaveAudio", "SaveAnimatedWEBP", "SaveAnimatedPNG"}
# Node types that load models (for dependency checking)
MODEL_LOADERS = {
"CheckpointLoaderSimple": ("ckpt_name", "checkpoints"),
"CheckpointLoader": ("ckpt_name", "checkpoints"),
"LoraLoader": ("lora_name", "loras"),
"LoraLoaderModelOnly": ("lora_name", "loras"),
"VAELoader": ("vae_name", "vae"),
"ControlNetLoader": ("control_net_name", "controlnet"),
"CLIPLoader": ("clip_name", "clip"),
"DualCLIPLoader": ("clip_name1", "clip"),
"UNETLoader": ("unet_name", "unet"),
"DiffusionModelLoader": ("model_name", "diffusion_models"),
"UpscaleModelLoader": ("model_name", "upscale_models"),
"CLIPVisionLoader": ("clip_name", "clip_vision"),
# Sampler nodes whose `positive` / `negative` connections we trace
SAMPLER_NODE_FAMILY = {
"KSampler", "KSamplerAdvanced",
"SamplerCustom", "SamplerCustomAdvanced",
"BasicGuider", "CFGGuider", "DualCFGGuider",
}
def validate_api_format(workflow: dict) -> bool:
"""Check if workflow is in API format (not editor format)."""
if "nodes" in workflow and "links" in workflow:
return False
# API format: top-level keys are node IDs, each has class_type
for node_id, node in workflow.items():
if isinstance(node, dict) and "class_type" in node:
return True
return False
def infer_type(value) -> str:
"""Infer JSON schema type from a Python value."""
def infer_type(value: Any) -> str:
if isinstance(value, bool):
return "bool"
if isinstance(value, int):
@ -104,109 +55,261 @@ def infer_type(value) -> str:
if isinstance(value, str):
return "string"
if isinstance(value, list):
return "link" # connections to other nodes
return "link"
if isinstance(value, dict):
return "object"
return "unknown"
def extract_schema(workflow: dict) -> dict:
"""Extract controllable parameters from a workflow."""
parameters = {}
output_nodes = []
model_deps = []
name_counts = {} # track duplicate friendly names
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.
for node_id, node in workflow.items():
if not isinstance(node, dict) or "class_type" not in node:
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
class_type = node["class_type"]
inputs = node.get("inputs", {})
meta_title = node.get("_meta", {}).get("title", "")
# Check if this is an output node
if class_type in OUTPUT_NODES:
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)
# Check if this is a model loader
if class_type in MODEL_LOADERS:
field, folder = MODEL_LOADERS[class_type]
if field in inputs and isinstance(inputs[field], str):
model_deps.append({
"node_id": node_id,
"class_type": class_type,
"field": field,
"value": inputs[field],
"folder": folder,
})
# Extract controllable parameters
for pattern_class, pattern_field, friendly_name in PARAM_PATTERNS:
if class_type != pattern_class:
# Match this node against PARAM_PATTERNS
for p_class, p_field, friendly in PARAM_PATTERNS:
if cls != p_class:
continue
if pattern_field not in inputs:
if p_field not in inputs:
continue
value = inputs[pattern_field]
val_type = infer_type(value)
if val_type == "link":
continue # skip linked inputs — not directly controllable
value = inputs[p_field]
t = infer_type(value)
if t == "link":
continue # connections aren't directly controllable
# Disambiguate duplicate friendly names
# Use title hint for prompt fields
actual_name = friendly_name
if friendly_name == "prompt" and meta_title:
title_lower = meta_title.lower()
if "negative" in title_lower or "neg" in title_lower:
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"
# Handle remaining duplicates by appending node_id
if actual_name in name_counts:
name_counts[actual_name] += 1
actual_name = f"{actual_name}_{node_id}"
else:
name_counts[actual_name] = 1
parameters[actual_name] = {
raw_params.append({
"name_hint": actual_name,
"node_id": node_id,
"field": pattern_field,
"type": val_type,
"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():
parser = argparse.ArgumentParser(description="Extract controllable parameters from a ComfyUI workflow")
parser.add_argument("workflow", help="Path to workflow API JSON file")
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
args = parser.parse_args()
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)
workflow_path = Path(args.workflow)
if not workflow_path.exists():
print(f"Error: {workflow_path} not found", file=sys.stderr)
sys.exit(1)
wf_path = Path(args.workflow).expanduser()
if not wf_path.exists():
print(f"Error: {wf_path} not found", file=sys.stderr)
return 1
with open(workflow_path) as f:
workflow = json.load(f)
if not validate_api_format(workflow):
print("Error: Workflow is in editor format, not API format.", file=sys.stderr)
print("Re-export from ComfyUI using 'Save (API Format)' button.", file=sys.stderr)
sys.exit(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)
output_json = json.dumps(schema, indent=2)
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(output_json)
Path(args.output).write_text(out)
print(f"Schema written to {args.output}", file=sys.stderr)
else:
print(output_json)
print(out)
return 0
if __name__ == "__main__":
main()
sys.exit(main())

View file

@ -0,0 +1,158 @@
#!/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 <prompt_id>
python3 fetch_logs.py <prompt_id> --host https://cloud.comfy.org
python3 fetch_logs.py --tail-queue # show currently queued/running jobs
"""
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, 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())

View file

@ -1,27 +1,24 @@
#!/usr/bin/env python3
"""Detect whether this machine can realistically run ComfyUI locally.
"""hardware_check.py — Detect whether this machine can realistically run ComfyUI locally.
Emits a structured JSON report the agent can read to decide whether to:
- help the user install ComfyUI locally, or
- steer them to Comfy Cloud instead.
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]
Exit code:
0 "ok" can run local ComfyUI at reasonable speed
1 "marginal" technically works but slow / memory-tight
2 "cloud" local is not viable, recommend Comfy Cloud
The JSON report always prints to stdout regardless of exit code.
Output fields the agent should read:
verdict: "ok" | "marginal" | "cloud"
recommended_install_path: "nvidia" | "amd" | "apple-silicon" | "intel" | "comfy-cloud"
comfy_cli_flag: "--nvidia" | "--amd" | "--m-series" | None
(pass directly to `comfy install` when verdict != cloud)
gpu: detected GPU info or null
notes: list of human-readable strings to surface to the user
python3 hardware_check.py [--json] [--check-pytorch]
"""
from __future__ import annotations
@ -33,18 +30,28 @@ import re
import shutil
import subprocess
import sys
from typing import Any
# Rough thresholds. SDXL/Flux need real VRAM; SD1.5 will scrape by on 6GB.
# Apple Silicon shares RAM with GPU — unified memory budget is total RAM.
MIN_VRAM_GB_USABLE = 6 # below this, most modern models won't load
OK_VRAM_GB = 8 # SDXL fits comfortably here
GREAT_VRAM_GB = 12 # Flux / video models start being realistic
MIN_MAC_RAM_GB = 16 # Apple Silicon unified memory; below = pain
OK_MAC_RAM_GB = 32 # smooth for SDXL / most workflows
# 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 ~524 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 = 5) -> str:
def _run(cmd: list[str], timeout: int = 8) -> str:
try:
out = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout, check=False
@ -54,45 +61,108 @@ def _run(cmd: list[str], timeout: int = 5) -> str:
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=name,memory.total,driver_version",
"--query-gpu=index,name,memory.total,driver_version",
"--format=csv,noheader,nounits",
])
if not out.strip():
return None
first = out.strip().splitlines()[0]
parts = [p.strip() for p in first.split(",")]
if len(parts) < 2:
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
name = parts[0]
try:
vram_mb = int(parts[1])
except ValueError:
vram_mb = 0
driver = parts[2] if len(parts) > 2 else ""
return {
"vendor": "nvidia",
"name": name,
"vram_gb": round(vram_mb / 1024, 1),
"driver": driver,
}
# 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:\s*(.+)", out)
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 = 0.0
if vram_m:
vram_gb = round(int(vram_m.group(1)) / (1024**3), 1)
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",
@ -105,33 +175,46 @@ def detect_apple_silicon() -> dict | None:
if platform.system() != "Darwin":
return None
if platform.machine() != "arm64":
return None # Intel Mac — no usable MPS
return None
chip = _run(["sysctl", "-n", "machdep.cpu.brand_string"]).strip()
# Examples: "Apple M1", "Apple M1 Pro", "Apple M2 Max", "Apple M3 Ultra"
m = re.search(r"Apple M(\d+)", chip)
generation = int(m.group(1)) if m else 1
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() != "Linux":
if platform.system() not in ("Linux", "Windows"):
return None
if not shutil.which("clinfo"):
return None
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}
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
@ -152,6 +235,15 @@ def total_system_ram_gb() -> float:
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:
@ -159,21 +251,67 @@ def total_system_ram_gb() -> float:
return 0.0
# Map recommended_install_path → flag the agent can pass to `comfy install`
# Set to None when no local install is advised (verdict=cloud).
_COMFY_CLI_FLAG = {
"nvidia": "--nvidia",
"amd": "--amd",
"apple-silicon": "--m-series",
"intel": None, # comfy-cli has no Intel Arc flag — manual install
"comfy-cloud": None,
}
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 classify(gpu: dict | None, ram_gb: float) -> tuple[str, str, list[str]]:
"""Return (verdict, recommended_install_path, notes)."""
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)."
@ -184,49 +322,59 @@ def classify(gpu: dict | None, ram_gb: float) -> tuple[str, str, list[str]]:
return "cloud", "comfy-cloud", notes
if gpu["vendor"] == "apple":
gen = gpu.get("generation", 1)
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"Apple Silicon with {mem} GB unified memory — below the {MIN_MAC_RAM_GB} GB practical minimum."
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"Apple Silicon M{gen} with {mem} GB — SDXL works but slow. Flux/video likely too tight."
f"{gen_str} with {mem} GB — SDXL works but slow. Flux/video likely too tight."
)
return "marginal", "apple-silicon", notes
notes.append(f"Apple Silicon M{gen} with {mem} GB unified memory — good for SDXL/Flux.")
notes.append(f"{gen_str} with {mem} GB unified memory — good for SDXL/Flux.")
return "ok", "apple-silicon", notes
# Discrete GPU path (nvidia/amd/intel)
vram = gpu.get("vram_gb", 0.0)
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"{gpu['name']} has only {vram} GB VRAM — below the {MIN_VRAM_GB_USABLE} GB practical minimum."
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"{gpu['name']} ({vram} GB VRAM) — SD1.5 works, SDXL tight, Flux/video unlikely."
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"{gpu['name']} ({vram} GB VRAM) — SDXL comfortable, Flux possible with optimizations.")
notes.append(f"{name} ({vram} GB VRAM) — SDXL comfortable, Flux possible with optimizations.")
return "ok", gpu["vendor"], notes
notes.append(f"{gpu['name']} ({vram} GB VRAM) — can run everything including Flux/video.")
notes.append(f"{name} ({vram} GB VRAM) — can run everything including Flux/video.")
return "ok", gpu["vendor"], notes
def build_report() -> dict:
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()
@ -235,16 +383,19 @@ def build_report() -> dict:
or detect_intel_arc()
)
# Intel Mac special case — fall out of apple-silicon detection with no GPU
if gpu is None and sysname == "Darwin" and platform.machine() != "arm64":
# 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.",
]
return {
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",
@ -252,13 +403,21 @@ def build_report() -> dict:
"notes": notes,
"install_urls": _install_urls(),
}
if check_pytorch:
report["pytorch"] = check_pytorch_cuda()
return report
verdict, install_path, notes = classify(gpu, ram_gb)
verdict, install_path, notes = classify(
gpu, ram_gb, free_disk_gb, wsl=wsl, rosetta=rosetta,
)
return {
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,
@ -266,6 +425,9 @@ def build_report() -> dict:
"notes": notes,
"install_urls": _install_urls(),
}
if check_pytorch:
report["pytorch"] = check_pytorch_cuda()
return report
def _install_urls() -> dict:
@ -277,26 +439,50 @@ def _install_urls() -> dict:
}
def main() -> int:
report = build_report()
json_mode = "--json" in sys.argv
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)
if json_mode:
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']})")
print(f"RAM: {report['system_ram_gb']} GB")
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")
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")
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']}")
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']}")
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}")

View file

@ -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())

View file

@ -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())

971
skills/creative/comfyui/scripts/run_workflow.py Normal file → Executable file

File diff suppressed because it is too large Load diff

View file

@ -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())