#!/usr/bin/env python3 """Run the self-contained continuous Comfy Platform HLS demo.""" from __future__ import annotations import argparse import json import math import os import secrets import shutil import signal import socket import subprocess import tempfile import threading import time import urllib.parse import urllib.request import uuid import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] WORKFLOW_PATH = ROOT / "examples" / "basic_text_to_video_api.json" HLS_PLAYER_PATH = ROOT / "examples" / "vendor" / "hls.light.min.js" PROMPT_NODE = "5" SAMPLER_NODE = "8" OUTPUT_NODE = "12" MAX_PROMPT_LENGTH = 12_000 MAX_PLAYLIST_SEGMENTS = 180 # AAC priming and trailing packets can extend beyond the video EXTINF duration. # Keep the next clip's DTS clear of that encoder padding for both media tracks. CLIP_BOUNDARY_SAFETY_SECONDS = 0.1 RETIRED_SEGMENT_GRACE_SECONDS = 60 RESOLUTION_PROFILES = { "448x256": {"width": 448, "height": 256, "gpuLabel": "RTX 5090"}, "512x320": {"width": 512, "height": 320, "gpuLabel": "RTX 6000 Pro"}, } PAGE = r''' ComfyStreamerH3 Live
COMFYSTREAMERH3

Continuous live video

