| """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}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _EFFECTIVE_DURATION_CEILING_S = 120
|
|
|
|
|
| def _zerogpu_duration_factor() -> float:
|
| """Live ZeroGPU duration multiplier for the GPU model this Space landed on.
|
|
|
| Mirrors what the spaces lib does at schedule time (client.py reads the same
|
| `duration_factor`). Falls back to the worst-case 1.5 if the config can't be
|
| read, so we never over-declare and trip the per-call maximum.
|
| """
|
| try:
|
| from spaces.zero.config import get_config
|
|
|
| factor = float(get_config().get("duration_factor", 1.0))
|
| return factor if factor > 0 else 1.0
|
| except Exception:
|
| return 1.5
|
|
|
|
|
| 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. The result is then clamped so that the
|
| value ZeroGPU actually charges against the per-call max — declared ×
|
| `duration_factor` (1.0 on H200, 1.5 on RTX PRO 6000) — stays at/under the
|
| known-good ceiling. Without dividing by the factor, a 1.5x GPU inflates the
|
| declared duration past the cap and the call is rejected with "ZeroGPU
|
| illegal duration" (client.py), leaving the user with progress then nothing.
|
| """
|
| 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)
|
| declared_ceiling = int(_EFFECTIVE_DURATION_CEILING_S / _zerogpu_duration_factor())
|
| return max(60, min(est, declared_ceiling))
|
|
|
|
|
|
|
| _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,
|
| ):
|
| """Run the workflow on GPU, streaming progress, ending with the video path.
|
|
|
| Generator — every yield is `("progress", step, total)` during sampling and
|
| the final yield is `("video", path)`. Yielding is the *only* way ComfyUI's
|
| per-step counter crosses the @spaces.GPU subprocess boundary on HF Spaces as
|
| discrete events: the `spaces` library streams a GPU generator's yields back
|
| to the parent, so the caller can turn them into ProgressEvents (and thus SSE
|
| `generating` frames). A plain return would run the whole diffusion inside the
|
| forked child with its progress hook firing on a queue the parent never sees.
|
| The yielded values are picklable tuples, fine across the boundary. The `mode`,
|
| `preset`, and `multiplier` args are consumed by `_duration_for` to size the
|
| GPU slot. `progress` (a `gr.Progress`) is still updated for the native bar.
|
| """
|
| import time as _time
|
| _gpu_start = _time.time()
|
| try:
|
| yield from _execute_workflow_inner(
|
| executor, workflow, output_ids, mode, preset, multiplier, progress,
|
| )
|
| finally:
|
| print(
|
| f"[backend] GPU time consumed: {_time.time() - _gpu_start:.2f}s "
|
| f"(mode={mode}, preset={preset}, multiplier={multiplier})",
|
| file=sys.stderr,
|
| flush=True,
|
| )
|
|
|
|
|
| def _execute_workflow_inner(
|
| executor: Any,
|
| workflow: dict,
|
| output_ids: list[str],
|
| mode: str,
|
| preset: str,
|
| multiplier: float = 1.0,
|
| progress: Any = None,
|
| ):
|
| import comfy.utils as _cu
|
| import queue as _queue
|
| import threading as _threading
|
|
|
| step_q: _queue.Queue = _queue.Queue()
|
|
|
| def _gp_hook(value, total, _preview=None, **_kw):
|
| try:
|
| v, t = int(value), int(total)
|
| if progress is not None:
|
| progress(v / max(t, 1), desc=f"Sampling step {v}/{t}")
|
| step_q.put((v, t))
|
| except Exception:
|
| pass
|
|
|
| _cu.set_progress_bar_global_hook(_gp_hook)
|
|
|
| result: dict = {}
|
|
|
| def _run():
|
| try:
|
| 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 {}
|
| path = ""
|
| 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")):
|
| path = item.get("fullpath") or fn
|
| result["path"] = path
|
| except Exception as exc:
|
| result["error"] = exc
|
| finally:
|
| step_q.put(None)
|
|
|
| worker = _threading.Thread(target=_run, daemon=True)
|
| worker.start()
|
| while True:
|
| item = step_q.get()
|
| if item is None:
|
| break
|
| yield ("progress", item[0], item[1])
|
| worker.join()
|
| if "error" in result:
|
| raise result["error"]
|
| yield ("video", result.get("path", ""))
|
|
|
|
|
| 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,
|
| )
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| video_path = ""
|
| for _item in _execute_workflow(
|
| self._executor, workflow, output_ids, mode, preset, duration_multiplier, progress,
|
| ):
|
| if not isinstance(_item, (tuple, list)) or not _item:
|
| continue
|
| if _item[0] == "progress":
|
| _hook(_item[1], _item[2])
|
| elif _item[0] == "video":
|
| video_path = _item[1]
|
|
|
|
|
|
|
|
|
| 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])
|
|
|