Spaces:
Running on Zero
Running on Zero
ci: run unit tests + ruff lint on every push
Browse files- .github/workflows/ci.yml +31 -0
- app.py +44 -24
- backend.py +26 -13
- models.py +10 -14
- modes.py +14 -13
- pyproject.toml +1 -0
- tests/conftest.py +4 -4
- tests/test_backend.py +1 -0
- tests/test_extract_modes.py +1 -0
- tests/test_models.py +14 -10
- tests/test_modes.py +8 -2
- tests/test_workflow.py +1 -0
- tools/extract_modes.py +2 -1
- tools/refresh_models.py +1 -0
- ui.py +33 -13
- workflow.py +1 -0
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
pull_request:
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
test:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
steps:
|
| 11 |
+
- uses: actions/checkout@v4
|
| 12 |
+
with:
|
| 13 |
+
submodules: false # ComfyUI submodule not needed for L1+L3 tests
|
| 14 |
+
|
| 15 |
+
- uses: actions/setup-python@v5
|
| 16 |
+
with:
|
| 17 |
+
python-version: "3.11"
|
| 18 |
+
|
| 19 |
+
- name: Install runtime + dev deps
|
| 20 |
+
run: |
|
| 21 |
+
pip install -U pip
|
| 22 |
+
pip install -r requirements.txt
|
| 23 |
+
|
| 24 |
+
- name: Run unit + integration tests (no GPU)
|
| 25 |
+
run: |
|
| 26 |
+
python -m pytest tests/ -v -m "not gpu"
|
| 27 |
+
|
| 28 |
+
- name: Lint
|
| 29 |
+
run: |
|
| 30 |
+
ruff check .
|
| 31 |
+
ruff format --check .
|
app.py
CHANGED
|
@@ -1,21 +1,26 @@
|
|
| 1 |
# app.py
|
| 2 |
"""LTX 2.3 All-in-One — Gradio entry point."""
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import os
|
| 6 |
import pathlib
|
| 7 |
import sys
|
|
|
|
|
|
|
| 8 |
|
| 9 |
import gradio as gr
|
| 10 |
|
|
|
|
| 11 |
import modes
|
| 12 |
import ui
|
| 13 |
-
|
| 14 |
|
| 15 |
# ---------------------------------------------------------------------------
|
| 16 |
# Bootstrap — runs once on cold start.
|
| 17 |
# ---------------------------------------------------------------------------
|
| 18 |
|
|
|
|
| 19 |
def _on_spaces() -> bool:
|
| 20 |
return bool(os.environ.get("SPACES_ZERO_GPU"))
|
| 21 |
|
|
@@ -39,6 +44,7 @@ CUSTOM_NODES_PINNED: list[tuple[str, str]] = [
|
|
| 39 |
|
| 40 |
def _git_clone(url: str, dst: pathlib.Path, ref: str) -> None:
|
| 41 |
import subprocess
|
|
|
|
| 42 |
subprocess.check_call(["git", "clone", "--depth", "1", "--branch", ref, url, str(dst)])
|
| 43 |
|
| 44 |
|
|
@@ -54,6 +60,7 @@ def _bootstrap() -> None:
|
|
| 54 |
_git_clone(node_url, comfy_dir / "custom_nodes" / name, ref=node_ref)
|
| 55 |
# Install custom node deps
|
| 56 |
import subprocess
|
|
|
|
| 57 |
for cn in (comfy_dir / "custom_nodes").iterdir():
|
| 58 |
req = cn / "requirements.txt"
|
| 59 |
if req.exists():
|
|
@@ -106,7 +113,7 @@ def build_app() -> gr.Blocks:
|
|
| 106 |
|
| 107 |
def _render_sidebar() -> None:
|
| 108 |
gr.Markdown("### Modes")
|
| 109 |
-
for
|
| 110 |
gr.Markdown(f"- {mode.icon} {mode.label}")
|
| 111 |
gr.Markdown("---\n### Models")
|
| 112 |
gr.Button("Unload all models", variant="secondary")
|
|
@@ -115,7 +122,7 @@ def _render_sidebar() -> None:
|
|
| 115 |
def _render_mode_panels() -> dict[str, dict]:
|
| 116 |
"""Render one form per mode. Returns the component handles keyed by mode."""
|
| 117 |
handles: dict[str, dict] = {}
|
| 118 |
-
with gr.Tabs()
|
| 119 |
for name, mode in modes.MODE_REGISTRY.items():
|
| 120 |
with gr.Tab(label=f"{mode.icon} {mode.label}"):
|
| 121 |
handles[name] = _render_one_mode(name)
|
|
@@ -124,12 +131,13 @@ def _render_mode_panels() -> dict[str, dict]:
|
|
| 124 |
|
| 125 |
def _render_one_mode(name: str) -> dict:
|
| 126 |
"""Render a per-mode form. Returns component handles for the generate handler."""
|
| 127 |
-
mode = modes.MODE_REGISTRY[name]
|
| 128 |
handles: dict = {"mode": name}
|
| 129 |
|
| 130 |
with gr.Row():
|
| 131 |
with gr.Column(scale=2):
|
| 132 |
-
handles["prompt"] = gr.Textbox(
|
|
|
|
|
|
|
| 133 |
|
| 134 |
# Mode-specific media inputs
|
| 135 |
if name == "i2v":
|
|
@@ -168,12 +176,6 @@ def _render_one_mode(name: str) -> dict:
|
|
| 168 |
return handles
|
| 169 |
|
| 170 |
|
| 171 |
-
import time
|
| 172 |
-
from typing import Any
|
| 173 |
-
|
| 174 |
-
import workflow as wf_module
|
| 175 |
-
import backend as backend_module
|
| 176 |
-
|
| 177 |
_BACKEND: backend_module.ComfyUILibraryBackend | None = None
|
| 178 |
|
| 179 |
|
|
@@ -202,10 +204,22 @@ async def _on_generate(mode_name: str, **inputs: Any):
|
|
| 202 |
"fps": int(inputs.get("fps", 24)),
|
| 203 |
"seed": int(inputs.get("seed", 42)),
|
| 204 |
}
|
| 205 |
-
for k in (
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
if k in inputs:
|
| 210 |
params[k] = inputs[k]
|
| 211 |
|
|
@@ -227,7 +241,8 @@ async def _on_generate(mode_name: str, **inputs: Any):
|
|
| 227 |
stage_label=f"Downloading {event.filename}",
|
| 228 |
step=int(event.mb_done),
|
| 229 |
total_steps=int(max(event.mb_total, 1)),
|
| 230 |
-
elapsed_s=elapsed,
|
|
|
|
| 231 |
)
|
| 232 |
yield status, gr.update()
|
| 233 |
elif isinstance(event, backend_module.ProgressEvent):
|
|
@@ -242,7 +257,8 @@ async def _on_generate(mode_name: str, **inputs: Any):
|
|
| 242 |
stage_label=stage.label,
|
| 243 |
step=event.step,
|
| 244 |
total_steps=event.total_steps,
|
| 245 |
-
elapsed_s=elapsed,
|
|
|
|
| 246 |
)
|
| 247 |
yield status, gr.update()
|
| 248 |
elif isinstance(event, backend_module.OutputEvent):
|
|
@@ -251,8 +267,8 @@ async def _on_generate(mode_name: str, **inputs: Any):
|
|
| 251 |
error_html = (
|
| 252 |
f'<div class="status-card status-error">'
|
| 253 |
f' <div class="status-row"><span class="status-stage">Error · {event.category}</span></div>'
|
| 254 |
-
f
|
| 255 |
-
f
|
| 256 |
)
|
| 257 |
yield error_html, gr.update()
|
| 258 |
|
|
@@ -292,10 +308,14 @@ def _collect_inputs_for_mode(mode_name: str, h: dict) -> list:
|
|
| 292 |
elif mode_name == "style":
|
| 293 |
base.append(h["input_video"])
|
| 294 |
base.append(h["negative_prompt"])
|
| 295 |
-
base.extend(
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
if h["lora"].ic_lora is not None:
|
| 300 |
base.extend([h["lora"].ic_lora, h["lora"].ic_strength])
|
| 301 |
if h["lora"].pose_on is not None:
|
|
@@ -307,7 +327,7 @@ def _make_handler(mode_name: str, h: dict):
|
|
| 307 |
keys = _input_keys_for_mode(mode_name, h)
|
| 308 |
|
| 309 |
async def handler(*values):
|
| 310 |
-
kwargs = dict(zip(keys, values))
|
| 311 |
async for output in _on_generate(mode_name, **kwargs):
|
| 312 |
yield output
|
| 313 |
|
|
|
|
| 1 |
# app.py
|
| 2 |
"""LTX 2.3 All-in-One — Gradio entry point."""
|
| 3 |
+
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
import os
|
| 7 |
import pathlib
|
| 8 |
import sys
|
| 9 |
+
import time
|
| 10 |
+
from typing import Any
|
| 11 |
|
| 12 |
import gradio as gr
|
| 13 |
|
| 14 |
+
import backend as backend_module
|
| 15 |
import modes
|
| 16 |
import ui
|
| 17 |
+
import workflow as wf_module
|
| 18 |
|
| 19 |
# ---------------------------------------------------------------------------
|
| 20 |
# Bootstrap — runs once on cold start.
|
| 21 |
# ---------------------------------------------------------------------------
|
| 22 |
|
| 23 |
+
|
| 24 |
def _on_spaces() -> bool:
|
| 25 |
return bool(os.environ.get("SPACES_ZERO_GPU"))
|
| 26 |
|
|
|
|
| 44 |
|
| 45 |
def _git_clone(url: str, dst: pathlib.Path, ref: str) -> None:
|
| 46 |
import subprocess
|
| 47 |
+
|
| 48 |
subprocess.check_call(["git", "clone", "--depth", "1", "--branch", ref, url, str(dst)])
|
| 49 |
|
| 50 |
|
|
|
|
| 60 |
_git_clone(node_url, comfy_dir / "custom_nodes" / name, ref=node_ref)
|
| 61 |
# Install custom node deps
|
| 62 |
import subprocess
|
| 63 |
+
|
| 64 |
for cn in (comfy_dir / "custom_nodes").iterdir():
|
| 65 |
req = cn / "requirements.txt"
|
| 66 |
if req.exists():
|
|
|
|
| 113 |
|
| 114 |
def _render_sidebar() -> None:
|
| 115 |
gr.Markdown("### Modes")
|
| 116 |
+
for mode in modes.MODE_REGISTRY.values():
|
| 117 |
gr.Markdown(f"- {mode.icon} {mode.label}")
|
| 118 |
gr.Markdown("---\n### Models")
|
| 119 |
gr.Button("Unload all models", variant="secondary")
|
|
|
|
| 122 |
def _render_mode_panels() -> dict[str, dict]:
|
| 123 |
"""Render one form per mode. Returns the component handles keyed by mode."""
|
| 124 |
handles: dict[str, dict] = {}
|
| 125 |
+
with gr.Tabs():
|
| 126 |
for name, mode in modes.MODE_REGISTRY.items():
|
| 127 |
with gr.Tab(label=f"{mode.icon} {mode.label}"):
|
| 128 |
handles[name] = _render_one_mode(name)
|
|
|
|
| 131 |
|
| 132 |
def _render_one_mode(name: str) -> dict:
|
| 133 |
"""Render a per-mode form. Returns component handles for the generate handler."""
|
|
|
|
| 134 |
handles: dict = {"mode": name}
|
| 135 |
|
| 136 |
with gr.Row():
|
| 137 |
with gr.Column(scale=2):
|
| 138 |
+
handles["prompt"] = gr.Textbox(
|
| 139 |
+
label="Prompt", lines=4, placeholder="Describe the shot..."
|
| 140 |
+
)
|
| 141 |
|
| 142 |
# Mode-specific media inputs
|
| 143 |
if name == "i2v":
|
|
|
|
| 176 |
return handles
|
| 177 |
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
_BACKEND: backend_module.ComfyUILibraryBackend | None = None
|
| 180 |
|
| 181 |
|
|
|
|
| 204 |
"fps": int(inputs.get("fps", 24)),
|
| 205 |
"seed": int(inputs.get("seed", 42)),
|
| 206 |
}
|
| 207 |
+
for k in (
|
| 208 |
+
"image",
|
| 209 |
+
"audio",
|
| 210 |
+
"first_frame",
|
| 211 |
+
"last_frame",
|
| 212 |
+
"input_video",
|
| 213 |
+
"camera_lora",
|
| 214 |
+
"camera_strength",
|
| 215 |
+
"detailer_on",
|
| 216 |
+
"detailer_strength",
|
| 217 |
+
"ic_lora",
|
| 218 |
+
"ic_strength",
|
| 219 |
+
"pose_on",
|
| 220 |
+
"audio_cfg",
|
| 221 |
+
"image_strength",
|
| 222 |
+
):
|
| 223 |
if k in inputs:
|
| 224 |
params[k] = inputs[k]
|
| 225 |
|
|
|
|
| 241 |
stage_label=f"Downloading {event.filename}",
|
| 242 |
step=int(event.mb_done),
|
| 243 |
total_steps=int(max(event.mb_total, 1)),
|
| 244 |
+
elapsed_s=elapsed,
|
| 245 |
+
eta_s=0,
|
| 246 |
)
|
| 247 |
yield status, gr.update()
|
| 248 |
elif isinstance(event, backend_module.ProgressEvent):
|
|
|
|
| 257 |
stage_label=stage.label,
|
| 258 |
step=event.step,
|
| 259 |
total_steps=event.total_steps,
|
| 260 |
+
elapsed_s=elapsed,
|
| 261 |
+
eta_s=eta,
|
| 262 |
)
|
| 263 |
yield status, gr.update()
|
| 264 |
elif isinstance(event, backend_module.OutputEvent):
|
|
|
|
| 267 |
error_html = (
|
| 268 |
f'<div class="status-card status-error">'
|
| 269 |
f' <div class="status-row"><span class="status-stage">Error · {event.category}</span></div>'
|
| 270 |
+
f" <div>{event.message}</div>"
|
| 271 |
+
f"</div>"
|
| 272 |
)
|
| 273 |
yield error_html, gr.update()
|
| 274 |
|
|
|
|
| 308 |
elif mode_name == "style":
|
| 309 |
base.append(h["input_video"])
|
| 310 |
base.append(h["negative_prompt"])
|
| 311 |
+
base.extend(
|
| 312 |
+
[
|
| 313 |
+
h["lora"].camera_lora,
|
| 314 |
+
h["lora"].camera_strength,
|
| 315 |
+
h["lora"].detailer_on,
|
| 316 |
+
h["lora"].detailer_strength,
|
| 317 |
+
]
|
| 318 |
+
)
|
| 319 |
if h["lora"].ic_lora is not None:
|
| 320 |
base.extend([h["lora"].ic_lora, h["lora"].ic_strength])
|
| 321 |
if h["lora"].pose_on is not None:
|
|
|
|
| 327 |
keys = _input_keys_for_mode(mode_name, h)
|
| 328 |
|
| 329 |
async def handler(*values):
|
| 330 |
+
kwargs = dict(zip(keys, values, strict=False))
|
| 331 |
async for output in _on_generate(mode_name, **kwargs):
|
| 332 |
yield output
|
| 333 |
|
backend.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
Single-process, single-implementation. The @spaces.GPU decorator is the only
|
| 4 |
divergence between local and HF Spaces deployment.
|
| 5 |
"""
|
|
|
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
import asyncio
|
|
@@ -13,7 +14,7 @@ import threading
|
|
| 13 |
import traceback as tb_mod
|
| 14 |
from collections.abc import AsyncIterator, Iterable
|
| 15 |
from dataclasses import dataclass, field
|
| 16 |
-
from typing import Any
|
| 17 |
|
| 18 |
import models
|
| 19 |
|
|
@@ -36,7 +37,7 @@ class ProgressEvent:
|
|
| 36 |
@dataclass
|
| 37 |
class OutputEvent:
|
| 38 |
video_path: str
|
| 39 |
-
audio_path:
|
| 40 |
meta: dict = field(default_factory=dict)
|
| 41 |
|
| 42 |
|
|
@@ -44,7 +45,7 @@ class OutputEvent:
|
|
| 44 |
class ErrorEvent:
|
| 45 |
category: str # "oom" | "zerogpu_timeout" | "execution" | "interrupt" | "download"
|
| 46 |
message: str
|
| 47 |
-
stage:
|
| 48 |
traceback: str = ""
|
| 49 |
|
| 50 |
|
|
@@ -113,13 +114,18 @@ class ComfyUILibraryBackend:
|
|
| 113 |
asyncio.run_coroutine_threadsafe(queue.put(event), loop)
|
| 114 |
|
| 115 |
def _hook(value: int, total: int, _preview=None) -> None:
|
| 116 |
-
_push(
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
def _worker() -> None:
|
| 122 |
import comfy.utils
|
|
|
|
| 123 |
saved_hook = getattr(comfy.utils, "PROGRESS_BAR_HOOK", None)
|
| 124 |
try:
|
| 125 |
# Use the public setter; it writes the same global the
|
|
@@ -137,11 +143,13 @@ class ComfyUILibraryBackend:
|
|
| 137 |
video_path = _first_video_path(outputs) or ""
|
| 138 |
_push(OutputEvent(video_path=video_path))
|
| 139 |
except Exception as exc:
|
| 140 |
-
_push(
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
| 145 |
finally:
|
| 146 |
comfy.utils.set_progress_bar_global_hook(saved_hook)
|
| 147 |
_free_memory()
|
|
@@ -149,6 +157,7 @@ class ComfyUILibraryBackend:
|
|
| 149 |
|
| 150 |
if _on_spaces():
|
| 151 |
import spaces
|
|
|
|
| 152 |
execute = spaces.GPU(duration=gpu_duration)(_worker)
|
| 153 |
thread = threading.Thread(target=execute, daemon=True)
|
| 154 |
else:
|
|
@@ -165,6 +174,7 @@ class ComfyUILibraryBackend:
|
|
| 165 |
"""Cancel the currently running workflow (if any)."""
|
| 166 |
try:
|
| 167 |
import comfy.model_management as mm
|
|
|
|
| 168 |
mm.interrupt_current_processing()
|
| 169 |
except Exception:
|
| 170 |
pass
|
|
@@ -183,24 +193,27 @@ def _free_memory() -> None:
|
|
| 183 |
"""Free VRAM after a workflow finishes (success or failure)."""
|
| 184 |
try:
|
| 185 |
import comfy.model_management as mm
|
|
|
|
| 186 |
mm.unload_all_models()
|
| 187 |
except Exception:
|
| 188 |
pass
|
| 189 |
try:
|
| 190 |
import torch
|
|
|
|
| 191 |
if torch.backends.mps.is_available():
|
| 192 |
torch.mps.empty_cache()
|
| 193 |
except Exception:
|
| 194 |
pass
|
| 195 |
try:
|
| 196 |
import torch
|
|
|
|
| 197 |
if torch.cuda.is_available():
|
| 198 |
torch.cuda.empty_cache()
|
| 199 |
except Exception:
|
| 200 |
pass
|
| 201 |
|
| 202 |
|
| 203 |
-
def _first_video_path(outputs: Iterable) ->
|
| 204 |
"""Find the first .mp4 path emitted by VHS_VideoCombine in PromptExecutor outputs."""
|
| 205 |
for output in outputs:
|
| 206 |
if not isinstance(output, dict):
|
|
|
|
| 3 |
Single-process, single-implementation. The @spaces.GPU decorator is the only
|
| 4 |
divergence between local and HF Spaces deployment.
|
| 5 |
"""
|
| 6 |
+
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import asyncio
|
|
|
|
| 14 |
import traceback as tb_mod
|
| 15 |
from collections.abc import AsyncIterator, Iterable
|
| 16 |
from dataclasses import dataclass, field
|
| 17 |
+
from typing import Any
|
| 18 |
|
| 19 |
import models
|
| 20 |
|
|
|
|
| 37 |
@dataclass
|
| 38 |
class OutputEvent:
|
| 39 |
video_path: str
|
| 40 |
+
audio_path: str | None = None
|
| 41 |
meta: dict = field(default_factory=dict)
|
| 42 |
|
| 43 |
|
|
|
|
| 45 |
class ErrorEvent:
|
| 46 |
category: str # "oom" | "zerogpu_timeout" | "execution" | "interrupt" | "download"
|
| 47 |
message: str
|
| 48 |
+
stage: int | None = None
|
| 49 |
traceback: str = ""
|
| 50 |
|
| 51 |
|
|
|
|
| 114 |
asyncio.run_coroutine_threadsafe(queue.put(event), loop)
|
| 115 |
|
| 116 |
def _hook(value: int, total: int, _preview=None) -> None:
|
| 117 |
+
_push(
|
| 118 |
+
ProgressEvent(
|
| 119 |
+
stage=0,
|
| 120 |
+
stage_label="diffusion",
|
| 121 |
+
step=int(value),
|
| 122 |
+
total_steps=int(total),
|
| 123 |
+
)
|
| 124 |
+
)
|
| 125 |
|
| 126 |
def _worker() -> None:
|
| 127 |
import comfy.utils
|
| 128 |
+
|
| 129 |
saved_hook = getattr(comfy.utils, "PROGRESS_BAR_HOOK", None)
|
| 130 |
try:
|
| 131 |
# Use the public setter; it writes the same global the
|
|
|
|
| 143 |
video_path = _first_video_path(outputs) or ""
|
| 144 |
_push(OutputEvent(video_path=video_path))
|
| 145 |
except Exception as exc:
|
| 146 |
+
_push(
|
| 147 |
+
ErrorEvent(
|
| 148 |
+
category=_classify(exc),
|
| 149 |
+
message=str(exc),
|
| 150 |
+
traceback=tb_mod.format_exc(),
|
| 151 |
+
)
|
| 152 |
+
)
|
| 153 |
finally:
|
| 154 |
comfy.utils.set_progress_bar_global_hook(saved_hook)
|
| 155 |
_free_memory()
|
|
|
|
| 157 |
|
| 158 |
if _on_spaces():
|
| 159 |
import spaces
|
| 160 |
+
|
| 161 |
execute = spaces.GPU(duration=gpu_duration)(_worker)
|
| 162 |
thread = threading.Thread(target=execute, daemon=True)
|
| 163 |
else:
|
|
|
|
| 174 |
"""Cancel the currently running workflow (if any)."""
|
| 175 |
try:
|
| 176 |
import comfy.model_management as mm
|
| 177 |
+
|
| 178 |
mm.interrupt_current_processing()
|
| 179 |
except Exception:
|
| 180 |
pass
|
|
|
|
| 193 |
"""Free VRAM after a workflow finishes (success or failure)."""
|
| 194 |
try:
|
| 195 |
import comfy.model_management as mm
|
| 196 |
+
|
| 197 |
mm.unload_all_models()
|
| 198 |
except Exception:
|
| 199 |
pass
|
| 200 |
try:
|
| 201 |
import torch
|
| 202 |
+
|
| 203 |
if torch.backends.mps.is_available():
|
| 204 |
torch.mps.empty_cache()
|
| 205 |
except Exception:
|
| 206 |
pass
|
| 207 |
try:
|
| 208 |
import torch
|
| 209 |
+
|
| 210 |
if torch.cuda.is_available():
|
| 211 |
torch.cuda.empty_cache()
|
| 212 |
except Exception:
|
| 213 |
pass
|
| 214 |
|
| 215 |
|
| 216 |
+
def _first_video_path(outputs: Iterable) -> str | None:
|
| 217 |
"""Find the first .mp4 path emitted by VHS_VideoCombine in PromptExecutor outputs."""
|
| 218 |
for output in outputs:
|
| 219 |
if not isinstance(output, dict):
|
models.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
Lookups are by filename only — the same filename in two different repos is not
|
| 4 |
supported. If that ever happens we'll qualify by ComfyUI loader-type.
|
| 5 |
"""
|
|
|
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
import logging
|
|
@@ -25,12 +26,8 @@ class ModelEntry:
|
|
| 25 |
|
| 26 |
MODEL_REGISTRY: dict[str, ModelEntry] = {
|
| 27 |
# Main LTX 2.3 transformer + LoRAs + upscalers
|
| 28 |
-
"ltx-2.3-22b-distilled.safetensors": ModelEntry(
|
| 29 |
-
|
| 30 |
-
),
|
| 31 |
-
"ltx-2.3-22b-dev.safetensors": ModelEntry(
|
| 32 |
-
"Lightricks/LTX-2.3", comfy_type="checkpoints"
|
| 33 |
-
),
|
| 34 |
"ltx-2.3-spatial-upscaler-x2-1.0.safetensors": ModelEntry(
|
| 35 |
"Lightricks/LTX-2.3", comfy_type="upscale_models"
|
| 36 |
),
|
|
@@ -62,12 +59,8 @@ MODEL_REGISTRY: dict[str, ModelEntry] = {
|
|
| 62 |
subfolder="gemma-3-12b-it",
|
| 63 |
),
|
| 64 |
# Kijai's LTX 2.3 ComfyUI assets
|
| 65 |
-
"LTX23_video_vae_bf16.safetensors": ModelEntry(
|
| 66 |
-
|
| 67 |
-
),
|
| 68 |
-
"LTX23_audio_vae_bf16.safetensors": ModelEntry(
|
| 69 |
-
"Kijai/LTX2.3_comfy", comfy_type="vae"
|
| 70 |
-
),
|
| 71 |
"ltx-2.3_text_projection_bf16.safetensors": ModelEntry(
|
| 72 |
"Kijai/LTX2.3_comfy", comfy_type="text_encoders"
|
| 73 |
),
|
|
@@ -133,8 +126,10 @@ def walk_workflow_for_models(workflow: dict) -> set[str]:
|
|
| 133 |
widgets = node.get("widgets_values") or []
|
| 134 |
for value in _flatten_widget_values(widgets):
|
| 135 |
if isinstance(value, str) and (
|
| 136 |
-
value.endswith(".safetensors")
|
| 137 |
-
or value
|
|
|
|
|
|
|
| 138 |
):
|
| 139 |
needed.add(value)
|
| 140 |
return needed
|
|
@@ -246,6 +241,7 @@ def ensure_models(filenames: set[str]) -> Iterator[DownloadEvent]:
|
|
| 246 |
def ensure_models_for_mode(mode: str) -> Iterator[DownloadEvent]:
|
| 247 |
"""Convenience: walk a mode's workflow and ensure all referenced models exist."""
|
| 248 |
import workflow as workflow_module # local import to avoid cycle at import time
|
|
|
|
| 249 |
wf = workflow_module.load_template(mode)
|
| 250 |
needed = walk_workflow_for_models(wf)
|
| 251 |
yield from ensure_models(needed)
|
|
|
|
| 3 |
Lookups are by filename only — the same filename in two different repos is not
|
| 4 |
supported. If that ever happens we'll qualify by ComfyUI loader-type.
|
| 5 |
"""
|
| 6 |
+
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import logging
|
|
|
|
| 26 |
|
| 27 |
MODEL_REGISTRY: dict[str, ModelEntry] = {
|
| 28 |
# Main LTX 2.3 transformer + LoRAs + upscalers
|
| 29 |
+
"ltx-2.3-22b-distilled.safetensors": ModelEntry("Lightricks/LTX-2.3", comfy_type="checkpoints"),
|
| 30 |
+
"ltx-2.3-22b-dev.safetensors": ModelEntry("Lightricks/LTX-2.3", comfy_type="checkpoints"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
"ltx-2.3-spatial-upscaler-x2-1.0.safetensors": ModelEntry(
|
| 32 |
"Lightricks/LTX-2.3", comfy_type="upscale_models"
|
| 33 |
),
|
|
|
|
| 59 |
subfolder="gemma-3-12b-it",
|
| 60 |
),
|
| 61 |
# Kijai's LTX 2.3 ComfyUI assets
|
| 62 |
+
"LTX23_video_vae_bf16.safetensors": ModelEntry("Kijai/LTX2.3_comfy", comfy_type="vae"),
|
| 63 |
+
"LTX23_audio_vae_bf16.safetensors": ModelEntry("Kijai/LTX2.3_comfy", comfy_type="vae"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
"ltx-2.3_text_projection_bf16.safetensors": ModelEntry(
|
| 65 |
"Kijai/LTX2.3_comfy", comfy_type="text_encoders"
|
| 66 |
),
|
|
|
|
| 126 |
widgets = node.get("widgets_values") or []
|
| 127 |
for value in _flatten_widget_values(widgets):
|
| 128 |
if isinstance(value, str) and (
|
| 129 |
+
value.endswith(".safetensors")
|
| 130 |
+
or value.endswith(".gguf")
|
| 131 |
+
or value == "tokenizer.model"
|
| 132 |
+
or value.endswith(".json")
|
| 133 |
):
|
| 134 |
needed.add(value)
|
| 135 |
return needed
|
|
|
|
| 241 |
def ensure_models_for_mode(mode: str) -> Iterator[DownloadEvent]:
|
| 242 |
"""Convenience: walk a mode's workflow and ensure all referenced models exist."""
|
| 243 |
import workflow as workflow_module # local import to avoid cycle at import time
|
| 244 |
+
|
| 245 |
wf = workflow_module.load_template(mode)
|
| 246 |
needed = walk_workflow_for_models(wf)
|
| 247 |
yield from ensure_models(needed)
|
modes.py
CHANGED
|
@@ -14,6 +14,7 @@ backend.py.
|
|
| 14 |
Tasks 11 (T2V + I2V) and 12 (A2V + Lipsync + Keyframe + Style) populate
|
| 15 |
MODE_REGISTRY. This task only sets up the dataclass and the empty container.
|
| 16 |
"""
|
|
|
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
from collections.abc import Callable
|
|
@@ -64,12 +65,12 @@ MODE_REGISTRY: dict[str, Mode] = {}
|
|
| 64 |
# in models.py once camera-LoRA selection lands. Deferred for now.
|
| 65 |
# ---------------------------------------------------------------------------
|
| 66 |
|
| 67 |
-
T2V_NODE_PROMPT = 5536
|
| 68 |
-
T2V_NODE_NEG_PROMPT = 5537
|
| 69 |
-
T2V_NODE_WIDTH = 5383
|
| 70 |
-
T2V_NODE_HEIGHT = 5382
|
| 71 |
-
T2V_NODE_FPS = 5445
|
| 72 |
-
T2V_NODE_CLIP_LENGTH = 196
|
| 73 |
|
| 74 |
I2V_NODE_PROMPT = 5536
|
| 75 |
I2V_NODE_NEG_PROMPT = 5537
|
|
@@ -77,7 +78,7 @@ I2V_NODE_WIDTH = 5383
|
|
| 77 |
I2V_NODE_HEIGHT = 5382
|
| 78 |
I2V_NODE_FPS = 5445
|
| 79 |
I2V_NODE_CLIP_LENGTH = 196
|
| 80 |
-
I2V_NODE_IMAGE = 149
|
| 81 |
|
| 82 |
# Mode-specific media nodes — captured from workflows/{a2v,lipsync,keyframe,style}.json
|
| 83 |
# on 2026-04-30. All four templates contain the same node ids for these inputs (the
|
|
@@ -97,27 +98,27 @@ A2V_NODE_WIDTH = 5383
|
|
| 97 |
A2V_NODE_HEIGHT = 5382
|
| 98 |
A2V_NODE_FPS = 5445
|
| 99 |
A2V_NODE_CLIP_LENGTH = 196
|
| 100 |
-
A2V_NODE_AUDIO = 5400
|
| 101 |
|
| 102 |
LIPSYNC_NODE_PROMPT = 5536
|
| 103 |
LIPSYNC_NODE_NEG_PROMPT = 5537
|
| 104 |
LIPSYNC_NODE_FPS = 5445
|
| 105 |
LIPSYNC_NODE_CLIP_LENGTH = 196
|
| 106 |
-
LIPSYNC_NODE_IMAGE = 149
|
| 107 |
-
LIPSYNC_NODE_AUDIO = 5400
|
| 108 |
|
| 109 |
KEYFRAME_NODE_PROMPT = 5536
|
| 110 |
KEYFRAME_NODE_NEG_PROMPT = 5537
|
| 111 |
KEYFRAME_NODE_FPS = 5445
|
| 112 |
KEYFRAME_NODE_CLIP_LENGTH = 196
|
| 113 |
-
KEYFRAME_NODE_FIRST_FRAME = 149
|
| 114 |
-
KEYFRAME_NODE_LAST_FRAME = 5437
|
| 115 |
|
| 116 |
STYLE_NODE_PROMPT = 5536
|
| 117 |
STYLE_NODE_NEG_PROMPT = 5537
|
| 118 |
STYLE_NODE_FPS = 5445
|
| 119 |
STYLE_NODE_CLIP_LENGTH = 196
|
| 120 |
-
STYLE_NODE_INPUT_VIDEO = 5444
|
| 121 |
|
| 122 |
|
| 123 |
def _frames_to_seconds(frames: int, fps: int) -> int:
|
|
|
|
| 14 |
Tasks 11 (T2V + I2V) and 12 (A2V + Lipsync + Keyframe + Style) populate
|
| 15 |
MODE_REGISTRY. This task only sets up the dataclass and the empty container.
|
| 16 |
"""
|
| 17 |
+
|
| 18 |
from __future__ import annotations
|
| 19 |
|
| 20 |
from collections.abc import Callable
|
|
|
|
| 65 |
# in models.py once camera-LoRA selection lands. Deferred for now.
|
| 66 |
# ---------------------------------------------------------------------------
|
| 67 |
|
| 68 |
+
T2V_NODE_PROMPT = 5536 # CLIPTextEncode positive — wv[0] = prompt
|
| 69 |
+
T2V_NODE_NEG_PROMPT = 5537 # CLIPTextEncode negative — wv[0] = negative prompt
|
| 70 |
+
T2V_NODE_WIDTH = 5383 # INTConstant "Width" — wv[0]
|
| 71 |
+
T2V_NODE_HEIGHT = 5382 # INTConstant "Height" — wv[0]
|
| 72 |
+
T2V_NODE_FPS = 5445 # INTConstant "FPS" — wv[0]
|
| 73 |
+
T2V_NODE_CLIP_LENGTH = 196 # mxSlider "Clip Length ( in seconds )" — wv[0]
|
| 74 |
|
| 75 |
I2V_NODE_PROMPT = 5536
|
| 76 |
I2V_NODE_NEG_PROMPT = 5537
|
|
|
|
| 78 |
I2V_NODE_HEIGHT = 5382
|
| 79 |
I2V_NODE_FPS = 5445
|
| 80 |
I2V_NODE_CLIP_LENGTH = 196
|
| 81 |
+
I2V_NODE_IMAGE = 149 # LoadImage "Load Image1" — wv[0] = filename
|
| 82 |
|
| 83 |
# Mode-specific media nodes — captured from workflows/{a2v,lipsync,keyframe,style}.json
|
| 84 |
# on 2026-04-30. All four templates contain the same node ids for these inputs (the
|
|
|
|
| 98 |
A2V_NODE_HEIGHT = 5382
|
| 99 |
A2V_NODE_FPS = 5445
|
| 100 |
A2V_NODE_CLIP_LENGTH = 196
|
| 101 |
+
A2V_NODE_AUDIO = 5400 # VHS_LoadAudioUpload — dict wv keyed by "audio"
|
| 102 |
|
| 103 |
LIPSYNC_NODE_PROMPT = 5536
|
| 104 |
LIPSYNC_NODE_NEG_PROMPT = 5537
|
| 105 |
LIPSYNC_NODE_FPS = 5445
|
| 106 |
LIPSYNC_NODE_CLIP_LENGTH = 196
|
| 107 |
+
LIPSYNC_NODE_IMAGE = 149 # LoadImage "Load Image1" — wv[0] = filename
|
| 108 |
+
LIPSYNC_NODE_AUDIO = 5400 # VHS_LoadAudioUpload — dict wv keyed by "audio"
|
| 109 |
|
| 110 |
KEYFRAME_NODE_PROMPT = 5536
|
| 111 |
KEYFRAME_NODE_NEG_PROMPT = 5537
|
| 112 |
KEYFRAME_NODE_FPS = 5445
|
| 113 |
KEYFRAME_NODE_CLIP_LENGTH = 196
|
| 114 |
+
KEYFRAME_NODE_FIRST_FRAME = 149 # LoadImage "Load Image1" — wv[0] = filename
|
| 115 |
+
KEYFRAME_NODE_LAST_FRAME = 5437 # LoadImage "Load Image2" — wv[0] = filename
|
| 116 |
|
| 117 |
STYLE_NODE_PROMPT = 5536
|
| 118 |
STYLE_NODE_NEG_PROMPT = 5537
|
| 119 |
STYLE_NODE_FPS = 5445
|
| 120 |
STYLE_NODE_CLIP_LENGTH = 196
|
| 121 |
+
STYLE_NODE_INPUT_VIDEO = 5444 # VHS_LoadVideo — dict wv keyed by "video"
|
| 122 |
|
| 123 |
|
| 124 |
def _frames_to_seconds(frames: int, fps: int) -> int:
|
pyproject.toml
CHANGED
|
@@ -7,6 +7,7 @@ markers = [
|
|
| 7 |
[tool.ruff]
|
| 8 |
line-length = 100
|
| 9 |
target-version = "py311"
|
|
|
|
| 10 |
|
| 11 |
[tool.ruff.lint]
|
| 12 |
select = ["E", "F", "I", "B", "UP"]
|
|
|
|
| 7 |
[tool.ruff]
|
| 8 |
line-length = 100
|
| 9 |
target-version = "py311"
|
| 10 |
+
exclude = ["comfyui/", ".venv/"]
|
| 11 |
|
| 12 |
[tool.ruff.lint]
|
| 13 |
select = ["E", "F", "I", "B", "UP"]
|
tests/conftest.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Shared pytest fixtures and CLI flags."""
|
|
|
|
| 2 |
import json
|
| 3 |
import os
|
| 4 |
import pathlib
|
|
@@ -11,7 +12,8 @@ REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
| 11 |
DEFAULT_MASTER_WORKFLOW = pathlib.Path(
|
| 12 |
os.environ.get(
|
| 13 |
"LTX23_MASTER_WORKFLOW",
|
| 14 |
-
pathlib.Path.home()
|
|
|
|
| 15 |
/ "1. LTX 2.3 All-In-One 260406-05.json",
|
| 16 |
)
|
| 17 |
)
|
|
@@ -26,9 +28,7 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
| 26 |
)
|
| 27 |
|
| 28 |
|
| 29 |
-
def pytest_collection_modifyitems(
|
| 30 |
-
config: pytest.Config, items: list[pytest.Item]
|
| 31 |
-
) -> None:
|
| 32 |
if not config.getoption("--gpu"):
|
| 33 |
skip_gpu = pytest.mark.skip(reason="GPU smoke tests skipped (use --gpu)")
|
| 34 |
for item in items:
|
|
|
|
| 1 |
"""Shared pytest fixtures and CLI flags."""
|
| 2 |
+
|
| 3 |
import json
|
| 4 |
import os
|
| 5 |
import pathlib
|
|
|
|
| 12 |
DEFAULT_MASTER_WORKFLOW = pathlib.Path(
|
| 13 |
os.environ.get(
|
| 14 |
"LTX23_MASTER_WORKFLOW",
|
| 15 |
+
pathlib.Path.home()
|
| 16 |
+
/ "Projects/comfyui/user/default/workflows"
|
| 17 |
/ "1. LTX 2.3 All-In-One 260406-05.json",
|
| 18 |
)
|
| 19 |
)
|
|
|
|
| 28 |
)
|
| 29 |
|
| 30 |
|
| 31 |
+
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
|
|
|
|
|
|
| 32 |
if not config.getoption("--gpu"):
|
| 33 |
skip_gpu = pytest.mark.skip(reason="GPU smoke tests skipped (use --gpu)")
|
| 34 |
for item in items:
|
tests/test_backend.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Backend tests — most are smoke / structural since the real work is GPU."""
|
|
|
|
| 2 |
import backend
|
| 3 |
|
| 4 |
|
|
|
|
| 1 |
"""Backend tests — most are smoke / structural since the real work is GPU."""
|
| 2 |
+
|
| 3 |
import backend
|
| 4 |
|
| 5 |
|
tests/test_extract_modes.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Tests for the workflow-mode extractor."""
|
|
|
|
| 2 |
import json
|
| 3 |
import subprocess
|
| 4 |
import sys
|
|
|
|
| 1 |
"""Tests for the workflow-mode extractor."""
|
| 2 |
+
|
| 3 |
import json
|
| 4 |
import subprocess
|
| 5 |
import sys
|
tests/test_models.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
"""Unit tests for models.py — MODEL_REGISTRY and ensure_models_for_mode."""
|
| 2 |
-
import pathlib
|
| 3 |
|
| 4 |
import models
|
| 5 |
import workflow
|
| 6 |
|
| 7 |
|
| 8 |
def test_model_registry_resolves_known_files():
|
| 9 |
-
assert
|
|
|
|
|
|
|
| 10 |
assert models.MODEL_REGISTRY["ltx-2.3-22b-distilled.safetensors"].subfolder == ""
|
| 11 |
|
| 12 |
|
|
@@ -22,9 +23,7 @@ def test_walk_workflow_for_models_finds_t2v_loaders():
|
|
| 22 |
needed = models.walk_workflow_for_models(wf)
|
| 23 |
# T2V needs at minimum a transformer (distilled, dev fp8, or GGUF Q4) and a gemma encoder
|
| 24 |
assert any(
|
| 25 |
-
name.endswith(".gguf")
|
| 26 |
-
or "distilled.safetensors" in name
|
| 27 |
-
or "transformer_only" in name
|
| 28 |
for name in needed
|
| 29 |
)
|
| 30 |
assert any("gemma" in name.lower() for name in needed)
|
|
@@ -38,6 +37,7 @@ def test_ensure_models_creates_symlinks_local(tmp_path, monkeypatch, fake_hf_cac
|
|
| 38 |
# Force the HF Hub call to fail so the fallback path (cache_dir.rglob) is exercised.
|
| 39 |
def _raise(*_args, **_kwargs):
|
| 40 |
raise RuntimeError("offline test: forcing fallback to cache scan")
|
|
|
|
| 41 |
monkeypatch.setattr(models, "hf_hub_download", _raise)
|
| 42 |
|
| 43 |
comfy_models = tmp_path / "comfyui" / "models"
|
|
@@ -47,23 +47,27 @@ def test_ensure_models_creates_symlinks_local(tmp_path, monkeypatch, fake_hf_cac
|
|
| 47 |
"ltx-2.3-22b-distilled.safetensors",
|
| 48 |
"model-00001-of-00005.safetensors",
|
| 49 |
}
|
| 50 |
-
|
| 51 |
|
| 52 |
# Each requested file should now have a symlink in comfyui/models/<type>/
|
| 53 |
assert (comfy_models / "checkpoints" / "ltx-2.3-22b-distilled.safetensors").is_symlink()
|
| 54 |
-
assert (
|
| 55 |
-
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
-
def test_ensure_models_skips_unregistered_files_with_warning(
|
|
|
|
|
|
|
| 59 |
"""Files not in MODEL_REGISTRY are skipped (with warning), not raised."""
|
| 60 |
import logging
|
|
|
|
| 61 |
monkeypatch.setenv("HF_HUB_CACHE", str(fake_hf_cache))
|
| 62 |
monkeypatch.setattr(models, "_on_spaces", lambda: False)
|
| 63 |
monkeypatch.setattr(models, "_comfy_models_dir", lambda: tmp_path / "comfyui" / "models")
|
| 64 |
|
| 65 |
with caplog.at_level(logging.WARNING):
|
| 66 |
-
|
| 67 |
|
| 68 |
# Should not raise, should log a warning, should yield no events for the missing entry.
|
| 69 |
assert any("nonexistent_phantom_file" in record.message for record in caplog.records)
|
|
|
|
| 1 |
"""Unit tests for models.py — MODEL_REGISTRY and ensure_models_for_mode."""
|
|
|
|
| 2 |
|
| 3 |
import models
|
| 4 |
import workflow
|
| 5 |
|
| 6 |
|
| 7 |
def test_model_registry_resolves_known_files():
|
| 8 |
+
assert (
|
| 9 |
+
models.MODEL_REGISTRY["ltx-2.3-22b-distilled.safetensors"].repo_id == "Lightricks/LTX-2.3"
|
| 10 |
+
)
|
| 11 |
assert models.MODEL_REGISTRY["ltx-2.3-22b-distilled.safetensors"].subfolder == ""
|
| 12 |
|
| 13 |
|
|
|
|
| 23 |
needed = models.walk_workflow_for_models(wf)
|
| 24 |
# T2V needs at minimum a transformer (distilled, dev fp8, or GGUF Q4) and a gemma encoder
|
| 25 |
assert any(
|
| 26 |
+
name.endswith(".gguf") or "distilled.safetensors" in name or "transformer_only" in name
|
|
|
|
|
|
|
| 27 |
for name in needed
|
| 28 |
)
|
| 29 |
assert any("gemma" in name.lower() for name in needed)
|
|
|
|
| 37 |
# Force the HF Hub call to fail so the fallback path (cache_dir.rglob) is exercised.
|
| 38 |
def _raise(*_args, **_kwargs):
|
| 39 |
raise RuntimeError("offline test: forcing fallback to cache scan")
|
| 40 |
+
|
| 41 |
monkeypatch.setattr(models, "hf_hub_download", _raise)
|
| 42 |
|
| 43 |
comfy_models = tmp_path / "comfyui" / "models"
|
|
|
|
| 47 |
"ltx-2.3-22b-distilled.safetensors",
|
| 48 |
"model-00001-of-00005.safetensors",
|
| 49 |
}
|
| 50 |
+
list(models.ensure_models(needed))
|
| 51 |
|
| 52 |
# Each requested file should now have a symlink in comfyui/models/<type>/
|
| 53 |
assert (comfy_models / "checkpoints" / "ltx-2.3-22b-distilled.safetensors").is_symlink()
|
| 54 |
+
assert (
|
| 55 |
+
comfy_models / "text_encoders" / "gemma-3-12b-it" / "model-00001-of-00005.safetensors"
|
| 56 |
+
).is_symlink()
|
| 57 |
|
| 58 |
|
| 59 |
+
def test_ensure_models_skips_unregistered_files_with_warning(
|
| 60 |
+
tmp_path, monkeypatch, fake_hf_cache, caplog
|
| 61 |
+
):
|
| 62 |
"""Files not in MODEL_REGISTRY are skipped (with warning), not raised."""
|
| 63 |
import logging
|
| 64 |
+
|
| 65 |
monkeypatch.setenv("HF_HUB_CACHE", str(fake_hf_cache))
|
| 66 |
monkeypatch.setattr(models, "_on_spaces", lambda: False)
|
| 67 |
monkeypatch.setattr(models, "_comfy_models_dir", lambda: tmp_path / "comfyui" / "models")
|
| 68 |
|
| 69 |
with caplog.at_level(logging.WARNING):
|
| 70 |
+
list(models.ensure_models({"nonexistent_phantom_file.safetensors"}))
|
| 71 |
|
| 72 |
# Should not raise, should log a warning, should yield no events for the missing entry.
|
| 73 |
assert any("nonexistent_phantom_file" in record.message for record in caplog.records)
|
tests/test_modes.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Unit tests for modes.py — MODE_REGISTRY and parameterize_fn correctness."""
|
|
|
|
| 2 |
import pytest
|
| 3 |
|
| 4 |
import modes
|
|
@@ -23,7 +24,7 @@ def test_t2v_parameterize_produces_valid_patches(canonical_inputs):
|
|
| 23 |
patches = mode.parameterize_fn(inputs)
|
| 24 |
|
| 25 |
# All patches must be (node_id: int, widget_index: int, value: Any)
|
| 26 |
-
for node_id, widget_index,
|
| 27 |
assert isinstance(node_id, int)
|
| 28 |
assert isinstance(widget_index, int)
|
| 29 |
|
|
@@ -88,7 +89,12 @@ def test_style_parameterize_passes_input_video(canonical_inputs):
|
|
| 88 |
def test_mode_registry_has_all_six_keys():
|
| 89 |
"""All six modes are in the registry now."""
|
| 90 |
assert set(modes.MODE_REGISTRY.keys()) == {
|
| 91 |
-
"t2v",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
|
|
|
|
| 1 |
"""Unit tests for modes.py — MODE_REGISTRY and parameterize_fn correctness."""
|
| 2 |
+
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
import modes
|
|
|
|
| 24 |
patches = mode.parameterize_fn(inputs)
|
| 25 |
|
| 26 |
# All patches must be (node_id: int, widget_index: int, value: Any)
|
| 27 |
+
for node_id, widget_index, _value in patches:
|
| 28 |
assert isinstance(node_id, int)
|
| 29 |
assert isinstance(widget_index, int)
|
| 30 |
|
|
|
|
| 89 |
def test_mode_registry_has_all_six_keys():
|
| 90 |
"""All six modes are in the registry now."""
|
| 91 |
assert set(modes.MODE_REGISTRY.keys()) == {
|
| 92 |
+
"t2v",
|
| 93 |
+
"a2v",
|
| 94 |
+
"i2v",
|
| 95 |
+
"lipsync",
|
| 96 |
+
"keyframe",
|
| 97 |
+
"style",
|
| 98 |
}
|
| 99 |
|
| 100 |
|
tests/test_workflow.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Unit tests for workflow.py — pure functions over JSON dicts."""
|
|
|
|
| 2 |
import pytest
|
| 3 |
|
| 4 |
import workflow
|
|
|
|
| 1 |
"""Unit tests for workflow.py — pure functions over JSON dicts."""
|
| 2 |
+
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
import workflow
|
tools/extract_modes.py
CHANGED
|
@@ -12,6 +12,7 @@ Group title -> output filename mapping:
|
|
| 12 |
05 -> keyframe.json
|
| 13 |
06 -> style.json
|
| 14 |
"""
|
|
|
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
import argparse
|
|
@@ -94,7 +95,7 @@ def extract_mode(master: dict, mode_prefix: str) -> dict:
|
|
| 94 |
"id": f"ltx23-aio-{mode_prefix}",
|
| 95 |
"revision": 0,
|
| 96 |
"last_node_id": max(kept_ids, default=0),
|
| 97 |
-
"last_link_id": max((
|
| 98 |
"nodes": nodes,
|
| 99 |
"links": links,
|
| 100 |
"groups": groups,
|
|
|
|
| 12 |
05 -> keyframe.json
|
| 13 |
06 -> style.json
|
| 14 |
"""
|
| 15 |
+
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
import argparse
|
|
|
|
| 95 |
"id": f"ltx23-aio-{mode_prefix}",
|
| 96 |
"revision": 0,
|
| 97 |
"last_node_id": max(kept_ids, default=0),
|
| 98 |
+
"last_link_id": max((link[0] for link in links), default=0),
|
| 99 |
"nodes": nodes,
|
| 100 |
"links": links,
|
| 101 |
"groups": groups,
|
tools/refresh_models.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Materialize all LTX 2.3 model files for every mode by walking each template."""
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import pathlib
|
|
|
|
| 1 |
"""Materialize all LTX 2.3 model files for every mode by walking each template."""
|
| 2 |
+
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import pathlib
|
ui.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
| 1 |
# ui.py
|
| 2 |
"""Reusable Gradio components shared across modes."""
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
| 5 |
import gradio as gr
|
| 6 |
|
| 7 |
|
|
@@ -48,11 +51,11 @@ def render_status(
|
|
| 48 |
f' <div class="status-row">'
|
| 49 |
f' <span class="status-stage">Stage {stage_index} · {stage_label}</span>'
|
| 50 |
f' <span class="status-meta">Step {step}/{total_steps} · '
|
| 51 |
-
f
|
| 52 |
-
f
|
| 53 |
f' <div class="status-bar"><div class="status-fill" style="width:{pct}%"></div></div>'
|
| 54 |
f' <div class="status-mem">{memory_text}</div>'
|
| 55 |
-
f
|
| 56 |
)
|
| 57 |
|
| 58 |
|
|
@@ -63,12 +66,15 @@ def _fmt_secs(secs: float) -> str:
|
|
| 63 |
return f"{secs // 60}m {secs % 60}s"
|
| 64 |
|
| 65 |
|
| 66 |
-
from dataclasses import dataclass
|
| 67 |
-
|
| 68 |
-
|
| 69 |
CAMERA_LORAS: list[str] = [
|
| 70 |
-
"none",
|
| 71 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
]
|
| 73 |
|
| 74 |
IC_LORAS_BY_MODE: dict[str, list[str]] = {
|
|
@@ -101,19 +107,29 @@ def lora_chrome(mode: str) -> LoRAComponents:
|
|
| 101 |
with gr.Group():
|
| 102 |
gr.Markdown("**📷 Camera Movement**")
|
| 103 |
camera_lora = gr.Dropdown(
|
| 104 |
-
choices=CAMERA_LORAS,
|
|
|
|
|
|
|
| 105 |
info="Mutually exclusive — pick one camera direction or none.",
|
| 106 |
)
|
| 107 |
camera_strength = gr.Slider(
|
| 108 |
-
minimum=0.0,
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
)
|
| 111 |
|
| 112 |
with gr.Group():
|
| 113 |
gr.Markdown("**✨ Detailer**")
|
| 114 |
detailer_on = gr.Checkbox(label="Apply IC-LoRA-Detailer", value=False)
|
| 115 |
detailer_strength = gr.Slider(
|
| 116 |
-
minimum=0.0,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
)
|
| 118 |
|
| 119 |
ic_lora = ic_strength = pose_on = None
|
|
@@ -127,7 +143,11 @@ def lora_chrome(mode: str) -> LoRAComponents:
|
|
| 127 |
label="IC-LoRA",
|
| 128 |
)
|
| 129 |
ic_strength = gr.Slider(
|
| 130 |
-
minimum=0.0,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
)
|
| 132 |
|
| 133 |
if mode in ("i2v", "lipsync"):
|
|
|
|
| 1 |
# ui.py
|
| 2 |
"""Reusable Gradio components shared across modes."""
|
| 3 |
+
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
import gradio as gr
|
| 9 |
|
| 10 |
|
|
|
|
| 51 |
f' <div class="status-row">'
|
| 52 |
f' <span class="status-stage">Stage {stage_index} · {stage_label}</span>'
|
| 53 |
f' <span class="status-meta">Step {step}/{total_steps} · '
|
| 54 |
+
f" {_fmt_secs(elapsed_s)} elapsed · ~{_fmt_secs(eta_s)} remaining</span>"
|
| 55 |
+
f" </div>"
|
| 56 |
f' <div class="status-bar"><div class="status-fill" style="width:{pct}%"></div></div>'
|
| 57 |
f' <div class="status-mem">{memory_text}</div>'
|
| 58 |
+
f"</div>"
|
| 59 |
)
|
| 60 |
|
| 61 |
|
|
|
|
| 66 |
return f"{secs // 60}m {secs % 60}s"
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
| 69 |
CAMERA_LORAS: list[str] = [
|
| 70 |
+
"none",
|
| 71 |
+
"static",
|
| 72 |
+
"dolly-in",
|
| 73 |
+
"dolly-out",
|
| 74 |
+
"dolly-left",
|
| 75 |
+
"dolly-right",
|
| 76 |
+
"jib-up",
|
| 77 |
+
"jib-down",
|
| 78 |
]
|
| 79 |
|
| 80 |
IC_LORAS_BY_MODE: dict[str, list[str]] = {
|
|
|
|
| 107 |
with gr.Group():
|
| 108 |
gr.Markdown("**📷 Camera Movement**")
|
| 109 |
camera_lora = gr.Dropdown(
|
| 110 |
+
choices=CAMERA_LORAS,
|
| 111 |
+
value="none",
|
| 112 |
+
label="Camera",
|
| 113 |
info="Mutually exclusive — pick one camera direction or none.",
|
| 114 |
)
|
| 115 |
camera_strength = gr.Slider(
|
| 116 |
+
minimum=0.0,
|
| 117 |
+
maximum=1.5,
|
| 118 |
+
value=0.8,
|
| 119 |
+
step=0.05,
|
| 120 |
+
label="Camera strength",
|
| 121 |
+
visible=True,
|
| 122 |
)
|
| 123 |
|
| 124 |
with gr.Group():
|
| 125 |
gr.Markdown("**✨ Detailer**")
|
| 126 |
detailer_on = gr.Checkbox(label="Apply IC-LoRA-Detailer", value=False)
|
| 127 |
detailer_strength = gr.Slider(
|
| 128 |
+
minimum=0.0,
|
| 129 |
+
maximum=1.0,
|
| 130 |
+
value=0.5,
|
| 131 |
+
step=0.05,
|
| 132 |
+
label="Detailer strength",
|
| 133 |
)
|
| 134 |
|
| 135 |
ic_lora = ic_strength = pose_on = None
|
|
|
|
| 143 |
label="IC-LoRA",
|
| 144 |
)
|
| 145 |
ic_strength = gr.Slider(
|
| 146 |
+
minimum=0.0,
|
| 147 |
+
maximum=1.0,
|
| 148 |
+
value=0.5,
|
| 149 |
+
step=0.05,
|
| 150 |
+
label="IC strength",
|
| 151 |
)
|
| 152 |
|
| 153 |
if mode in ("i2v", "lipsync"):
|
workflow.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Pure functions over LTX 2.3 mode workflow JSON templates."""
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import copy
|
|
|
|
| 1 |
"""Pure functions over LTX 2.3 mode workflow JSON templates."""
|
| 2 |
+
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import copy
|