Finding an existing ready GPU…
''' def _cli_error(stdout: str, stderr: str) -> str: for line in reversed(stdout.splitlines()): try: envelope = json.loads(line) except json.JSONDecodeError: continue error = envelope.get("error") if isinstance(error, dict) and isinstance(error.get("message"), str): return error["message"] return (stderr or stdout).strip()[-1500:] def _gpu_resolution(gpu_class: object) -> str | None: if not isinstance(gpu_class, str): return None normalized = gpu_class.lower().replace("_", "-") if "5090" in normalized: return "448x256" if "6000" in normalized: return "512x320" return None def _ready_gpu_profiles(deployments: list[object]) -> dict[str, dict[str, Any]]: candidates: dict[str, list[tuple[str, str, str]]] = {key: [] for key in RESOLUTION_PROFILES} for deployment in deployments: if not isinstance(deployment, dict) or deployment.get("status") != "ready": continue compute = deployment.get("computeConfig") gpu_class = compute.get("gpuClass") if isinstance(compute, dict) else None resolution = _gpu_resolution(gpu_class) deployment_id = deployment.get("id") endpoint_url = deployment.get("endpointUrl") if ( resolution in candidates and isinstance(deployment_id, str) and deployment_id and isinstance(endpoint_url, str) and endpoint_url ): candidates[resolution].append((str(deployment.get("updatedAt") or ""), deployment_id, endpoint_url)) profiles: dict[str, dict[str, Any]] = {} for resolution, entries in candidates.items(): if not entries: continue _, deployment_id, endpoint_url = max(entries, key=lambda entry: entry[0]) profiles[resolution] = { **RESOLUTION_PROFILES[resolution], "deploymentId": deployment_id, "deploymentUrl": endpoint_url, } return profiles def _cli_env() -> dict[str, str]: env = os.environ.copy() env.pop("COMFY_API_KEY", None) env.pop("COMFY_CLOUD_API_KEY", None) return env def discover_ready_gpu_profiles() -> dict[str, dict[str, Any]]: """Find existing ready workspace GPUs with the user's Comfy CLI login.""" result = subprocess.run( ["comfy", "--json", "deploy", "ls", "--workspace", "--status", "ready"], cwd=ROOT, env=_cli_env(), capture_output=True, text=True, timeout=30, check=False, ) if result.returncode: raise RuntimeError(_cli_error(result.stdout, result.stderr) or "Comfy Platform discovery failed") envelope = json.loads(result.stdout) if not envelope.get("ok"): raise RuntimeError(_cli_error(result.stdout, result.stderr) or "Comfy Platform discovery failed") deployments = envelope.get("data", {}).get("deployments") if not isinstance(deployments, list): raise ValueError("Comfy CLI returned an invalid deployment list") return _ready_gpu_profiles(deployments) class ContinuousDemo: def __init__(self, profiles: dict[str, dict[str, Any]], default_prompt: str, root: Path): self.profiles = profiles self.default_resolution = "512x320" if "512x320" in profiles else "448x256" self.root = root self.media_root = root / "media" self.media_root.mkdir(parents=True) self.lock = threading.RLock() self.stopping = threading.Event() self.requested = {"prompt": default_prompt, "resolution": self.default_resolution} self.entries: list[tuple[int, float, str, bool]] = [] self.retired_segments: list[tuple[float, Path]] = [] self.next_sequence = 0 self.discontinuity_sequence = 0 self.total_duration_seconds = 0.0 self.previous_resolution: str | None = None self.attempt_count = 0 self.clip_count = 0 self.status = "Generating the default prompt…" self.error: str | None = None self.active_process: subprocess.Popen[str] | None = None self.worker = threading.Thread(target=self._run_forever, name="h3-continuous-generator", daemon=True) def start(self) -> None: self.worker.start() def request(self, prompt: str, resolution: str) -> None: if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > MAX_PROMPT_LENGTH: raise ValueError(f"prompt must contain 1 to {MAX_PROMPT_LENGTH} characters") if resolution not in self.profiles: raise ValueError("select a resolution with a ready GPU") with self.lock: self.requested = {"prompt": prompt.strip(), "resolution": resolution} self.status = "Prompt queued; current clip will finish first." self.error = None def snapshot(self) -> dict[str, Any]: with self.lock: return { "profiles": { key: {"width": value["width"], "height": value["height"], "gpuLabel": value["gpuLabel"]} for key, value in self.profiles.items() }, "defaultResolution": self.requested["resolution"], "prompt": self.requested["prompt"], "status": self.status, "error": self.error, "playable": self.clip_count >= 2, "clips": self.clip_count, "segments": len(self.entries), } def playlist(self) -> str: with self.lock: entries = list(self.entries) playable = self.clip_count >= 2 discontinuity_sequence = self.discontinuity_sequence if not playable or not entries: return "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n#EXT-X-MEDIA-SEQUENCE:0\n" target = max(2, math.ceil(max(entry[1] for entry in entries))) lines = [ "#EXTM3U", "#EXT-X-VERSION:3", f"#EXT-X-TARGETDURATION:{target}", f"#EXT-X-MEDIA-SEQUENCE:{entries[0][0]}", f"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuity_sequence}", "#EXT-X-INDEPENDENT-SEGMENTS", ] for _sequence, duration, uri, discontinuity in entries: if discontinuity: lines.append("#EXT-X-DISCONTINUITY") lines.extend((f"#EXTINF:{duration:.3f},", uri)) return "\n".join(lines) + "\n" def stop(self) -> None: self.stopping.set() with self.lock: process = self.active_process if process and process.poll() is None: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait() if self.worker.is_alive(): self.worker.join() def _run_process( self, command: list[str], *, timeout: float, env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: with self.lock: if self.stopping.is_set(): raise InterruptedError("demo is stopping") process = subprocess.Popen( command, cwd=ROOT, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) self.active_process = process try: stdout, stderr = process.communicate(timeout=timeout) except subprocess.TimeoutExpired: process.kill() process.communicate() raise RuntimeError(f"{command[0]} timed out after {timeout:g} seconds") finally: with self.lock: self.active_process = None if self.stopping.is_set(): raise InterruptedError("demo is stopping") return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) def _run_forever(self) -> None: while not self.stopping.is_set(): with self.lock: request = dict(self.requested) self.attempt_count += 1 clip_number = self.attempt_count self.status = f"Generating {request['resolution']} on {self.profiles[request['resolution']]['gpuLabel']}…" self.error = None work = self.root / f"clip-{clip_number:06d}" try: work.mkdir() video = self._generate(request, work) with self.lock: timestamp_offset = ( self.total_duration_seconds + self.clip_count * CLIP_BOUNDARY_SAFETY_SECONDS ) published = self._segment(video, clip_number, timestamp_offset) with self.lock: self.clip_count += 1 discontinuity = ( self.previous_resolution is not None and request["resolution"] != self.previous_resolution ) for index, duration, uri in published: self.entries.append((self.next_sequence, duration, uri, discontinuity and index == 0)) self.next_sequence += 1 self.total_duration_seconds += sum(duration for _, duration, _ in published) self.previous_resolution = request["resolution"] if len(self.entries) > MAX_PLAYLIST_SEGMENTS: removed = self.entries[:-MAX_PLAYLIST_SEGMENTS] self.entries = self.entries[-MAX_PLAYLIST_SEGMENTS:] self.discontinuity_sequence += sum(1 for entry in removed if entry[3]) for _, _, uri, _ in removed: self.retired_segments.append(( time.monotonic() + RETIRED_SEGMENT_GRACE_SECONDS, self.root / uri, )) now = time.monotonic() remaining = [] for deadline, path in self.retired_segments: if deadline <= now: path.unlink(missing_ok=True) else: remaining.append((deadline, path)) self.retired_segments = remaining self.status = "Live — generating the next clip." self.error = None except Exception as error: if self.stopping.is_set(): break with self.lock: self.status = "Generation failed; retrying." self.error = str(error)[:1200] if self.stopping.wait(5): break finally: shutil.rmtree(work, ignore_errors=True) def _generate(self, request: dict[str, str], work: Path) -> Path: workflow = json.loads(WORKFLOW_PATH.read_text())["prompt"] profile = self.profiles[request["resolution"]] inputs = workflow[PROMPT_NODE]["inputs"] inputs.update(prompt=request["prompt"], width=profile["width"], height=profile["height"]) workflow[SAMPLER_NODE]["inputs"]["run_nonce"] = f"local-continuous-{uuid.uuid4().hex}" workflow[SAMPLER_NODE]["inputs"]["seed"] = secrets.randbelow(2**32) workflow[OUTPUT_NODE]["inputs"]["filename_prefix"] = f"h3-live/{uuid.uuid4().hex}" workflow_path = work / "workflow.json" output_dir = work / "outputs" workflow_path.write_text(json.dumps(workflow)) output_dir.mkdir() command = [ "comfy", "--json", "deploy", "run", "--workflow", str(workflow_path), "--deployment", profile["deploymentId"], "--output-dir", str(output_dir), "--timeout", "900", ] result = self._run_process(command, env=_cli_env(), timeout=960) if result.returncode: raise RuntimeError(_cli_error(result.stdout, result.stderr) or "Comfy Platform generation failed") videos = sorted(path for path in output_dir.rglob("*") if path.is_file() and path.suffix.lower() == ".mp4") if not videos: raise RuntimeError("Comfy Platform completed without returning an MP4") return videos[0] def _segment(self, video: Path, clip_number: int, timestamp_offset: float) -> list[tuple[int, float, str]]: clip_dir = self.media_root / f"clip-{clip_number:06d}" clip_dir.mkdir() local_playlist = clip_dir / "clip.m3u8" segment_pattern = clip_dir / "segment-%05d.ts" result = self._run_process([ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(video), "-map", "0:v:0", "-map", "0:a?", "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-pix_fmt", "yuv420p", "-g", "24", "-keyint_min", "24", "-sc_threshold", "0", "-c:a", "aac", "-b:a", "128k", "-f", "hls", "-hls_time", "1", "-hls_list_size", "0", "-hls_flags", "independent_segments", "-output_ts_offset", f"{timestamp_offset:.6f}", "-hls_segment_filename", str(segment_pattern), str(local_playlist), ], timeout=300) if result.returncode: raise RuntimeError((result.stderr or "FFmpeg could not segment the generated video")[-1200:]) published: list[tuple[int, float, str]] = [] lines = local_playlist.read_text().splitlines() for index, line in enumerate(lines): if not line or line.startswith("#"): continue segment = (clip_dir / line).resolve() if not segment.is_relative_to(self.media_root.resolve()) or not segment.is_file(): raise RuntimeError("FFmpeg returned an invalid segment path") duration_line = None if index > 0 and lines[index - 1].startswith("#EXTINF:"): duration_line = lines[index - 1] duration = float(duration_line.split(":", 1)[1].split(",", 1)[0]) if duration_line else 1.0 uri = segment.relative_to(self.root.resolve()).as_posix() published.append((len(published), duration, uri)) if not published: raise RuntimeError("FFmpeg produced no HLS segments") return published def make_handler(demo: ContinuousDemo) -> type[BaseHTTPRequestHandler]: class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: path = urllib.parse.urlsplit(self.path).path if path in {"/", "/live"}: self._send(200, PAGE.encode(), "text/html; charset=utf-8") elif path == "/healthz": self._send(200, b'{"ok":true}', "application/json") elif path == "/static/hls.light.min.js": self._send(200, HLS_PLAYER_PATH.read_bytes(), "application/javascript") elif path == "/api/state": self._json(200, demo.snapshot()) elif path == "/live.m3u8": self._send(200, demo.playlist().encode(), "application/vnd.apple.mpegurl") elif path.startswith("/media/"): relative = urllib.parse.unquote(path.removeprefix("/media/")) target = (demo.media_root / relative).resolve() if not target.is_relative_to(demo.media_root.resolve()): self.send_error(404) return try: body = target.read_bytes() except (FileNotFoundError, IsADirectoryError): self.send_error(404) return self._send(200, body, "video/mp2t") else: self.send_error(404) def do_POST(self) -> None: if urllib.parse.urlsplit(self.path).path != "/api/run": self.send_error(404) return try: size = int(self.headers.get("Content-Length", "0")) if not 0 < size <= MAX_PROMPT_LENGTH + 256: raise ValueError("request body is missing or too large") request = json.loads(self.rfile.read(size)) if not isinstance(request, dict): raise ValueError("request body must be an object") demo.request(request.get("prompt"), request.get("resolution")) self._json(202, {"message": "Prompt queued; current clip will finish first."}) except (ValueError, TypeError, json.JSONDecodeError) as error: self._json(400, {"error": str(error)}) def _json(self, status: int, value: Any) -> None: self._send(status, json.dumps(value).encode(), "application/json; charset=utf-8") def _send(self, status: int, body: bytes, content_type: str) -> None: self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def log_message(self, _format: str, *_args: object) -> None: return return Handler def main() -> int: parser = argparse.ArgumentParser(description="Run the self-contained continuous H3 live viewer.") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8765) parser.add_argument("--no-browser", action="store_true") args = parser.parse_args() if shutil.which("comfy") is None: raise SystemExit("Install and sign in to comfy-cli before starting this demo.") if shutil.which("ffmpeg") is None: raise SystemExit("Install ffmpeg before starting this demo.") if not HLS_PLAYER_PATH.is_file(): raise SystemExit("The repo-local HLS player file is missing.") profiles = discover_ready_gpu_profiles() if not profiles: raise SystemExit("No ready RTX 5090 or RTX 6000 Pro deployment was found by Comfy CLI.") with socket.socket() as probe: probe.settimeout(0.2) if probe.connect_ex((args.host, args.port)) == 0: raise SystemExit(f"Port {args.port} is already in use. Stop the current demo first.") workflow = json.loads(WORKFLOW_PATH.read_text())["prompt"] default_prompt = workflow[PROMPT_NODE]["inputs"]["prompt"] state_dir = Path(tempfile.mkdtemp(prefix="comfystreamerh3-live-")) demo = ContinuousDemo(profiles, default_prompt, state_dir) server = ThreadingHTTPServer((args.host, args.port), make_handler(demo)) base_url = f"http://{args.host}:{args.port}" demo.start() print("Ready GPU profiles: " + ", ".join(profile["gpuLabel"] for profile in profiles.values()), flush=True) print(f"Continuous video UI: {base_url}/live", flush=True) if not args.no_browser: webbrowser.open_new_tab(base_url + "/live") def interrupt(_signum: int, _frame: Any) -> None: raise KeyboardInterrupt signal.signal(signal.SIGTERM, interrupt) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.shutdown() server.server_close() demo.stop() shutil.rmtree(state_dir, ignore_errors=True) return 0 if __name__ == "__main__": raise SystemExit(main())