scripts/ws_monitor.py
scripts/ws_monitor.pyBrowse 33 files
2,476 tokens
10,512 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""3ws_monitor.py — Real-time ComfyUI WebSocket monitor.4 5Connects to /ws and pretty-prints execution events: node start/finish, sampling6progress, cached nodes, errors. Optionally writes preview frames to disk.7 8Useful for:9 - Watching a long-running job in real time without parsing JSON yourself10 - Saving in-progress preview frames for video / animation workflows11 - Debugging "why is this hanging?" — see exactly which node is stuck12 13Usage:14 # Local — watch all jobs from this client_id15 python3 ws_monitor.py16 17 # Cloud — watch a specific prompt_id18 python3 ws_monitor.py --host https://cloud.comfy.org \19 --prompt-id abc-123-def20 21 # Save preview frames to ./previews/22 python3 ws_monitor.py --previews ./previews23 24Requires: websocket-client (`pip install websocket-client`).25Falls back to a clear error message when not installed.26"""27 28from __future__ import annotations29 30import argparse31import json32import struct33import sys34from pathlib import Path35from urllib.parse import urlparse36 37sys.path.insert(0, str(Path(__file__).resolve().parent))38from _common import ( # noqa: E40239 DEFAULT_LOCAL_HOST, ENV_API_KEY, log, new_client_id, resolve_api_key, is_cloud_host,40)41 42 43# Binary frame types from ComfyUI WebSocket protocol44BINARY_PREVIEW_IMAGE = 145BINARY_TEXT = 346BINARY_PREVIEW_IMAGE_WITH_METADATA = 447 48# Image type codes inside PREVIEW_IMAGE49IMAGE_TYPE_JPEG = 150IMAGE_TYPE_PNG = 251 52# ANSI escape codes (works on most modern terminals)53RESET = "\033[0m"54DIM = "\033[2m"55BOLD = "\033[1m"56GREEN = "\033[32m"57YELLOW = "\033[33m"58RED = "\033[31m"59CYAN = "\033[36m"60 61 62def fmt_color(s: str, color: str, *, color_on: bool = True) -> str:63 return f"{color}{s}{RESET}" if color_on else s64 65 66def parse_binary_frame(data: bytes) -> dict | None:67 if len(data) < 8:68 return None69 type_code = struct.unpack(">I", data[0:4])[0]70 if type_code == BINARY_PREVIEW_IMAGE:71 image_type = struct.unpack(">I", data[4:8])[0]72 ext = "jpg" if image_type == IMAGE_TYPE_JPEG else "png" if image_type == IMAGE_TYPE_PNG else "bin"73 return {74 "kind": "preview",75 "image_type": image_type,76 "ext": ext,77 "image_bytes": data[8:],78 }79 if type_code == BINARY_PREVIEW_IMAGE_WITH_METADATA:80 if len(data) < 12:81 return None82 meta_len = struct.unpack(">I", data[4:8])[0]83 meta_end = 8 + meta_len84 if len(data) < meta_end:85 return None86 try:87 meta = json.loads(data[8:meta_end].decode("utf-8"))88 except Exception:89 meta = {"raw": data[8:meta_end][:200].decode("utf-8", "replace")}90 return {91 "kind": "preview_with_metadata",92 "metadata": meta,93 "image_bytes": data[meta_end:],94 "ext": "png",95 }96 if type_code == BINARY_TEXT:97 if len(data) < 8:98 return None99 nid_len = struct.unpack(">I", data[4:8])[0]100 nid_end = 8 + nid_len101 if len(data) < nid_end:102 return None103 return {104 "kind": "text",105 "node_id": data[8:nid_end].decode("utf-8", "replace"),106 "text": data[nid_end:].decode("utf-8", "replace"),107 }108 return {"kind": "unknown", "type_code": type_code, "size": len(data)}109 110 111def main(argv: list[str] | None = None) -> int:112 p = argparse.ArgumentParser(description="Real-time ComfyUI WebSocket monitor")113 p.add_argument("--host", default=DEFAULT_LOCAL_HOST, help="ComfyUI server URL")114 p.add_argument("--api-key", help=f"API key for cloud (or set ${ENV_API_KEY} env var)")115 p.add_argument("--client-id", default=None, help="Client ID (default: random UUID)")116 p.add_argument("--prompt-id", default=None,117 help="Filter to a specific prompt_id (default: all jobs)")118 p.add_argument("--previews", default=None,119 help="Directory to save in-progress preview frames")120 p.add_argument("--no-color", action="store_true", help="Disable ANSI colour")121 p.add_argument("--timeout", type=float, default=600.0,122 help="Hard cap on monitor duration (default 600s)")123 args = p.parse_args(argv)124 125 try:126 import websocket # type: ignore[import-not-found]127 except ImportError:128 print(json.dumps({129 "error": "websocket-client not installed",130 "install": "pip install websocket-client",131 }))132 return 1133 134 api_key = resolve_api_key(args.api_key)135 cloud = is_cloud_host(args.host)136 client_id = args.client_id or new_client_id()137 138 # Build WS URL preserving any base-path component (e.g. behind reverse proxy).139 parsed = urlparse(args.host if "://" in args.host else f"http://{args.host}")140 scheme = "wss" if parsed.scheme == "https" else "ws"141 netloc = parsed.netloc142 base_path = parsed.path.rstrip("/")143 ws_url = f"{scheme}://{netloc}{base_path}/ws?clientId={client_id}"144 if cloud and api_key:145 ws_url += f"&token={api_key}"146 147 color_on = not args.no_color and sys.stdout.isatty()148 149 preview_dir = Path(args.previews).expanduser() if args.previews else None150 if preview_dir:151 preview_dir.mkdir(parents=True, exist_ok=True)152 log(f"Saving previews to {preview_dir}")153 154 log(f"Connecting to {ws_url} (client_id={client_id})")155 if args.prompt_id:156 log(f"Filtering messages to prompt_id={args.prompt_id}")157 158 ws = websocket.create_connection(ws_url, timeout=args.timeout)159 ws.settimeout(args.timeout)160 161 preview_counter = 0162 try:163 while True:164 try:165 msg = ws.recv()166 except websocket.WebSocketTimeoutException:167 log(f"Idle for {args.timeout}s — exiting")168 return 0169 if isinstance(msg, bytes):170 parsed = parse_binary_frame(msg)171 if parsed is None:172 continue173 if parsed["kind"] in {"preview", "preview_with_metadata"} and preview_dir:174 img_bytes = parsed.get("image_bytes", b"")175 if img_bytes:176 ext = parsed.get("ext", "png")177 out = preview_dir / f"preview_{preview_counter:05d}.{ext}"178 out.write_bytes(img_bytes)179 preview_counter += 1180 log(f" [preview] saved {out.name} ({len(img_bytes)} bytes)")181 continue182 183 try:184 payload = json.loads(msg)185 except Exception:186 continue187 mtype = payload.get("type", "")188 mdata = payload.get("data", {}) or {}189 pid = mdata.get("prompt_id")190 191 if args.prompt_id and pid and pid != args.prompt_id:192 continue193 194 if mtype == "status":195 qr = mdata.get("status", {}).get("exec_info", {}).get("queue_remaining", "?")196 print(fmt_color(f"[status] queue_remaining={qr}", DIM, color_on=color_on))197 elif mtype == "execution_start":198 print(fmt_color(f"[start] prompt_id={pid}", BOLD, color_on=color_on))199 elif mtype == "executing":200 node = mdata.get("node")201 if node:202 print(fmt_color(f" [executing] node={node}", CYAN, color_on=color_on))203 else:204 print(fmt_color(f" [executing] (workflow done) prompt_id={pid}", DIM, color_on=color_on))205 elif mtype == "progress":206 v, m = mdata.get("value", 0), mdata.get("max", 0)207 pct = (v / m * 100) if m else 0208 print(f" [progress] {v}/{m} ({pct:5.1f}%) node={mdata.get('node')}")209 elif mtype == "progress_state":210 # Newer extended progress message211 nodes = mdata.get("nodes") or {}212 running = [k for k, v in nodes.items() if v.get("running")]213 if running:214 print(fmt_color(f" [progress_state] running={running}", DIM, color_on=color_on))215 elif mtype == "executed":216 node = mdata.get("node")217 out = mdata.get("output") or {}218 summary_parts = []219 for key in ("images", "video", "videos", "gifs", "audio", "files"):220 if out.get(key):221 summary_parts.append(f"{key}={len(out[key])}")222 summary = ", ".join(summary_parts) if summary_parts else "(no files)"223 print(fmt_color(f" [executed] node={node} {summary}", GREEN, color_on=color_on))224 elif mtype == "execution_cached":225 cached = mdata.get("nodes") or []226 if cached:227 print(fmt_color(f" [cached] {len(cached)} nodes skipped", DIM, color_on=color_on))228 elif mtype == "execution_success":229 print(fmt_color(f"[success] prompt_id={pid}", GREEN + BOLD, color_on=color_on))230 if args.prompt_id:231 return 0232 elif mtype == "execution_error":233 exc_type = mdata.get("exception_type", "?")234 exc_msg = mdata.get("exception_message", "?")235 print(fmt_color(f"[error] {exc_type}: {exc_msg}", RED + BOLD, color_on=color_on))236 tb = mdata.get("traceback")237 if tb:238 if isinstance(tb, list):239 for line in tb:240 print(fmt_color(f" {line}", RED, color_on=color_on))241 else:242 print(fmt_color(f" {tb}", RED, color_on=color_on))243 if args.prompt_id:244 return 1245 elif mtype == "execution_interrupted":246 print(fmt_color(f"[interrupted] prompt_id={pid}", YELLOW, color_on=color_on))247 if args.prompt_id:248 return 1249 elif mtype == "notification":250 v = mdata.get("value", "")251 print(fmt_color(f"[notification] {v}", DIM, color_on=color_on))252 else:253 # Unknown / lightly-used types: print compactly254 print(fmt_color(f"[{mtype}] {json.dumps(mdata, default=str)[:200]}", DIM, color_on=color_on))255 256 except KeyboardInterrupt:257 log("Interrupted")258 return 130259 finally:260 try:261 ws.close()262 except Exception:263 pass264 265 266if __name__ == "__main__":267 sys.exit(main())268