techfreakworm commited on
Commit
3ea399a
·
unverified ·
1 Parent(s): 2fd6ed6

ci: run unit tests + ruff lint on every push

Browse files
.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 name, mode in modes.MODE_REGISTRY.items():
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() as 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(label="Prompt", lines=4, placeholder="Describe the shot...")
 
 
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 ("image", "audio", "first_frame", "last_frame", "input_video",
206
- "camera_lora", "camera_strength",
207
- "detailer_on", "detailer_strength",
208
- "ic_lora", "ic_strength", "pose_on", "audio_cfg", "image_strength"):
 
 
 
 
 
 
 
 
 
 
 
 
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, eta_s=0,
 
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, eta_s=eta,
 
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' <div>{event.message}</div>'
255
- f'</div>'
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
- h["lora"].camera_lora, h["lora"].camera_strength,
297
- h["lora"].detailer_on, h["lora"].detailer_strength,
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, Optional
17
 
18
  import models
19
 
@@ -36,7 +37,7 @@ class ProgressEvent:
36
  @dataclass
37
  class OutputEvent:
38
  video_path: str
39
- audio_path: Optional[str] = None
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: Optional[int] = None
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(ProgressEvent(
117
- stage=0, stage_label="diffusion",
118
- step=int(value), total_steps=int(total),
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(ErrorEvent(
141
- category=_classify(exc),
142
- message=str(exc),
143
- traceback=tb_mod.format_exc(),
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) -> Optional[str]:
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
- "Lightricks/LTX-2.3", comfy_type="checkpoints"
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
- "Kijai/LTX2.3_comfy", comfy_type="vae"
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") or value.endswith(".gguf")
137
- or value == "tokenizer.model" or value.endswith(".json")
 
 
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 # CLIPTextEncode positive — wv[0] = prompt
68
- T2V_NODE_NEG_PROMPT = 5537 # CLIPTextEncode negative — wv[0] = negative prompt
69
- T2V_NODE_WIDTH = 5383 # INTConstant "Width" — wv[0]
70
- T2V_NODE_HEIGHT = 5382 # INTConstant "Height" — wv[0]
71
- T2V_NODE_FPS = 5445 # INTConstant "FPS" — wv[0]
72
- T2V_NODE_CLIP_LENGTH = 196 # mxSlider "Clip Length ( in seconds )" — wv[0]
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 # LoadImage "Load Image1" — wv[0] = filename
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 # VHS_LoadAudioUpload — dict wv keyed by "audio"
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 # LoadImage "Load Image1" — wv[0] = filename
107
- LIPSYNC_NODE_AUDIO = 5400 # VHS_LoadAudioUpload — dict wv keyed by "audio"
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 # LoadImage "Load Image1" — wv[0] = filename
114
- KEYFRAME_NODE_LAST_FRAME = 5437 # LoadImage "Load Image2" — wv[0] = filename
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 # VHS_LoadVideo — dict wv keyed by "video"
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() / "Projects/comfyui/user/default/workflows"
 
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 models.MODEL_REGISTRY["ltx-2.3-22b-distilled.safetensors"].repo_id == "Lightricks/LTX-2.3"
 
 
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
- events = 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 (comfy_models / "text_encoders" / "gemma-3-12b-it"
55
- / "model-00001-of-00005.safetensors").is_symlink()
 
56
 
57
 
58
- def test_ensure_models_skips_unregistered_files_with_warning(tmp_path, monkeypatch, fake_hf_cache, caplog):
 
 
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
- events = list(models.ensure_models({"nonexistent_phantom_file.safetensors"}))
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, value in patches:
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", "a2v", "i2v", "lipsync", "keyframe", "style",
 
 
 
 
 
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((l[0] for l in links), default=0),
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' {_fmt_secs(elapsed_s)} elapsed · ~{_fmt_secs(eta_s)} remaining</span>'
52
- f' </div>'
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'</div>'
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", "static", "dolly-in", "dolly-out", "dolly-left", "dolly-right",
71
- "jib-up", "jib-down",
 
 
 
 
 
 
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, value="none", label="Camera",
 
 
105
  info="Mutually exclusive — pick one camera direction or none.",
106
  )
107
  camera_strength = gr.Slider(
108
- minimum=0.0, maximum=1.5, value=0.8, step=0.05,
109
- label="Camera strength", visible=True,
 
 
 
 
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, maximum=1.0, value=0.5, step=0.05, label="Detailer strength",
 
 
 
 
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, maximum=1.0, value=0.5, step=0.05, label="IC strength",
 
 
 
 
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