mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 01:07:31 +08:00
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:
835
skills/creative/comfyui/scripts/_common.py
Normal file
835
skills/creative/comfyui/scripts/_common.py
Normal 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)
|
||||
225
skills/creative/comfyui/scripts/auto_fix_deps.py
Executable file
225
skills/creative/comfyui/scripts/auto_fix_deps.py
Executable 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
495
skills/creative/comfyui/scripts/check_deps.py
Normal file → Executable 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())
|
||||
|
||||
@@ -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
399
skills/creative/comfyui/scripts/extract_schema.py
Normal file → Executable 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())
|
||||
|
||||
158
skills/creative/comfyui/scripts/fetch_logs.py
Executable file
158
skills/creative/comfyui/scripts/fetch_logs.py
Executable 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())
|
||||
@@ -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 ~5–24 GB
|
||||
|
||||
_COMFY_CLI_FLAG = {
|
||||
"nvidia": "--nvidia",
|
||||
"amd": "--amd",
|
||||
"apple-silicon": "--m-series",
|
||||
"intel": None,
|
||||
"comfy-cloud": None,
|
||||
"cpu": "--cpu",
|
||||
}
|
||||
|
||||
|
||||
def _run(cmd: list[str], timeout: int = 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}")
|
||||
|
||||
|
||||
223
skills/creative/comfyui/scripts/health_check.py
Executable file
223
skills/creative/comfyui/scripts/health_check.py
Executable 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())
|
||||
243
skills/creative/comfyui/scripts/run_batch.py
Executable file
243
skills/creative/comfyui/scripts/run_batch.py
Executable 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
971
skills/creative/comfyui/scripts/run_workflow.py
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
267
skills/creative/comfyui/scripts/ws_monitor.py
Executable file
267
skills/creative/comfyui/scripts/ws_monitor.py
Executable 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())
|
||||
Reference in New Issue
Block a user