Files
hermes-agent/skills/creative/comfyui/scripts/hardware_check.py
SHL0MS a7780fe05f 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.)
2026-04-29 20:48:01 -07:00

498 lines
17 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""hardware_check.py — Detect whether this machine can realistically run ComfyUI locally.
Improvements over v1:
- Multi-GPU detection: scans all NVIDIA / AMD GPUs, picks the best one (most VRAM)
- Apple Silicon: detects Rosetta-via-x86_64 false negative; warns instead of misclassifying
- Apple generation: defaults to None (unknown) instead of mis-tagging as M1
- WSL2 detection: identifies WSL2 + nvidia-smi situation explicitly
- ROCm: prefers `rocm-smi --json` for new ROCm 6.x output
- Disk space check: warns if /home or workspace volume has < 25 GB free
- PyTorch verification (optional): tries to import torch and check device availability
- Windows: prefers PowerShell `Get-CimInstance` over deprecated `wmic`
- More accurate VRAM thresholds and verdict reasons
Emits a structured JSON report. Exit codes match `verdict`:
0 → ok
1 → marginal
2 → cloud
Usage:
python3 hardware_check.py [--json] [--check-pytorch]
"""
from __future__ import annotations
import json
import os
import platform
import re
import shutil
import subprocess
import sys
from typing import Any
# Thresholds (GiB).
MIN_VRAM_GB_USABLE = 6
OK_VRAM_GB = 8
GREAT_VRAM_GB = 12
MIN_MAC_RAM_GB = 16
OK_MAC_RAM_GB = 32
MIN_FREE_DISK_GB = 25 # ComfyUI core ~5 GB + one model ~524 GB
_COMFY_CLI_FLAG = {
"nvidia": "--nvidia",
"amd": "--amd",
"apple-silicon": "--m-series",
"intel": None,
"comfy-cloud": None,
"cpu": "--cpu",
}
def _run(cmd: list[str], timeout: int = 8) -> str:
try:
out = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout, check=False
)
return (out.stdout or "") + (out.stderr or "")
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return ""
def is_wsl() -> bool:
"""Return True when running under Windows Subsystem for Linux."""
if platform.system() != "Linux":
return False
if "microsoft" in platform.release().lower() or "wsl" in platform.release().lower():
return True
try:
with open("/proc/version", "r") as fh:
return "microsoft" in fh.read().lower()
except OSError:
return False
def is_rosetta() -> bool:
"""Return True when Python is running translated under Rosetta on Apple Silicon."""
if platform.system() != "Darwin":
return False
if platform.machine() == "arm64":
return False
# x86_64 on Darwin — could be Intel Mac or Rosetta. Probe sysctl.
out = _run(["sysctl", "-in", "sysctl.proc_translated"]).strip()
return out == "1"
def detect_nvidia() -> dict | None:
"""Detect NVIDIA GPUs. Returns the GPU with the most VRAM, plus list of all."""
if not shutil.which("nvidia-smi"):
return None
out = _run([
"nvidia-smi",
"--query-gpu=index,name,memory.total,driver_version",
"--format=csv,noheader,nounits",
])
if not out.strip():
return None
gpus = []
for line in out.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 3:
continue
try:
idx = int(parts[0])
name = parts[1]
vram_mb = int(parts[2])
except ValueError:
continue
driver = parts[3] if len(parts) > 3 else ""
gpus.append({
"vendor": "nvidia",
"index": idx,
"name": name,
"vram_gb": round(vram_mb / 1024, 1),
"driver": driver,
})
if not gpus:
return None
# Pick GPU with most VRAM
best = max(gpus, key=lambda g: g["vram_gb"])
if len(gpus) > 1:
best["all_gpus"] = gpus
return best
def detect_rocm() -> dict | None:
if not shutil.which("rocm-smi"):
return None
# Prefer JSON output (new ROCm 6.x)
out = _run(["rocm-smi", "--showproductname", "--showmeminfo", "vram", "--json"])
if out.strip().startswith("{"):
try:
data = json.loads(out)
cards = []
for card_id, info in data.items():
if not card_id.startswith("card"):
continue
name = (info.get("Card series") or info.get("Card model")
or info.get("Marketing Name") or "AMD GPU")
vram_b = info.get("VRAM Total Memory (B)") or info.get("vram_total_memory_b") or 0
try:
vram_b = int(vram_b)
except (ValueError, TypeError):
vram_b = 0
cards.append({
"vendor": "amd",
"name": str(name).strip(),
"vram_gb": round(vram_b / (1024**3), 1),
"driver": "rocm",
})
if cards:
best = max(cards, key=lambda c: c["vram_gb"])
if len(cards) > 1:
best["all_gpus"] = cards
return best
except json.JSONDecodeError:
pass
# Fall back to text parsing
out = _run(["rocm-smi", "--showproductname", "--showmeminfo", "vram"])
if not out.strip():
return None
name_m = re.search(r"Card (?:series|model|Marketing Name):\s*(.+)", out)
vram_m = re.search(r"VRAM Total Memory \(B\):\s*(\d+)", out)
vram_gb = round(int(vram_m.group(1)) / (1024**3), 1) if vram_m else 0.0
return {
"vendor": "amd",
"name": name_m.group(1).strip() if name_m else "AMD GPU",
"vram_gb": vram_gb,
"driver": "rocm",
}
def detect_apple_silicon() -> dict | None:
if platform.system() != "Darwin":
return None
if platform.machine() != "arm64":
return None
chip = _run(["sysctl", "-n", "machdep.cpu.brand_string"]).strip()
m = re.search(r"Apple M(\d+)", chip)
generation = int(m.group(1)) if m else None
mem_bytes = 0
try:
mem_bytes = int(_run(["sysctl", "-n", "hw.memsize"]).strip() or 0)
except ValueError:
pass
ram_gb = round(mem_bytes / (1024**3), 1) if mem_bytes else 0.0
# Detect chip variant ("Pro", "Max", "Ultra") — affects performance even at same gen
variant = None
for v in ("Ultra", "Max", "Pro"):
if v in chip:
variant = v
break
return {
"vendor": "apple",
"name": chip or "Apple Silicon",
"generation": generation,
"variant": variant,
"unified_memory_gb": ram_gb,
}
def detect_intel_arc() -> dict | None:
if platform.system() not in ("Linux", "Windows"):
return None
if shutil.which("clinfo"):
out = _run(["clinfo", "--list"])
if "Intel" in out and ("Arc" in out or "Xe" in out):
return {"vendor": "intel", "name": "Intel Arc/Xe", "vram_gb": 0.0}
# Windows: try Get-CimInstance
if platform.system() == "Windows" and shutil.which("powershell"):
out = _run(["powershell", "-NoProfile",
"Get-CimInstance Win32_VideoController | Select-Object Name | Format-List"])
if "Intel" in out and ("Arc" in out or "Iris Xe" in out):
return {"vendor": "intel", "name": "Intel Arc/Iris Xe", "vram_gb": 0.0}
return None
def total_system_ram_gb() -> float:
sysname = platform.system()
if sysname == "Darwin":
try:
return round(int(_run(["sysctl", "-n", "hw.memsize"]).strip() or 0) / (1024**3), 1)
except ValueError:
return 0.0
if sysname == "Linux":
try:
with open("/proc/meminfo", "r") as fh:
for line in fh:
if line.startswith("MemTotal:"):
kb = int(line.split()[1])
return round(kb / (1024**2), 1)
except OSError:
return 0.0
if sysname == "Windows":
if shutil.which("powershell"):
out = _run([
"powershell", "-NoProfile",
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
])
m = re.search(r"(\d{8,})", out)
if m:
return round(int(m.group(1)) / (1024**3), 1)
# Fall back to wmic for older Windows
out = _run(["wmic", "ComputerSystem", "get", "TotalPhysicalMemory"])
m = re.search(r"(\d{6,})", out)
if m:
return round(int(m.group(1)) / (1024**3), 1)
return 0.0
def total_free_disk_gb(path: str = ".") -> float:
try:
usage = shutil.disk_usage(path)
return round(usage.free / (1024**3), 1)
except OSError:
return 0.0
def check_pytorch_cuda() -> dict | None:
"""Optional PyTorch availability check. Only run when --check-pytorch is set."""
try:
import torch # type: ignore[import-not-found]
except Exception as e:
return {"available": False, "reason": f"torch not importable: {e}"}
info: dict[str, Any] = {
"available": True,
"torch_version": torch.__version__,
}
try:
info["cuda_available"] = bool(torch.cuda.is_available())
if info["cuda_available"]:
info["cuda_device_count"] = torch.cuda.device_count()
info["cuda_device_0"] = torch.cuda.get_device_name(0)
except Exception:
info["cuda_available"] = False
try:
info["mps_available"] = bool(torch.backends.mps.is_available())
except Exception:
info["mps_available"] = False
return info
def classify(gpu: dict | None, ram_gb: float, free_disk_gb: float, *, wsl: bool, rosetta: bool) -> tuple[str, str, list[str]]:
notes: list[str] = []
if rosetta:
notes.append(
"Detected Python running under Rosetta on Apple Silicon. "
"ComfyUI MPS support requires native ARM64 Python — install via "
"`brew install python` or arm64 Miniforge, then re-run."
)
return "cloud", "comfy-cloud", notes
if wsl and gpu and gpu["vendor"] == "nvidia":
notes.append("Detected WSL2 + NVIDIA — confirm `nvidia-smi` works in your WSL distro before installing.")
if free_disk_gb and free_disk_gb < MIN_FREE_DISK_GB:
notes.append(
f"Free disk space ({free_disk_gb} GB) is below the {MIN_FREE_DISK_GB} GB recommended minimum. "
"ComfyUI core (~5 GB) plus one SDXL model (~6.5 GB) needs space; Flux Dev needs ~24 GB."
)
# Host RAM matters even for discrete-GPU systems: ComfyUI swaps model
# weights through CPU RAM when shuffling between text encoders / VAE / UNet.
# Apple's unified-memory check is handled below so don't double-warn.
if ram_gb and ram_gb < 8 and gpu and gpu.get("vendor") != "apple":
notes.append(
f"System RAM ({ram_gb} GB) is low. ComfyUI swaps model weights through "
"host RAM; <8 GB causes severe slowdowns. 16+ GB recommended."
)
if gpu is None:
notes.append(
"No supported accelerator found (NVIDIA CUDA / AMD ROCm / Apple Silicon / Intel Arc)."
)
notes.append(
"CPU-only ComfyUI works but is unusably slow for modern models — use Comfy Cloud."
)
return "cloud", "comfy-cloud", notes
if gpu["vendor"] == "apple":
gen = gpu.get("generation")
variant = gpu.get("variant")
mem = gpu.get("unified_memory_gb", 0.0)
gen_str = f"M{gen}" if gen else "Apple Silicon"
if variant:
gen_str += f" {variant}"
if mem < MIN_MAC_RAM_GB:
notes.append(
f"{gen_str} with {mem} GB unified memory — below the {MIN_MAC_RAM_GB} GB practical minimum."
)
notes.append("SD1.5 may work; SDXL/Flux will swap or OOM. Recommend Comfy Cloud.")
return "cloud", "comfy-cloud", notes
if mem < OK_MAC_RAM_GB:
notes.append(
f"{gen_str} with {mem} GB — SDXL works but slow. Flux/video likely too tight."
)
return "marginal", "apple-silicon", notes
notes.append(f"{gen_str} with {mem} GB unified memory — good for SDXL/Flux.")
return "ok", "apple-silicon", notes
if gpu["vendor"] == "intel":
notes.append("Intel Arc detected — ComfyUI IPEX support is experimental; Comfy Cloud is more reliable.")
return "marginal", "intel", notes
# Discrete NVIDIA / AMD
vram = gpu.get("vram_gb", 0.0)
name = gpu["name"]
if vram < MIN_VRAM_GB_USABLE:
notes.append(
f"{name} has only {vram} GB VRAM — below the {MIN_VRAM_GB_USABLE} GB practical minimum."
)
notes.append("Most modern models won't load. Recommend Comfy Cloud.")
return "cloud", "comfy-cloud", notes
if vram < OK_VRAM_GB:
notes.append(
f"{name} ({vram} GB VRAM) — SD1.5 works, SDXL tight, Flux/video unlikely."
)
return "marginal", gpu["vendor"], notes
if vram < GREAT_VRAM_GB:
notes.append(f"{name} ({vram} GB VRAM) — SDXL comfortable, Flux possible with optimizations.")
return "ok", gpu["vendor"], notes
notes.append(f"{name} ({vram} GB VRAM) — can run everything including Flux/video.")
return "ok", gpu["vendor"], notes
def build_report(*, check_pytorch: bool = False) -> dict:
sysname = platform.system()
arch = platform.machine()
ram_gb = total_system_ram_gb()
free_disk_gb = total_free_disk_gb(os.path.expanduser("~"))
rosetta = is_rosetta()
wsl = is_wsl()
gpu = (
detect_nvidia()
or detect_rocm()
or detect_apple_silicon()
or detect_intel_arc()
)
# Intel Mac: arm64 detect failed AND no other GPU paths
if gpu is None and sysname == "Darwin" and arch != "arm64" and not rosetta:
notes = [
"Intel Mac detected — no MPS backend available.",
"ComfyUI will fall back to CPU which is unusably slow. Use Comfy Cloud.",
]
report = {
"os": sysname,
"arch": arch,
"system_ram_gb": ram_gb,
"free_disk_gb": free_disk_gb,
"wsl": False,
"rosetta": False,
"gpu": None,
"verdict": "cloud",
"recommended_install_path": "comfy-cloud",
"comfy_cli_flag": None,
"notes": notes,
"install_urls": _install_urls(),
}
if check_pytorch:
report["pytorch"] = check_pytorch_cuda()
return report
verdict, install_path, notes = classify(
gpu, ram_gb, free_disk_gb, wsl=wsl, rosetta=rosetta,
)
report = {
"os": sysname,
"arch": arch,
"system_ram_gb": ram_gb,
"free_disk_gb": free_disk_gb,
"wsl": wsl,
"rosetta": rosetta,
"gpu": gpu,
"verdict": verdict,
"recommended_install_path": install_path,
"comfy_cli_flag": _COMFY_CLI_FLAG.get(install_path),
"notes": notes,
"install_urls": _install_urls(),
}
if check_pytorch:
report["pytorch"] = check_pytorch_cuda()
return report
def _install_urls() -> dict:
return {
"desktop": "https://docs.comfy.org/installation/desktop",
"manual": "https://docs.comfy.org/installation/manual_install",
"comfy_cli": "https://docs.comfy.org/comfy-cli/getting-started",
"cloud": "https://platform.comfy.org",
}
def main(argv: list[str] | None = None) -> int:
import argparse
p = argparse.ArgumentParser(description="Check whether this machine can run ComfyUI locally.")
p.add_argument("--json", action="store_true", help="Emit machine-readable JSON only")
p.add_argument("--check-pytorch", action="store_true",
help="Also probe `torch` for CUDA/MPS availability (slower)")
args = p.parse_args(argv)
report = build_report(check_pytorch=args.check_pytorch)
if args.json:
print(json.dumps(report, indent=2))
else:
print(f"OS: {report['os']} ({report['arch']})")
if report.get("wsl"):
print("Env: WSL2")
if report.get("rosetta"):
print("Env: Rosetta (x86_64 Python on Apple Silicon)")
print(f"RAM: {report['system_ram_gb']} GB")
print(f"Free disk: {report['free_disk_gb']} GB (~/)")
if report["gpu"]:
g = report["gpu"]
if g["vendor"] == "apple":
print(f"GPU: {g['name']}{g.get('unified_memory_gb', 0)} GB unified memory")
else:
print(f"GPU: {g['name']}{g.get('vram_gb', 0)} GB VRAM")
if g.get("all_gpus") and len(g["all_gpus"]) > 1:
print(f" ({len(g['all_gpus'])} GPUs total; using best by VRAM)")
else:
print("GPU: (none detected)")
print(f"Verdict: {report['verdict']}{report['recommended_install_path']}")
if report["comfy_cli_flag"]:
print(f" run: comfy --skip-prompt install {report['comfy_cli_flag']}")
if report.get("pytorch"):
pt = report["pytorch"]
if pt.get("available"):
line = f"PyTorch: {pt.get('torch_version')}"
if pt.get("cuda_available"):
line += f" + CUDA ({pt.get('cuda_device_0', '?')})"
if pt.get("mps_available"):
line += " + MPS"
print(line)
else:
print(f"PyTorch: not available — {pt.get('reason')}")
for n in report["notes"]:
print(f"{n}")
if report["verdict"] == "ok":
return 0
if report["verdict"] == "marginal":
return 1
return 2
if __name__ == "__main__":
sys.exit(main())