scripts/_common.py
scripts/_common.pyBrowse 33 files
8,061 tokens
33,792 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1"""2_common.py — Shared logic for ComfyUI skill scripts.3 4Single source of truth for:5- HTTP transport (with retry/backoff, streaming, timeout handling)6- Cloud detection and endpoint mapping (local ComfyUI vs Comfy Cloud)7- Workflow node-type catalogs (param patterns, model loaders, output nodes)8- API-format validation9- Path-traversal-safe file writes10- API-key loading from env / CLI11 12Stdlib-only by design (with optional `requests` upgrade if installed). Python 3.10+.13"""14 15from __future__ import annotations16 17import json18import os19import random20import re21import sys22import time23import uuid24from dataclasses import dataclass25from pathlib import Path26from typing import Any, Iterator27from urllib.parse import urlparse28 29# Optional: prefer `requests` if installed (better redirects, streaming, header handling)30try:31 import requests # type: ignore[import-not-found]32 HAS_REQUESTS = True33except ImportError: # pragma: no cover - exercised via stdlib fallback34 HAS_REQUESTS = False35 import urllib.error36 import urllib.request37 38 39# =============================================================================40# Constants & catalogs41# =============================================================================42 43DEFAULT_LOCAL_HOST = "http://127.0.0.1:8188"44DEFAULT_CLOUD_HOST = "https://cloud.comfy.org"45ENV_API_KEY = "COMFY_CLOUD_API_KEY" # env var NAME (placeholder, not a secret value)46 47# Connection / retry defaults48DEFAULT_HTTP_TIMEOUT = 60 # seconds — single-attempt request timeout49DEFAULT_RETRIES = 3 # total attempts including the first50RETRY_BASE_DELAY = 1.0 # seconds — exponential backoff base51RETRY_MAX_DELAY = 30.0 # seconds — cap on backoff52RETRY_STATUS_CODES = {408, 429, 500, 502, 503, 504, 522, 524}53 54# Streaming download chunk size (bytes)55DOWNLOAD_CHUNK_SIZE = 1 << 16 # 64 KiB56 57# Heuristic: workflows with these node types tend to be slow → larger default timeout58SLOW_OUTPUT_NODES = {59 "VHS_VideoCombine", "SaveAnimatedWEBP", "SaveAnimatedPNG",60 "SaveVideo", "SaveAudio", "SaveAnimateDiffVideo",61 "SVD_img2vid_Conditioning",62 "WanVideoSampler", "HunyuanVideoSampler",63 "CogVideoSampler", "LTXVideoSampler",64}65 66# ---------------------------------------------------------------------------67# Output node catalog (extensible — community packs add their own)68# ---------------------------------------------------------------------------69OUTPUT_NODES: set[str] = {70 # Built-in71 "SaveImage", "PreviewImage",72 "SaveAudio", "SaveVideo", "PreviewAudio", "PreviewVideo",73 "SaveAnimatedWEBP", "SaveAnimatedPNG",74 # Common community packs75 "VHS_VideoCombine", # Video Helper Suite76 "ImageSave", # Was Node Suite77 "Image Save", # Was Node Suite (alt name)78 "easy imageSave", # easy-use79 "Image Save With Metadata",80 "PreviewImage|pysssss", # pysssss preview81 "ShowText|pysssss",82 "SaveLatent",83 "SaveGLB", # 3D84 "Save3D",85}86 87# ---------------------------------------------------------------------------88# Folder aliases — handle ComfyUI's gradual folder renames89# ---------------------------------------------------------------------------90# When `check_deps.py` queries `/models/<folder>` and gets 404 / empty,91# it tries each alias in turn. Critical for Comfy Cloud which has fully92# migrated to the new naming (unet → diffusion_models, clip → text_encoders).93FOLDER_ALIASES: dict[str, list[str]] = {94 "unet": ["unet", "diffusion_models"],95 "diffusion_models": ["diffusion_models", "unet"],96 "clip": ["clip", "text_encoders"],97 "text_encoders": ["text_encoders", "clip"],98 "controlnet": ["controlnet", "control_net"],99}100 101 102def folder_aliases_for(folder: str) -> list[str]:103 """Return the search order of folder names (primary first)."""104 return FOLDER_ALIASES.get(folder, [folder])105 106 107# ---------------------------------------------------------------------------108# Model-loader catalog: class_type -> (input field, model folder)109# ---------------------------------------------------------------------------110# A loader can have multiple fields (e.g., DualCLIPLoader has clip_name1 and111# clip_name2). We list them with explicit entries. The folder name is the112# *canonical* one; FOLDER_ALIASES is consulted when querying.113MODEL_LOADERS: dict[str, list[tuple[str, str]]] = {114 # Checkpoints115 "CheckpointLoaderSimple": [("ckpt_name", "checkpoints")],116 "CheckpointLoader": [("ckpt_name", "checkpoints")],117 "CheckpointLoader (Simple)": [("ckpt_name", "checkpoints")],118 "ImageOnlyCheckpointLoader": [("ckpt_name", "checkpoints")],119 "unCLIPCheckpointLoader": [("ckpt_name", "checkpoints")],120 # LoRA121 "LoraLoader": [("lora_name", "loras")],122 "LoraLoaderModelOnly": [("lora_name", "loras")],123 "LoraLoaderTagsQuery": [("lora_name", "loras")],124 # VAE125 "VAELoader": [("vae_name", "vae")],126 # ControlNet127 "ControlNetLoader": [("control_net_name", "controlnet")],128 "DiffControlNetLoader": [("control_net_name", "controlnet")],129 "ControlNetLoaderAdvanced": [("control_net_name", "controlnet")],130 # CLIP / text encoders (primary "clip" folder; check_deps tries text_encoders too)131 "CLIPLoader": [("clip_name", "clip")],132 "DualCLIPLoader": [("clip_name1", "clip"), ("clip_name2", "clip")],133 "TripleCLIPLoader": [("clip_name1", "clip"), ("clip_name2", "clip"), ("clip_name3", "clip")],134 "CLIPVisionLoader": [("clip_name", "clip_vision")],135 # UNET / Diffusion model (primary "unet"; check_deps tries diffusion_models too)136 "UNETLoader": [("unet_name", "unet")],137 "DiffusionModelLoader": [("model_name", "diffusion_models")],138 "UNETLoaderGGUF": [("unet_name", "unet")],139 # Upscaler140 "UpscaleModelLoader": [("model_name", "upscale_models")],141 # Style / GLIGEN / Hypernetwork142 "StyleModelLoader": [("style_model_name", "style_models")],143 "GLIGENLoader": [("gligen_name", "gligen")],144 "HypernetworkLoader": [("hypernetwork_name", "hypernetworks")],145 # IPAdapter family (community).146 # Note: IPAdapterUnifiedLoader's `preset` and IPAdapterInsightFaceLoader's147 # `provider` are enums (not file paths), so they're intentionally omitted —148 # check_deps would otherwise treat enum values as missing model files.149 "IPAdapterModelLoader": [("ipadapter_file", "ipadapter")],150 "InstantIDModelLoader": [("instantid_file", "instantid")],151 # AnimateDiff / video152 "ADE_LoadAnimateDiffModel": [("model_name", "animatediff_models")],153 "ADE_AnimateDiffLoaderWithContext": [("model_name", "animatediff_models")],154 "ADE_AnimateDiffLoaderGen1": [("model_name", "animatediff_models")],155 # Photomaker156 "PhotoMakerLoader": [("photomaker_model_name", "photomaker")],157 # Sampler / scheduler models158 "ModelSamplingFlux": [], # parametric only159}160 161# ---------------------------------------------------------------------------162# Param patterns: (class_type, field_name) -> friendly_name163# Order matters — first match wins for naming. Use _meta.title for disambiguation.164# ---------------------------------------------------------------------------165PARAM_PATTERNS: list[tuple[str, str, str]] = [166 # ---- Prompts ----167 ("CLIPTextEncode", "text", "prompt"),168 ("CLIPTextEncodeSDXL", "text_g", "prompt"),169 ("CLIPTextEncodeSDXL", "text_l", "prompt_l"),170 ("CLIPTextEncodeSDXLRefiner", "text", "refiner_prompt"),171 ("CLIPTextEncodeFlux", "clip_l", "prompt_l"),172 ("CLIPTextEncodeFlux", "t5xxl", "prompt"),173 ("CLIPTextEncodeFlux", "guidance", "guidance"),174 ("smZ CLIPTextEncode", "text", "prompt"),175 ("BNK_CLIPTextEncodeAdvanced", "text", "prompt"),176 177 # ---- Standard sampling ----178 ("KSampler", "seed", "seed"),179 ("KSampler", "steps", "steps"),180 ("KSampler", "cfg", "cfg"),181 ("KSampler", "sampler_name", "sampler_name"),182 ("KSampler", "scheduler", "scheduler"),183 ("KSampler", "denoise", "denoise"),184 ("KSamplerAdvanced", "noise_seed", "seed"),185 ("KSamplerAdvanced", "steps", "steps"),186 ("KSamplerAdvanced", "cfg", "cfg"),187 ("KSamplerAdvanced", "sampler_name", "sampler_name"),188 ("KSamplerAdvanced", "scheduler", "scheduler"),189 ("KSamplerAdvanced", "start_at_step", "start_at_step"),190 ("KSamplerAdvanced", "end_at_step", "end_at_step"),191 192 # ---- Modern sampler chain (Flux / SD3 / SDXL refiner via SamplerCustom) ----193 ("RandomNoise", "noise_seed", "seed"),194 ("BasicScheduler", "steps", "steps"),195 ("BasicScheduler", "scheduler", "scheduler"),196 ("BasicScheduler", "denoise", "denoise"),197 ("KSamplerSelect", "sampler_name", "sampler_name"),198 # NB: BasicGuider has no cfg input (it just bundles model+conditioning).199 ("CFGGuider", "cfg", "cfg"),200 ("DualCFGGuider", "cfg_conds", "cfg"),201 ("DualCFGGuider", "cfg_cond2_negative", "cfg_negative"),202 ("ModelSamplingFlux", "max_shift", "max_shift"),203 ("ModelSamplingFlux", "base_shift", "base_shift"),204 ("ModelSamplingFlux", "width", "model_width"),205 ("ModelSamplingFlux", "height", "model_height"),206 ("ModelSamplingSD3", "shift", "shift"),207 ("ModelSamplingDiscrete", "sampling", "sampling"),208 ("SDTurboScheduler", "steps", "steps"),209 ("SDTurboScheduler", "denoise", "denoise"),210 ("SamplerCustom", "noise_seed", "seed"),211 ("SamplerCustom", "cfg", "cfg"),212 # NB: SamplerCustomAdvanced takes a NOISE input (from RandomNoise) — no seed field directly.213 214 # ---- Dimensions / latent ----215 ("EmptyLatentImage", "width", "width"),216 ("EmptyLatentImage", "height", "height"),217 ("EmptyLatentImage", "batch_size", "batch_size"),218 ("EmptySD3LatentImage", "width", "width"),219 ("EmptySD3LatentImage", "height", "height"),220 ("EmptySD3LatentImage", "batch_size", "batch_size"),221 ("EmptyHunyuanLatentVideo", "width", "width"),222 ("EmptyHunyuanLatentVideo", "height", "height"),223 ("EmptyHunyuanLatentVideo", "length", "length"),224 ("EmptyHunyuanLatentVideo", "batch_size", "batch_size"),225 ("EmptyMochiLatentVideo", "width", "width"),226 ("EmptyMochiLatentVideo", "height", "height"),227 ("EmptyMochiLatentVideo", "length", "length"),228 ("EmptyLTXVLatentVideo", "width", "width"),229 ("EmptyLTXVLatentVideo", "height", "height"),230 ("EmptyLTXVLatentVideo", "length", "length"),231 ("LatentUpscale", "width", "upscale_width"),232 ("LatentUpscale", "height", "upscale_height"),233 ("LatentUpscaleBy", "scale_by", "scale_by"),234 ("ImageScale", "width", "width"),235 ("ImageScale", "height", "height"),236 237 # ---- Image input ----238 ("LoadImage", "image", "image"),239 ("LoadImageMask", "image", "mask_image"),240 ("LoadImageOutput", "image", "image"),241 ("VHS_LoadVideo", "video", "video"),242 ("VHS_LoadAudio", "audio", "audio"),243 244 # ---- Model selection (sometimes useful to swap per run) ----245 ("CheckpointLoaderSimple", "ckpt_name", "ckpt_name"),246 ("CheckpointLoader", "ckpt_name", "ckpt_name"),247 ("ImageOnlyCheckpointLoader", "ckpt_name", "ckpt_name"),248 ("VAELoader", "vae_name", "vae_name"),249 ("UNETLoader", "unet_name", "unet_name"),250 ("DiffusionModelLoader", "model_name", "diffusion_model_name"),251 ("UpscaleModelLoader", "model_name", "upscale_model_name"),252 ("CLIPLoader", "clip_name", "clip_name"),253 ("DualCLIPLoader", "clip_name1", "clip_name1"),254 ("DualCLIPLoader", "clip_name2", "clip_name2"),255 ("ControlNetLoader", "control_net_name", "controlnet_name"),256 257 # ---- LoRA ----258 ("LoraLoader", "lora_name", "lora_name"),259 ("LoraLoader", "strength_model", "lora_strength"),260 ("LoraLoader", "strength_clip", "lora_strength_clip"),261 ("LoraLoaderModelOnly", "lora_name", "lora_name"),262 ("LoraLoaderModelOnly", "strength_model", "lora_strength"),263 264 # ---- ControlNet ----265 ("ControlNetApply", "strength", "controlnet_strength"),266 ("ControlNetApplyAdvanced", "strength", "controlnet_strength"),267 ("ControlNetApplyAdvanced", "start_percent", "controlnet_start"),268 ("ControlNetApplyAdvanced", "end_percent", "controlnet_end"),269 270 # ---- IPAdapter ----271 ("IPAdapterAdvanced", "weight", "ipadapter_weight"),272 ("IPAdapterAdvanced", "start_at", "ipadapter_start"),273 ("IPAdapterAdvanced", "end_at", "ipadapter_end"),274 ("IPAdapter", "weight", "ipadapter_weight"),275 276 # ---- Upscale ----277 ("ImageUpscaleWithModel", "upscale_method", "upscale_method"),278 279 # ---- AnimateDiff ----280 ("ADE_AnimateDiffLoaderWithContext", "motion_scale", "motion_scale"),281 ("ADE_AnimateDiffLoaderGen1", "motion_scale", "motion_scale"),282 283 # ---- Video / Save ----284 ("VHS_VideoCombine", "frame_rate", "frame_rate"),285 ("VHS_VideoCombine", "format", "video_format"),286 ("VHS_VideoCombine", "filename_prefix", "filename_prefix"),287 ("SaveImage", "filename_prefix", "filename_prefix"),288 289 # ---- Hunyuan / Wan / LTX video ----290 ("HunyuanVideoSampler", "seed", "seed"),291 ("HunyuanVideoSampler", "steps", "steps"),292 ("HunyuanVideoSampler", "cfg", "cfg"),293 ("WanVideoSampler", "seed", "seed"),294 ("WanVideoSampler", "steps", "steps"),295 ("WanVideoSampler", "cfg", "cfg"),296 ("LTXVScheduler", "max_shift", "max_shift"),297 ("LTXVScheduler", "base_shift", "base_shift"),298 299 # ---- rgthree primitives (often used as user-facing inputs) ----300 ("Seed (rgthree)", "seed", "seed"),301 ("Image Comparer (rgthree)", "image_a", "image"),302 ("Power Lora Loader (rgthree)", "PowerLoraLoaderHeaderWidget", "_lora_header"),303 304 # ---- Easy-use / utility primitives ----305 ("PrimitiveNode", "value", "primitive_value"),306 ("easy seed", "seed", "seed"),307 ("easy positive", "positive", "prompt"),308 ("easy negative", "negative", "negative_prompt"),309 ("easy fullLoader", "ckpt_name", "ckpt_name"),310 ("easy fullLoader", "vae_name", "vae_name"),311 ("easy fullLoader", "lora_name", "lora_name"),312 ("easy fullLoader", "positive", "prompt"),313 ("easy fullLoader", "negative", "negative_prompt"),314]315 316# Prompt-like fields whose value should be scanned for embedding references317PROMPT_FIELDS = {"text", "text_g", "text_l", "t5xxl", "clip_l", "positive", "negative"}318 319# Pattern matches: embedding:name, embedding:name.pt, embedding:name:1.2, (embedding:name:1.2)320# Word-boundary at start avoids matching things like "no_embedding:foo".321EMBEDDING_REGEX = re.compile(322 r"(?:^|[\s,(\[])embedding\s*:\s*([A-Za-z0-9_\-\./\\]+?)(?:\.(?:pt|safetensors|bin))?(?=[\s:,)\(\]]|$)",323 re.IGNORECASE,324)325 326 327# =============================================================================328# Cloud detection & endpoint routing329# =============================================================================330 331CLOUD_DOMAIN_SUFFIXES = (".comfy.org",)332CLOUD_DOMAIN_EXACT = {"cloud.comfy.org"}333 334 335def is_cloud_host(host: str) -> bool:336 """True if the host points at Comfy Cloud (or staging/preview subdomain)."""337 parsed = urlparse(host if "://" in host else f"http://{host}")338 hostname = (parsed.hostname or "").lower()339 if hostname in CLOUD_DOMAIN_EXACT:340 return True341 return any(hostname.endswith(s) for s in CLOUD_DOMAIN_SUFFIXES)342 343 344def build_cloud_aware_url(base: str, path: str, *, force_cloud: bool | None = None) -> str:345 """Build a URL that adds /api prefix when targeting Comfy Cloud.346 347 Local ComfyUI accepts both `/foo` and `/api/foo` for many endpoints.348 Cloud requires `/api/foo`.349 350 `path` should be a path component (e.g. "/prompt") or full path with query351 (e.g. "/view?filename=x").352 """353 base = base.rstrip("/")354 cloud = is_cloud_host(base) if force_cloud is None else force_cloud355 if not path.startswith("/"):356 path = "/" + path357 if cloud and not path.startswith("/api/"):358 path = "/api" + path359 return base + path360 361 362def cloud_endpoint(path: str) -> str:363 """Map a cloud endpoint path to its current canonical form.364 365 Handles known renames documented in the Comfy Cloud API:366 /history -> /history_v2367 /models/<f> -> /experiment/models/<f>368 /models -> /experiment/models369 """370 if path.startswith("/history") and not path.startswith("/history_v2"):371 return "/history_v2" + path[len("/history"):]372 if path.startswith("/models/"):373 return "/experiment/models/" + path[len("/models/"):]374 if path == "/models":375 return "/experiment/models"376 return path377 378 379def resolve_url(base: str, path: str, *, is_cloud: bool | None = None) -> str:380 """Top-level URL resolver. Applies cloud rename + /api prefix as needed."""381 cloud = is_cloud_host(base) if is_cloud is None else is_cloud382 if cloud:383 path = cloud_endpoint(path)384 return build_cloud_aware_url(base, path, force_cloud=cloud)385 386 387# =============================================================================388# API key resolution389# =============================================================================390 391def resolve_api_key(explicit: str | None) -> str | None:392 """Look up API key from CLI flag → env var. Strips whitespace and quotes."""393 val = explicit if explicit else os.environ.get(ENV_API_KEY)394 if val is None:395 return None396 val = val.strip().strip("'\"")397 return val or None398 399 400# =============================================================================401# HTTP transport402# =============================================================================403 404@dataclass405class HTTPResponse:406 status: int407 headers: dict[str, str]408 body: bytes409 url: str # final URL after redirects410 411 def text(self, encoding: str = "utf-8") -> str:412 return self.body.decode(encoding, errors="replace")413 414 def json(self) -> Any:415 return json.loads(self.body.decode("utf-8", errors="replace"))416 417 418def _sleep_backoff(attempt: int, base: float = RETRY_BASE_DELAY, cap: float = RETRY_MAX_DELAY) -> None:419 """Sleep with full-jitter exponential backoff."""420 delay = min(cap, base * (2 ** attempt))421 delay = random.uniform(0, delay)422 time.sleep(delay)423 424 425def http_request(426 method: str,427 url: str,428 *,429 headers: dict[str, str] | None = None,430 json_body: Any = None,431 data: bytes | None = None,432 files: dict | None = None,433 form: dict | None = None,434 timeout: float = DEFAULT_HTTP_TIMEOUT,435 follow_redirects: bool = True,436 retries: int = DEFAULT_RETRIES,437 stream: bool = False,438 sink: Path | None = None,439) -> HTTPResponse:440 """Single entry point for all HTTP traffic.441 442 Behavior:443 - Retries on connection errors and on HTTP statuses in RETRY_STATUS_CODES,444 with exponential backoff + jitter.445 - For cross-host redirects, drops Authorization-style headers (so signed446 URLs don't leak the API key to S3/CloudFront).447 - When `stream=True` and `sink` is a Path, streams the response body to448 disk in 64 KiB chunks instead of buffering.449 450 Either `json_body`, `data`, or `files`+`form` may be supplied (mutually exclusive).451 """452 if headers is None:453 headers = {}454 headers = dict(headers) # copy455 headers.setdefault("User-Agent", "hermes-comfyui-skill/5.0")456 457 if files or form is not None:458 # Multipart upload — needs `requests`. The stdlib fallback lacks459 # multipart encoding helpers; raise a clear error.460 if not HAS_REQUESTS:461 raise RuntimeError(462 "Multipart upload requires the `requests` package. "463 "Install with: pip install requests"464 )465 466 last_exc: Exception | None = None467 for attempt in range(retries):468 try:469 resp = _http_once(470 method=method, url=url, headers=headers,471 json_body=json_body, data=data, files=files, form=form,472 timeout=timeout, follow_redirects=follow_redirects,473 stream=stream, sink=sink,474 )475 if resp.status in RETRY_STATUS_CODES and attempt + 1 < retries:476 _sleep_backoff(attempt)477 continue478 return resp479 except (TimeoutError, ConnectionError, OSError) as e:480 last_exc = e481 if attempt + 1 < retries:482 _sleep_backoff(attempt)483 continue484 raise485 486 # Should not reach here unless retries was 0487 if last_exc:488 raise last_exc489 raise RuntimeError("http_request: retries exhausted with no response")490 491 492_SENSITIVE_HEADERS = ("x-api-key", "authorization", "cookie")493 494 495if HAS_REQUESTS:496 class _StripSensitiveOnRedirectSession(requests.Session):497 """Session that drops sensitive headers on cross-host redirects.498 499 `requests` already strips `Authorization` cross-host (rebuild_auth),500 but it does NOT strip custom headers like `X-API-Key`. We override501 `rebuild_auth` to additionally strip every header in502 `_SENSITIVE_HEADERS` when the destination is a different host —503 critical when ComfyUI Cloud's `/api/view` redirects to a signed S3 URL.504 """505 506 def rebuild_auth(self, prepared_request, response): # type: ignore[override]507 super().rebuild_auth(prepared_request, response)508 try:509 old_url = response.request.url510 new_url = prepared_request.url511 old_host = (urlparse(old_url).hostname or "").lower()512 new_host = (urlparse(new_url).hostname or "").lower()513 if old_host and new_host and old_host != new_host:514 headers = prepared_request.headers515 for key in list(headers.keys()):516 if key.lower() in _SENSITIVE_HEADERS:517 del headers[key]518 except Exception:519 # Defensive: never let header stripping break a redirect.520 pass521 522 523def _http_once(524 *, method: str, url: str, headers: dict[str, str],525 json_body: Any, data: bytes | None, files: dict | None, form: dict | None,526 timeout: float, follow_redirects: bool,527 stream: bool, sink: Path | None,528) -> HTTPResponse:529 """One HTTP attempt. No retry."""530 if HAS_REQUESTS:531 kwargs: dict[str, Any] = {532 "method": method, "url": url, "headers": headers,533 "timeout": timeout, "allow_redirects": follow_redirects,534 }535 if json_body is not None:536 kwargs["json"] = json_body537 elif data is not None:538 kwargs["data"] = data539 elif files is not None or form is not None:540 kwargs["files"] = files541 kwargs["data"] = form542 if stream:543 kwargs["stream"] = True544 545 # Use the subclass that strips sensitive headers cross-host546 with _StripSensitiveOnRedirectSession() as s:547 try:548 r = s.request(**kwargs)549 if stream and sink is not None:550 sink.parent.mkdir(parents=True, exist_ok=True)551 with sink.open("wb") as f:552 for chunk in r.iter_content(DOWNLOAD_CHUNK_SIZE):553 if chunk:554 f.write(chunk)555 body = b"" # already drained556 else:557 body = r.content558 return HTTPResponse(559 status=r.status_code,560 headers={k: v for k, v in r.headers.items()},561 body=body,562 url=r.url,563 )564 except requests.exceptions.RequestException as e:565 # Convert to TimeoutError / ConnectionError so the retry loop566 # picks them up uniformly with the stdlib path.567 if isinstance(e, requests.exceptions.Timeout):568 raise TimeoutError(str(e)) from e569 raise ConnectionError(str(e)) from e570 571 # ---------- stdlib fallback ----------572 if json_body is not None:573 body_bytes = json.dumps(json_body).encode("utf-8")574 headers.setdefault("Content-Type", "application/json")575 else:576 body_bytes = data577 req = urllib.request.Request(url, data=body_bytes, headers=headers, method=method)578 579 # urllib follows redirects by default. We need to:580 # 1) intercept cross-host redirects and drop X-API-Key581 # 2) optionally NOT follow redirects when follow_redirects=False582 class _RedirectHandler(urllib.request.HTTPRedirectHandler):583 def __init__(self, original_host: str, follow: bool):584 self.original_host = original_host585 self.follow = follow586 587 def redirect_request(self, req2, fp, code, msg, hdrs, newurl):588 if not self.follow:589 return None590 new_host = (urlparse(newurl).hostname or "").lower()591 if new_host != self.original_host:592 # Build a new request with cleaned headers593 clean_headers = {594 k: v for k, v in req2.header_items()595 if k.lower() not in {"x-api-key", "authorization", "cookie"}596 }597 new_req = urllib.request.Request(newurl, headers=clean_headers, method="GET")598 return new_req599 return super().redirect_request(req2, fp, code, msg, hdrs, newurl)600 601 original_host = (urlparse(url).hostname or "").lower()602 opener = urllib.request.build_opener(_RedirectHandler(original_host, follow_redirects))603 604 try:605 resp = opener.open(req, timeout=timeout)606 except urllib.error.HTTPError as e:607 return HTTPResponse(608 status=e.code,609 headers=dict(e.headers) if e.headers else {},610 body=e.read() or b"",611 url=getattr(e, "url", url),612 )613 614 final_url = resp.geturl()615 final_status = resp.status616 final_headers = dict(resp.headers)617 618 if stream and sink is not None:619 sink.parent.mkdir(parents=True, exist_ok=True)620 with sink.open("wb") as f:621 while True:622 chunk = resp.read(DOWNLOAD_CHUNK_SIZE)623 if not chunk:624 break625 f.write(chunk)626 return HTTPResponse(status=final_status, headers=final_headers, body=b"", url=final_url)627 628 return HTTPResponse(status=final_status, headers=final_headers, body=resp.read(), url=final_url)629 630 631def http_get(url: str, **kwargs: Any) -> HTTPResponse:632 return http_request("GET", url, **kwargs)633 634 635def http_post(url: str, **kwargs: Any) -> HTTPResponse:636 return http_request("POST", url, **kwargs)637 638 639# =============================================================================640# Workflow validation & helpers641# =============================================================================642 643def is_api_format(workflow: Any) -> bool:644 """API format = top-level dict where each value has `class_type`."""645 if not isinstance(workflow, dict):646 return False647 if "nodes" in workflow and "links" in workflow:648 return False649 for v in workflow.values():650 if isinstance(v, dict) and "class_type" in v:651 return True652 return False653 654 655def unwrap_workflow(payload: Any) -> dict:656 """Unwrap common wrapper variants. Returns API-format workflow or raises ValueError."""657 if isinstance(payload, dict) and is_api_format(payload):658 return payload659 # Some files wrap workflow under "prompt" key (e.g. saved /prompt payloads)660 if isinstance(payload, dict) and "prompt" in payload and is_api_format(payload["prompt"]):661 return payload["prompt"]662 # Editor format663 if isinstance(payload, dict) and "nodes" in payload and "links" in payload:664 raise ValueError(665 "Workflow is in editor format (has top-level 'nodes' and 'links' arrays). "666 "Re-export from ComfyUI using 'Workflow → Export (API)' (newer UI) "667 "or 'Save (API Format)' (older UI)."668 )669 raise ValueError(670 "Workflow is not in API format. Each top-level entry must have a 'class_type' field."671 )672 673 674def is_link(value: Any) -> bool:675 """True if `value` is a [node_id, output_index] connection (length-2 list)."""676 return (677 isinstance(value, list)678 and len(value) == 2679 and isinstance(value[0], str)680 and isinstance(value[1], int)681 )682 683 684def iter_nodes(workflow: dict) -> Iterator[tuple[str, dict]]:685 """Yield (node_id, node) for each valid API-format node."""686 for node_id, node in workflow.items():687 if isinstance(node, dict) and "class_type" in node:688 yield node_id, node689 690 691def iter_model_deps(workflow: dict) -> Iterator[dict]:692 """Yield {node_id, class_type, field, value, folder} for each model dependency."""693 for node_id, node in iter_nodes(workflow):694 cls = node["class_type"]695 if cls not in MODEL_LOADERS:696 continue697 inputs = node.get("inputs", {}) or {}698 for field_name, folder in MODEL_LOADERS[cls]:699 val = inputs.get(field_name)700 if val and isinstance(val, str) and not is_link(val):701 yield {702 "node_id": node_id,703 "class_type": cls,704 "field": field_name,705 "value": val,706 "folder": folder,707 }708 709 710def iter_embedding_refs(workflow: dict) -> Iterator[tuple[str, str]]:711 """Yield (node_id, embedding_name) for every embedding mention in prompts."""712 for node_id, node in iter_nodes(workflow):713 inputs = node.get("inputs", {}) or {}714 for field_name, val in inputs.items():715 if field_name not in PROMPT_FIELDS:716 continue717 if not isinstance(val, str):718 continue719 for m in EMBEDDING_REGEX.finditer(val):720 yield node_id, m.group(1)721 722 723# =============================================================================724# Path safety725# =============================================================================726 727def safe_path_join(base: Path, *parts: str) -> Path:728 """Join paths, raising if the result escapes `base`.729 730 Server-supplied filenames may contain `../` etc. This guards against731 path-traversal attacks when downloading outputs.732 """733 base_resolved = base.resolve()734 candidate = base.joinpath(*parts).resolve()735 try:736 candidate.relative_to(base_resolved)737 except ValueError as e:738 raise ValueError(739 f"Refusing path traversal: {candidate} is outside {base_resolved}"740 ) from e741 return candidate742 743 744def media_type_from_filename(filename: str) -> str:745 ext = Path(filename).suffix.lower()746 if ext in {".mp4", ".webm", ".avi", ".mov", ".mkv", ".gif", ".webp"}:747 return "video"748 if ext in {".wav", ".mp3", ".flac", ".ogg", ".m4a"}:749 return "audio"750 if ext in {".glb", ".obj", ".ply", ".gltf"}:751 return "3d"752 if ext in {".json", ".txt", ".md"}:753 return "text"754 return "image"755 756 757def looks_like_video_workflow(workflow: dict) -> bool:758 """Used to bump default timeout for video workflows."""759 for _, node in iter_nodes(workflow):760 if node["class_type"] in SLOW_OUTPUT_NODES:761 return True762 if node["class_type"].lower().startswith(("animatediff", "ade_", "wanvideo", "hunyuanvideo", "ltxvideo", "cogvideo")):763 return True764 return False765 766 767# =============================================================================768# Seed handling769# =============================================================================770 771# ComfyUI's max seed range. Many UIs treat `-1` as "randomize on submit".772SEED_MAX = 2**63 - 1773SEED_MIN = 0774 775 776def coerce_seed(value: Any) -> int:777 """Convert -1 or None to a fresh random seed; otherwise return int(value).778 779 Accepts numeric -1 OR string "-1" (both treated as "randomize"). Other780 parse failures raise TypeError/ValueError for the caller to surface.781 """782 if value is None:783 return random.randint(SEED_MIN, SEED_MAX)784 # Stringly-typed -1 from CLI / JSON should also randomize785 if isinstance(value, str) and value.strip() == "-1":786 return random.randint(SEED_MIN, SEED_MAX)787 if value == -1:788 return random.randint(SEED_MIN, SEED_MAX)789 return int(value)790 791 792# =============================================================================793# Cloud model-list normalization794# =============================================================================795 796def parse_model_list(payload: Any) -> set[str]:797 """Normalize model-list responses from local ComfyUI vs Comfy Cloud.798 799 Local: `["a.safetensors", "b.safetensors"]`800 Cloud: `[{"name": "a.safetensors", "pathIndex": 0}, ...]`801 """802 if not isinstance(payload, list):803 return set()804 out: set[str] = set()805 for item in payload:806 if isinstance(item, str):807 out.add(item)808 elif isinstance(item, dict):809 name = item.get("name") or item.get("filename") or item.get("path")810 if isinstance(name, str):811 out.add(name)812 return out813 814 815# =============================================================================816# Misc utilities817# =============================================================================818 819def new_client_id() -> str:820 return str(uuid.uuid4())821 822 823def fmt_kv(d: dict) -> str:824 """Pretty key=value for log lines."""825 return " ".join(f"{k}={v!r}" for k, v in d.items())826 827 828def emit_json(obj: Any, *, indent: int = 2) -> None:829 """Print JSON to stdout. Centralised so behavior can be tweaked (e.g., --raw)."""830 print(json.dumps(obj, indent=indent, default=str))831 832 833def log(msg: str) -> None:834 """stderr log with consistent prefix (so JSON stdout stays clean)."""835 print(f"[comfyui-skill] {msg}", file=sys.stderr)836