| """ComfyUI library-mode backend. |
| |
| Single-process, single-implementation. The @spaces.GPU decorator is the only |
| divergence between local and HF Spaces deployment. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import contextvars |
| import os |
| import pathlib |
| import sys |
| import threading |
| import traceback as tb_mod |
| from collections.abc import AsyncIterator, Iterable |
| from dataclasses import dataclass, field |
| from typing import Any |
|
|
| import models |
|
|
|
|
| @dataclass |
| class DownloadEvent: |
| filename: str |
| mb_done: float |
| mb_total: float |
|
|
|
|
| @dataclass |
| class ProgressEvent: |
| stage: int |
| stage_label: str |
| step: int |
| total_steps: int |
|
|
|
|
| @dataclass |
| class OutputEvent: |
| video_path: str |
| audio_path: str | None = None |
| meta: dict = field(default_factory=dict) |
|
|
|
|
| @dataclass |
| class ErrorEvent: |
| category: str |
| message: str |
| stage: int | None = None |
| traceback: str = "" |
|
|
|
|
| def _on_spaces() -> bool: |
| return bool(os.environ.get("SPACES_ZERO_GPU")) |
|
|
|
|
| try: |
| import spaces |
| except ImportError: |
| spaces = None |
|
|
|
|
| def _identity(fn): |
| return fn |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| _BASE_DURATION_S: dict[str, int] = { |
| |
| "t2v": 90, |
| "i2v": 90, |
| "a2v": 120, |
| "lipsync": 240, |
| "keyframe": 180, |
| "style": 360, |
| } |
| _PRESET_MULT: dict[str, float] = {"fast": 1.0, "balanced": 1.5, "quality": 3.0} |
|
|
|
|
| def _frames_from_workflow(workflow: dict) -> int: |
| """Read the frame count from the workflow's EmptyLTXVLatentVideo node.""" |
| for node in workflow.values(): |
| if isinstance(node, dict) and node.get("class_type") == "EmptyLTXVLatentVideo": |
| try: |
| return int((node.get("inputs") or {}).get("length", 121)) |
| except (TypeError, ValueError): |
| return 121 |
| return 121 |
|
|
|
|
| def _duration_for( |
| executor: Any, |
| workflow: dict, |
| output_ids: list[str], |
| mode: str, |
| preset: str, |
| multiplier: float = 1.0, |
| progress: Any = None, |
| ) -> int: |
| """ZeroGPU duration estimator. Same signature as _execute_workflow. |
| |
| `progress` is a gr.Progress instance forwarded by the caller; we ignore it |
| here (estimator doesn't emit progress) but must accept it positionally so |
| ZeroGPU can call us with the same arg list it'll use for _execute_workflow. |
| |
| Estimate = (base × preset multiplier + cold-cache buffer + per-frame VAE |
| decode time) × retry multiplier, clamped to [60s, 240s]. ZeroGPU rejects |
| durations above the server's per-call max with "ZeroGPU illegal duration" |
| (client.py:137); 240s is observed to work for Pro identity (~2 min runs |
| needed for style + lipsync detailer paths). If the server rejects values |
| in this range, the user will see a clear error and can retry. |
| """ |
| base = _BASE_DURATION_S.get(mode, 180) |
| mult = _PRESET_MULT.get(preset.lower(), 1.5) |
| frames = _frames_from_workflow(workflow) |
| est = int((base * mult + 60 + frames * 0.3) * multiplier) |
| return max(60, min(est, 240)) |
|
|
|
|
| |
| _GPU = ( |
| spaces.GPU(duration=_duration_for) |
| if (spaces is not None and _on_spaces()) |
| else _identity |
| ) |
|
|
|
|
| @_GPU |
| def _execute_workflow( |
| executor: Any, |
| workflow: dict, |
| output_ids: list[str], |
| mode: str, |
| preset: str, |
| multiplier: float = 1.0, |
| progress: Any = None, |
| ) -> str: |
| """Run the workflow on GPU and return the path of the first video output. |
| |
| Returns just the video path (a plain string, picklable across the |
| @spaces.GPU subprocess boundary). The `mode`, `preset`, and `multiplier` |
| args are consumed by `_duration_for` to estimate the GPU slot to reserve. |
| |
| `progress` is an optional `gr.Progress` instance. It's the only progress |
| channel that crosses the @spaces.GPU subprocess boundary on HF Spaces — |
| Gradio + the `spaces` library wrap it with cross-process IPC. When set, |
| we mirror ComfyUI's step counter into it via the global progress hook, |
| chaining to whatever hook was already installed (so the local event-based |
| status banner keeps working alongside). |
| """ |
| if progress is not None: |
| import comfy.utils as _cu |
| _saved_hook = getattr(_cu, "PROGRESS_BAR_HOOK", None) |
|
|
| def _gp_hook(value, total, _preview=None, **_kw): |
| try: |
| v, t = int(value), int(total) |
| progress(v / max(t, 1), desc=f"Sampling step {v}/{t}") |
| except Exception: |
| pass |
| if _saved_hook is not None: |
| try: |
| _saved_hook(value, total, _preview) |
| except Exception: |
| pass |
|
|
| _cu.set_progress_bar_global_hook(_gp_hook) |
|
|
| executor.execute( |
| workflow, |
| prompt_id="ltx23-aio", |
| extra_data={"client_id": "ltx23-aio"}, |
| execute_outputs=output_ids, |
| ) |
| hist = getattr(executor, "history_result", {}) or {} |
| outs = hist.get("outputs") or {} |
| for output in outs.values(): |
| if not isinstance(output, dict): |
| continue |
| for value in output.values(): |
| if not isinstance(value, list): |
| continue |
| for item in value: |
| if isinstance(item, dict): |
| fn = item.get("filename") or "" |
| if fn.endswith((".mp4", ".webm", ".mov")): |
| return item.get("fullpath") or fn |
| return "" |
|
|
|
|
| class _StubServer: |
| """Minimal stub matching the surface ComfyUI's PromptExecutor expects.""" |
|
|
| client_id: str | None = "ltx23-aio" |
| last_node_id: str | None = None |
|
|
| def send_sync(self, event: str, data: dict, sid: str | None = None) -> None: |
| pass |
|
|
| def queue_updated(self) -> None: |
| pass |
|
|
|
|
| class _StubPromptQueue: |
| """Stub matching the surface VideoHelperSuite + others touch.""" |
|
|
| currently_running: dict = {} |
| history: dict = {} |
| flags: dict = {} |
|
|
| def get_current_queue(self) -> tuple[list, list]: |
| return ([], []) |
|
|
| def get_tasks_remaining(self) -> int: |
| return 0 |
|
|
| def set_flag(self, name: str, data) -> None: |
| pass |
|
|
| def get_flags(self, *a, **kw) -> dict: |
| return {} |
|
|
| def task_done(self, *a, **kw) -> None: |
| pass |
|
|
| def put(self, *a, **kw) -> None: |
| pass |
|
|
| def wipe_queue(self) -> None: |
| pass |
|
|
| def delete_queue_item(self, *a, **kw) -> None: |
| pass |
|
|
|
|
| class _StubPromptServerInstance: |
| """Surface that ComfyUI's `server.PromptServer.instance` exposes to custom nodes. |
| |
| VideoHelperSuite, KJNodes, and others read this at import time. They mostly |
| use it to register HTTP routes or send WS events or peek at the prompt queue. |
| No-ops here are fine — we have no real server. |
| """ |
|
|
| client_id: str | None = "ltx23-aio" |
| |
| |
| |
| |
| last_node_id: str = "ltx23-aio" |
| web_root: str = "" |
|
|
| class _Routes: |
| def get(self, *a, **kw): |
| return lambda fn: fn |
|
|
| def post(self, *a, **kw): |
| return lambda fn: fn |
|
|
| def static(self, *a, **kw): |
| return None |
|
|
| routes = _Routes() |
| sockets: dict = {} |
| prompt_queue = _StubPromptQueue() |
| |
| |
| supports: list[str] = ["custom_nodes_from_web"] |
| web_root: str = "" |
|
|
| def add_routes(self) -> None: |
| pass |
|
|
| def send_sync(self, event: str, data: dict, sid: str | None = None) -> None: |
| pass |
|
|
| def send_progress_text(self, text: str, node_id=None, sid=None) -> None: |
| |
| |
| pass |
|
|
| def queue_updated(self) -> None: |
| pass |
|
|
| def get_node_class_def(self, *a, **kw): |
| return None |
|
|
| def __getattr__(self, name): |
| |
| |
| |
| def _noop(*a, **kw): |
| return None |
| return _noop |
|
|
|
|
| def _comfy_dir() -> pathlib.Path: |
| if _on_spaces(): |
| return pathlib.Path.home() / "comfyui" |
| return pathlib.Path(__file__).parent / "comfyui" |
|
|
|
|
| class ComfyUILibraryBackend: |
| """Wraps PromptExecutor for in-process workflow execution.""" |
|
|
| def __init__(self) -> None: |
| self._comfy_dir = _comfy_dir() |
| if not self._comfy_dir.exists(): |
| raise RuntimeError( |
| f"ComfyUI not found at {self._comfy_dir}. " |
| f"Local: run `bash setup.sh`. Spaces: see app.py:_bootstrap()." |
| ) |
| if str(self._comfy_dir) not in sys.path: |
| sys.path.insert(0, str(self._comfy_dir)) |
|
|
| |
| |
| |
| |
| import asyncio |
| import threading |
|
|
| import comfy.cli_args |
| import execution |
| import nodes |
|
|
| |
| |
| |
| |
| |
| |
| |
| comfy_subdir = str(self._comfy_dir / "comfy") |
| sys.path = [p for p in sys.path if p not in (str(self._comfy_dir), comfy_subdir)] |
| sys.path.insert(0, comfy_subdir) |
| sys.path.insert(0, str(self._comfy_dir)) |
| if "utils" in sys.modules and not getattr(sys.modules["utils"], "__path__", None): |
| del sys.modules["utils"] |
|
|
| |
| |
| |
| import server as comfy_server |
|
|
| if getattr(comfy_server.PromptServer, "instance", None) is None: |
| comfy_server.PromptServer.instance = _StubPromptServerInstance() |
|
|
| |
| |
| |
| def _init_in_thread() -> None: |
| loop = asyncio.new_event_loop() |
| asyncio.set_event_loop(loop) |
| try: |
| loop.run_until_complete(nodes.init_extra_nodes()) |
| finally: |
| loop.close() |
|
|
| thread = threading.Thread(target=_init_in_thread, daemon=False) |
| thread.start() |
| thread.join() |
| |
| |
| |
| |
| |
| |
| self._executor = execution.PromptExecutor( |
| server=_StubServer(), |
| cache_args={"ram": 16.0, "lru": 0}, |
| ) |
|
|
| def __repr__(self) -> str: |
| return f"ComfyUILibraryBackend(comfy_dir={self._comfy_dir!r})" |
|
|
| async def submit( |
| self, |
| mode: str, |
| workflow: dict, |
| *, |
| preset: str = "balanced", |
| duration_multiplier: float = 1.0, |
| gpu_duration: int = 0, |
| progress: Any = None, |
| ) -> AsyncIterator[Any]: |
| """Run a workflow end-to-end. Yields Download/Progress/Output/Error events. |
| |
| `preset` and `duration_multiplier` flow through to the @spaces.GPU |
| duration estimator. The handler can re-call submit() with |
| duration_multiplier=2.0 if the first attempt aborts on timeout. |
| """ |
| |
| try: |
| needed = models.walk_workflow_for_models(workflow) |
| for download_event in models.ensure_models(needed): |
| yield download_event |
| except Exception as e: |
| yield ErrorEvent( |
| category="download", |
| message=str(e), |
| traceback=tb_mod.format_exc(), |
| ) |
| return |
|
|
| |
| queue: asyncio.Queue = asyncio.Queue() |
| loop = asyncio.get_running_loop() |
|
|
| def _push(event: Any) -> None: |
| asyncio.run_coroutine_threadsafe(queue.put(event), loop) |
|
|
| |
| |
| |
| |
| progress_state = {"stage": 0, "prev_total": -1, "max_step": -1} |
|
|
| def _hook(value: int, total: int, _preview=None, **_kwargs: Any) -> None: |
| v, t = int(value), int(total) |
| |
| if t != progress_state["prev_total"] or v < progress_state["max_step"]: |
| progress_state["stage"] += 1 |
| progress_state["prev_total"] = t |
| progress_state["max_step"] = v |
| else: |
| progress_state["max_step"] = max(progress_state["max_step"], v) |
| _push( |
| ProgressEvent( |
| stage=progress_state["stage"], |
| stage_label="diffusion", |
| step=v, |
| total_steps=t, |
| ) |
| ) |
|
|
| def _worker() -> None: |
| import comfy.utils |
|
|
| saved_hook = getattr(comfy.utils, "PROGRESS_BAR_HOOK", None) |
| try: |
| |
| |
| |
| |
| |
| output_ids = [ |
| nid for nid, n in workflow.items() |
| if n.get("class_type", "").startswith(("SaveVideo", "VHS_VideoCombine", "PreviewAudio", "CreateVideo")) |
| ] |
| print( |
| f"[backend] submitting workflow: {len(workflow)} nodes, " |
| f"output_ids={output_ids}", |
| file=sys.stderr, |
| flush=True, |
| ) |
| |
| |
| comfy.utils.set_progress_bar_global_hook(_hook) |
| |
| |
| |
| |
| |
| video_path = _execute_workflow( |
| self._executor, workflow, output_ids, mode, preset, duration_multiplier, progress, |
| ) |
| |
| |
| |
| |
| if not video_path: |
| video_path = _newest_recent_video(self._comfy_dir / "output") or "" |
| print( |
| f"[backend] workflow done; video_path={video_path!r}", |
| file=sys.stderr, |
| flush=True, |
| ) |
| _push(OutputEvent(video_path=video_path)) |
| except Exception as exc: |
| tb_text = tb_mod.format_exc() |
| print(f"[backend] worker exception:\n{tb_text}", file=sys.stderr, flush=True) |
| _push( |
| ErrorEvent( |
| category=_classify(exc), |
| message=str(exc), |
| traceback=tb_text, |
| ) |
| ) |
| finally: |
| comfy.utils.set_progress_bar_global_hook(saved_hook) |
| _free_memory() |
| _push(None) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| ctx = contextvars.copy_context() |
| thread = threading.Thread(target=ctx.run, args=(_worker,), daemon=True) |
| thread.start() |
|
|
| while True: |
| event = await queue.get() |
| if event is None: |
| return |
| yield event |
|
|
| def interrupt(self) -> None: |
| """Cancel the currently running workflow (if any).""" |
| try: |
| import comfy.model_management as mm |
|
|
| mm.interrupt_current_processing() |
| except Exception: |
| pass |
|
|
|
|
| def _classify(exc: Exception) -> str: |
| name = type(exc).__name__.lower() |
| msg = str(exc).lower() |
| if "outofmemory" in name or "cuda out of memory" in msg: |
| return "oom" |
| if "expired zerogpu proxy token" in msg or "expired" in msg and "token" in msg: |
| return "expired_token" |
| if "illegal duration" in msg: |
| return "illegal_duration" |
| if "unlogged user" in msg: |
| return "unlogged" |
| if "exceeded your" in msg and "gpu" in msg: |
| return "quota_exceeded" |
| |
| |
| if "gpu task aborted" in msg or ("gpu" in msg and "aborted" in msg): |
| return "gpu_timeout" |
| if "interrupt" in name: |
| return "interrupt" |
| return "execution" |
|
|
|
|
| def _free_memory() -> None: |
| """Free VRAM after a workflow finishes (success or failure).""" |
| try: |
| import comfy.model_management as mm |
|
|
| mm.unload_all_models() |
| except Exception: |
| pass |
| try: |
| import torch |
|
|
| if torch.backends.mps.is_available(): |
| torch.mps.empty_cache() |
| except Exception: |
| pass |
| try: |
| import torch |
|
|
| if torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
| except Exception: |
| pass |
|
|
|
|
| def _newest_recent_video(output_root: pathlib.Path, within_seconds: float = 60.0) -> str | None: |
| """Filesystem fallback: return the newest .mp4/.webm/.mov under *output_root* |
| that was modified within the last *within_seconds* seconds. |
| |
| Used when the executor's history_result didn't surface a path — typically |
| happens when ZeroGPU's subprocess boundary drops the mutation. The disk |
| is shared, so the file is there even when the in-memory state isn't. |
| """ |
| import time |
|
|
| if not output_root.exists(): |
| return None |
| cutoff = time.time() - within_seconds |
| candidates: list[tuple[float, pathlib.Path]] = [] |
| for ext in (".mp4", ".webm", ".mov"): |
| for p in output_root.rglob(f"*{ext}"): |
| try: |
| mtime = p.stat().st_mtime |
| except OSError: |
| continue |
| if mtime >= cutoff: |
| candidates.append((mtime, p)) |
| if not candidates: |
| return None |
| candidates.sort(reverse=True) |
| return str(candidates[0][1]) |
|
|