Fabrice-TIERCELIN commited on
Commit
2348409
·
1 Parent(s): 4f80c8f
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +0 -46
  2. .gitmodules +0 -3
  3. CLAUDE.md +0 -263
  4. LICENSE +674 -21
  5. README.md +6 -74
  6. SKILLS.md +0 -299
  7. app.py +76 -1217
  8. assets/seed_inputs/5.FLF2.png +0 -0
  9. assets/seed_inputs/IMG-20210721-WA0008.jpg +0 -0
  10. assets/seed_inputs/Sway Dance Lesson 31s.mp4 +0 -0
  11. assets/seed_inputs/beauty_pagent_dialogue.mp3 +0 -0
  12. assets/seed_inputs/influencer_mic_hd.png +0 -0
  13. backend.py +0 -603
  14. chain_injectors/__init__.py +50 -0
  15. chain_injectors/lora_injector.py +76 -0
  16. {tests → comfy_integration}/__init__.py +0 -0
  17. comfy_integration/nodes.py +44 -0
  18. comfy_integration/setup.py +74 -0
  19. {tools → core}/__init__.py +0 -0
  20. core/generation_logic.py +149 -0
  21. core/model_manager.py +32 -0
  22. core/pipelines/__init__.py +0 -0
  23. core/pipelines/base_pipeline.py +55 -0
  24. core/pipelines/pipeline_input_processor.py +36 -0
  25. core/pipelines/workflow_executor.py +131 -0
  26. core/settings.py +135 -0
  27. core/shared_state.py +1 -0
  28. core/workflow_assembler.py +203 -0
  29. docs/future_improvements.md +0 -148
  30. docs/superpowers/plans/2026-04-30-ltx23-aio-generator.md +0 -2932
  31. docs/superpowers/plans/2026-05-01-topaz-drawer-redesign.md +0 -535
  32. docs/superpowers/specs/2026-04-30-ltx23-aio-generator-design.md +0 -483
  33. docs/superpowers/specs/2026-05-01-topaz-drawer-redesign-design.md +0 -104
  34. mcp_tools/__init__.py +41 -0
  35. mcp_tools/common.py +465 -0
  36. mcp_tools/error_schema.py +85 -0
  37. mcp_tools/get_task_list.py +11 -0
  38. mcp_tools/get_task_status.py +21 -0
  39. mcp_tools/mcp_gradio_integration.py +120 -0
  40. mcp_tools/run.py +113 -0
  41. mcp_tools/tool_handlers.py +19 -0
  42. models.py +0 -338
  43. modes.py +0 -217
  44. pyproject.toml +0 -17
  45. requirements.txt +35 -52
  46. setup.sh +0 -51
  47. tests/conftest.py +0 -158
  48. tests/test_backend.py +0 -14
  49. tests/test_extract_modes.py +0 -39
  50. tests/test_models.py +0 -73
.gitignore DELETED
@@ -1,46 +0,0 @@
1
- # Superpowers brainstorming session artifacts
2
- .superpowers/
3
-
4
- # Python
5
- .venv/
6
- venv/
7
- __pycache__/
8
- *.pyc
9
- *.pyo
10
- *.egg-info/
11
- .pytest_cache/
12
- .mypy_cache/
13
- .ruff_cache/
14
-
15
- # Models (downloaded to HF cache, never to repo)
16
- models/
17
- checkpoints/
18
- *.safetensors
19
- *.gguf
20
-
21
- # Outputs
22
- outputs/
23
- generated/
24
- *.mp4
25
- *.wav
26
- *.webm
27
- !demo/**/*.mp4
28
- !demo/**/*.wav
29
- !assets/seed_inputs/**
30
-
31
- # OS
32
- .DS_Store
33
- Thumbs.db
34
-
35
- # IDE
36
- .vscode/
37
- .idea/
38
-
39
- # Env
40
- .env
41
- .env.local
42
- *.log
43
-
44
- # Gradio cache
45
- gradio_cached_examples/
46
- flagged/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitmodules DELETED
@@ -1,3 +0,0 @@
1
- [submodule "comfyui"]
2
- path = comfyui
3
- url = https://github.com/comfyanonymous/ComfyUI.git
 
 
 
 
CLAUDE.md DELETED
@@ -1,263 +0,0 @@
1
- # Project Guidelines — ltx2.3-AIO-generator
2
-
3
- Working notes for AI assistants and subagents implementing this project.
4
-
5
- > Companion: see `SKILLS.md` for process rules — how to investigate, verify,
6
- > commit, and ship changes here. This file is the *what* and *why*; SKILLS.md
7
- > is the *how*.
8
-
9
- ---
10
-
11
- ## ⚠ Git authorship — sole author rule
12
-
13
- **Mayank Gupta is the sole author on every commit in this repo.** No exceptions.
14
-
15
- When committing:
16
-
17
- - Do **NOT** append `Co-Authored-By: Claude ...` (or any other agent name).
18
- - Do **NOT** add "Generated with Claude Code" / "🤖 Generated with..." footers.
19
- - Do **NOT** pass `--author=...` — let git use the user's existing config.
20
- - Do **NOT** include attribution in PR descriptions.
21
-
22
- If asked to amend, re-commit, or rebase, strip any prior agent attribution from the commit message. Treat any tooling that suggests adding a Claude trailer as a bug to ignore.
23
-
24
- ---
25
-
26
- ## Project overview
27
-
28
- Gradio app wrapping the existing ComfyUI LTX 2.3 All-In-One workflow into mode-specific UIs. Same code runs locally (Apple Silicon MPS / NVIDIA CUDA) and on Hugging Face Spaces (ZeroGPU, Pro tier).
29
-
30
- **Spec:** `docs/superpowers/specs/2026-04-30-ltx23-aio-generator-design.md`
31
- **Plan:** `docs/superpowers/plans/2026-04-30-ltx23-aio-generator.md`
32
- **Future-improvements backlog:** `docs/future_improvements.md`
33
-
34
- If you're a subagent picking up a task, the plan file is your assignment.
35
-
36
- ---
37
-
38
- ## Modes (six)
39
-
40
- `t2v` text→video · `i2v` image→video · `a2v` audio→video · `lipsync` (image+audio) · `keyframe` (first+last frame→video) · `style` (preprocessor + IC-LoRA → restyle).
41
-
42
- Each is a separate API-format JSON in `workflows/`. Per-mode parameter patches live in `modes.py` `parameterize_fn`.
43
-
44
- ---
45
-
46
- ## Architectural facts (locked — do not relitigate)
47
-
48
- 1. **Backend is ComfyUI in library mode.** We call `comfy.execution.PromptExecutor` directly with workflow JSONs we parameterize. We do NOT run ComfyUI as a subprocess.
49
- 2. **Six mode-specific workflow JSON files** in `workflows/` are user-exported "API format" from the master workflow. Do not hand-edit. Editor-format (with `nodes` array) does NOT work — `walk_workflow_for_models` and `PromptExecutor` both expect API format.
50
- 3. **Models live in HF cache.** Local: `~/.cache/huggingface/hub` symlinked into `comfyui/models/<comfy_type>/`. Spaces: same hub cache mirrored into `~/hf-cache-rw/` (see "Spaces deployment" below). Never commit `*.safetensors`, `*.gguf`, `*.bin`, `*.pt`. The `assets/seed_inputs/` exception in `.gitignore` covers the small placeholder files.
51
- 4. **One backend, one process.** The `@spaces.GPU` decorator is the only divergence between local and Spaces runtimes.
52
- 5. **VRAM is ComfyUI's job.** The only `empty_cache()` calls live in `backend.py`'s `try/finally`. Don't sprinkle them elsewhere.
53
- 6. **Bundled ComfyUI, never user's existing.** Local: git submodule. Spaces: runtime clone via `_git_clone()` in `app.py:_bootstrap()`.
54
- 7. **comfy_dir resolves per-platform.** `~/comfyui` on Spaces (writable HOME), `<repo>/comfyui` locally. Both `app.py` and `backend.py` have `_comfy_dir()`-style helpers that MUST stay in sync.
55
- 8. **Custom nodes are pinned to SHAs**, not branches. See `CUSTOM_NODES_PINNED` in `app.py`. `--branch <SHA>` doesn't work in `git clone`; we use init+fetch+checkout via `_git_clone()`.
56
-
57
- ---
58
-
59
- ## Spaces deployment specifics (where the gotchas live)
60
-
61
- ### Model loading: `preload_from_hub` + runtime cache mirror
62
-
63
- HF Spaces' `preload_from_hub` directive in README YAML downloads listed files at build time into `~/.cache/huggingface/hub`. **Limitation: those files are owned by the build user** (root-ish). At runtime we run as uid 1000 and can't write there — any `hf_hub_download` for a non-preloaded file fails with `Permission denied (os error 13)`.
64
-
65
- **Fix:** `_mirror_preload_hf_cache()` in `app.py` walks the read-only preload tree once at bootstrap and builds a parallel writable tree at `~/hf-cache-rw/`:
66
- - `blobs/<sha>` files → **hardlinked** (zero-copy, shared inode, instant reads)
67
- - `snapshots/<commit>/...` symlinks → **preserved** (relative paths resolve within the mirror)
68
- - `refs/<branch>` → **byte-copied** (HF lib overwrites these on etag check; hardlinks would fail)
69
- - All dirs → mkdir (we own them)
70
- - Falls back to symlink if `os.link()` returns EXDEV (cross-device)
71
-
72
- Then sets `HF_HOME=~/hf-cache-rw` and `HF_HUB_CACHE=~/hf-cache-rw/hub`. After this, preloaded reads are instant cache hits AND lazy downloads write to dirs we own.
73
-
74
- The 10-entry cap on `preload_from_hub` is a hard HF limit. Total preload size cap is 150 GB (Spaces ephemeral storage). Current list is ~111 GB; see `docs/future_improvements.md` for what got dropped (84 GB of unused Lightricks transformers, 39 GB GGUF — both lazy-load when actually referenced).
75
-
76
- ### Per-call ZeroGPU duration: dynamic estimator + auto-retry
77
-
78
- `@spaces.GPU(duration=N)` is a per-call timeout, not a billing cap. Shorter declared duration = faster queue priority on the shared pool. Setting a one-size-fits-all 600s caps everything in the slow lane.
79
-
80
- **`_duration_for(executor, workflow, output_ids, mode, preset, multiplier=1.0)`** in `backend.py` estimates from:
81
- - `_BASE_DURATION_S[mode]` — t2v 90s, lipsync 240s, style 360s, etc.
82
- - `_PRESET_MULT[preset]` — fast 1×, balanced 1.5×, quality 3×
83
- - `_frames_from_workflow(workflow)` — read from `EmptyLTXVLatentVideo` `length`
84
- - +60s cold-cache buffer, +0.3s/frame VAE decode
85
- - Clamped to `[60s, 900s]`
86
-
87
- `@spaces.GPU(duration=_duration_for)` decorates `_execute_workflow` — ZeroGPU calls the estimator with the same args.
88
-
89
- **Auto-retry on timeout** in `_on_generate` (app.py): if first attempt raises `gradio.exceptions.Error('GPU task aborted')`, classified as `category='gpu_timeout'`, the handler shows a "Retrying with extended GPU budget" banner and re-submits with `duration_multiplier=2.0`. The estimator clamps the retry at 900s anyway. One retry only.
90
-
91
- ### Returning the video path through ZeroGPU's subprocess boundary
92
-
93
- `executor.history_result` was unreliable across the `@spaces.GPU` boundary — sometimes the parent process saw an empty dict even when the file was on disk. Fix: `_execute_workflow` reads `history_result["outputs"]` INSIDE the GPU context and returns the path string directly (picklable). Plus a filesystem fallback `_newest_recent_video()` that scans `comfyui/output/` for the newest mp4 modified in the last 60s.
94
-
95
- ### `allowed_paths` for video output
96
-
97
- Gradio 5 refuses to expose files outside cwd / temp / `allowed_paths`. ComfyUI writes to `~/comfyui/output/...` which is outside our app's cwd `/home/user/app` on Spaces. `app.launch(..., allowed_paths=[str(_output_dir)])` whitelists the entire ComfyUI output tree. Without this, video generates fine but `gr.Video` shows blank.
98
-
99
- ### HF Spaces' header widget z-index (DOM-injected)
100
-
101
- When a Space is loaded via the bare embed URL (`https://*.hf.space`), HF injects `#huggingface-space-header` at fixed `z-index: 20` in the top-right (the heart/share widget). Our header z-index has to coexist:
102
- - Default: header `z-index: 15` (below HF widget — visible)
103
- - Drawer open: `.drawer-elevated` class bumps to `z-index: 60` (above scrim 45 / drawer 50, hamburger × clickable as close)
104
-
105
- JS toggles `.drawer-elevated` on `.aio-header` in lockstep with `.drawer-open` on `.aio-shell`. Three call sites: hamburger onclick, click-outside dismisser (in `gr.Blocks(head=...)` because `<script>` in `gr.HTML` gets stripped), mode-button auto-close.
106
-
107
- ### Custom nodes the workflow needs
108
-
109
- Pinned in `CUSTOM_NODES_PINNED` (`app.py`):
110
-
111
- ```
112
- Lightricks/ComfyUI-LTXVideo
113
- kijai/ComfyUI-KJNodes
114
- rgthree/rgthree-comfy
115
- Kosinkadink/ComfyUI-VideoHelperSuite
116
- pythongosssss/ComfyUI-Custom-Scripts
117
- city96/ComfyUI-GGUF
118
- Fannovel16/comfyui_controlnet_aux
119
- evanspearman/ComfyMath
120
- Smirnov75/ComfyUI-mxToolkit
121
- DoctorDiffusion/ComfyUI-MediaMixer (provides FinalFrameSelector)
122
- ```
123
-
124
- Also `requirements.txt` includes deps the custom nodes need but their own `requirements.txt` files don't list (gguf, imageio_ffmpeg, opencv-python, matplotlib, diffusers, yt-dlp, psutil).
125
-
126
- ---
127
-
128
- ## UI design system: Topaz Cinema Slate
129
-
130
- Dark slate background + amber accent, IBM Plex typography. Defined as `_TOPAZ_THEME = gr.themes.Base(...).set(...)` in `app.py`. Custom CSS in `_CUSTOM_CSS` for everything Gradio's theme machinery doesn't cover (drawer, header, mode buttons, status banner).
131
-
132
- Layout: hamburger drawer. Pinned 220 px sidebar at ≥1024 px; below that, `position: fixed` overlay sliding from `left: -100%` to `left: 0` via `.aio-shell.drawer-open`.
133
-
134
- Mode-tag in header (`#aio-mode-tag`) shows current mode (T2V/A2V/I2V/LIPSYNC/KEY/STYLE), updated by JS in mode-button click handlers.
135
-
136
- Spec: `docs/superpowers/specs/2026-05-01-topaz-drawer-redesign-design.md`
137
- Plan: `docs/superpowers/plans/2026-05-01-topaz-drawer-redesign.md`
138
-
139
- ---
140
-
141
- ## Critical Gradio scoping facts
142
-
143
- - **Gradio prefixes user CSS** with `.gradio-container.gradio-container-<version> .contain ` — selectors that need to escape upward (`body:has(...)`, `html.foo .bar`) are rewritten to nonsense and silently break. Toggle classes via JS on elements INSIDE `.contain` (we use `.aio-shell` and `.aio-header`).
144
- - **Gradio strips `<script>` tags inside `gr.HTML`** at sanitization. Inline scripts MUST go in `gr.Blocks(head=...)` to actually run. The `_HEAD_HTML` string in `app.py` is where the global click-outside dismisser lives.
145
- - **Gradio's form labels have `z-index: 40`** built in. Anything we want above them (drawer, scrim) needs `z-index >= 41`. Our hierarchy: header (15 default → 60 elevated) > drawer (50) > scrim (45) > Gradio labels (40) > body.
146
- - **`onclick="..."` attributes on plain HTML buttons DO survive** sanitization. Use them for tiny per-element interactions (hamburger toggle).
147
-
148
- ---
149
-
150
- ## Coding conventions
151
-
152
- ### Language and structure
153
-
154
- - **Python 3.11.** No `match` statements (Spaces Python pin compatibility — Spaces base image is 3.10).
155
- - **Flat layout.** No `src/`, no nested packages. Top-level `.py` files only, each with one clear responsibility.
156
- - **No conda.** Always `python3.11 -m venv .venv`. System binaries via `brew`.
157
-
158
- ### Style
159
-
160
- - **No emojis** in code or commit messages unless the user explicitly asks. UI text and stage labels in `modes.py` / `ui.py` are OK because they are user-facing — not code.
161
- - **Comments only for non-obvious WHY.** Never narrate WHAT. Code with a good name doesn't need a comment.
162
- - **Type hints on public functions.** Internal helpers can skip them if obvious.
163
- - **Imports at top of file.** Inline imports only to break circular deps (e.g., `models.ensure_models_for_mode` imports `workflow` lazily — keep this, it's load-bearing).
164
- - **Format with `ruff format`.** Lint with `ruff check`. Both must pass in CI.
165
-
166
- ### Commits
167
-
168
- - **Conventional Commits style:** `<type>(<scope>): <subject>` — types: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `ci`, `perf`.
169
- - **Subject is imperative, lowercase, no trailing period.**
170
- - **Body explains WHY when not obvious.** Reference spec/plan section if relevant.
171
- - **Frequent small commits.** One logical change per commit.
172
- - **No agent attribution** (see top of file).
173
- - See `SKILLS.md` for the full process around when to commit vs hold.
174
-
175
- ### Testing
176
-
177
- - **TDD per the plan.** Each implementation task has the failing test first.
178
- - **No mocks for ComfyUI.** Tests run against real workflow JSONs. Stubs only for HTTP boundaries (HF Hub) and filesystem (use `tmp_path` and the `fake_hf_cache` fixture).
179
- - **L1 + L3 in CI** (no GPU). L2 + L4 are local-developer-only.
180
- - **Test naming:** `test_<unit>_<behavior_under_test>`.
181
- - **`pytest --gpu`** enables L4 smoke tests. Default skips them.
182
- - **`pytest --comfy-real`** uses bundled ComfyUI for L2 instead of the static stub validator.
183
-
184
- ---
185
-
186
- ## Editing the master workflow
187
-
188
- When the user updates `~/Projects/comfyui/user/default/workflows/1. LTX 2.3 All-In-One 260406-05.json`:
189
-
190
- ```bash
191
- python3.11 tools/extract_modes.py \
192
- --master ~/Projects/comfyui/user/default/workflows/"1. LTX 2.3 All-In-One 260406-05.json" \
193
- --out workflows
194
- ```
195
-
196
- Then run the test suite — L2 graph-validation catches any node that became invalid in any mode.
197
-
198
- After templates regenerate, the node-id constants in `modes.py` (e.g., `T2V_NODE_PROMPT = 240`) may need updating if ComfyUI re-numbered nodes. Procedure in plan Task 11 Step 4.
199
-
200
- The user has explicitly said **don't change JSON** — when adding capabilities, prefer parameterize_fn patches over hand-edits. The user re-exports from ComfyUI editor when the workflow changes.
201
-
202
- ---
203
-
204
- ## Common pitfalls (read before opening a PR)
205
-
206
- ### ComfyUI / models
207
-
208
- - **Loading models eagerly at import time.** Don't. `backend.py` constructs `PromptExecutor` once at instantiation; models load only when nodes execute.
209
- - **Hard-coded `torch.cuda` calls.** Use `comfy.model_management.get_torch_device()` or guard with `if torch.cuda.is_available()`. Never assume CUDA.
210
- - **Forgetting `.deepcopy` on workflow templates.** `workflow.load_template` already does this; if you bypass it for performance, you'll mutate the cached template.
211
- - **Importing `comfy.*` before `sys.path.insert(0, comfy_dir)`.** Will `ModuleNotFoundError`. The order in `backend.py:__init__` is intentional.
212
- - **`walk_workflow_for_models` returning empty.** Check that the workflow is API format (`{node_id: {class_type, inputs}}`), not editor format (`{nodes: [...]}`). The walker recurses into `Power Lora Loader` rows and skips ones with `on: false`.
213
- - **Hardcoded paths in seed inputs.** The workflow's `LoadImage` / `VHS_LoadVideo` nodes have baked-in default filenames (`Screenshot 2026-04-23 023318.jpeg`, `4. Lipsync Music.mp3`, etc.). Our `assets/seed_inputs/` covers the ones that ship with the master, plus `_stage_to_comfy_input` copies user uploads into `comfyui/input/`. If a workflow update adds a new default filename, add a placeholder file.
214
- - **`_COMFY_INPUT_DIR` and `_comfy_dir()` must agree.** Bug we hit: `app.py` had it hardcoded to `<repo>/comfyui/input` but on Spaces ComfyUI runs at `~/comfyui`. User uploads went to a directory ComfyUI never read. Both have to use the same on-Spaces vs local logic.
215
-
216
- ### Gradio / UI
217
-
218
- - **Adding `<script>` to `gr.HTML`.** Gets stripped. Use `gr.Blocks(head=...)`.
219
- - **Selectors that escape `.contain`.** Gradio rewrites them. Use a class on `.aio-shell` or `.aio-header` instead.
220
- - **`gr.Video` paths outside cwd.** Need `allowed_paths=` on launch.
221
- - **Z-index above HF's injected widget.** Header default z-index must be < 20 to not cover the heart/share widget. We use 15, bump to 60 only when drawer is open.
222
-
223
- ### Spaces
224
-
225
- - **`/data` requires the persistent-storage add-on** (separate paid feature, not included in Pro). We use `~/comfyui` and `~/hf-cache-rw` instead.
226
- - **Build user vs runtime user permissions.** preload_from_hub files are read-only for us. Mirror them — see "Spaces deployment specifics" above.
227
- - **`@spaces.GPU` requires module-level decoration.** Runtime-applied decoration isn't detected by ZeroGPU's startup analyzer. Module-level static decorator + dynamic-duration callable is the supported pattern.
228
- - **`history_result` may not survive ZeroGPU's subprocess boundary.** Compute outputs INSIDE the decorated function and return primitive types (str, int, dict of strs).
229
- - **`allowed_paths` on `app.launch()`** must include the ComfyUI output dir or videos won't display.
230
- - **Custom Dockerfile breaks ZeroGPU.** ZeroGPU is exclusively compatible with `sdk: gradio`. Switching to `sdk: docker` loses GPU access.
231
-
232
- ### Authoring
233
-
234
- - **Adding `Co-Authored-By` because tooling suggests it.** See top of file. Strip it.
235
- - **Don't push during HF testing.** When the user is running tests on the live Space, hold local commits until they say push. They'll explicitly tell you when to push.
236
-
237
- ---
238
-
239
- ## Out of scope for v1 (do not implement without asking)
240
-
241
- These are documented as v1.1+ in spec § 11. Don't pre-build them just because they'd be easy:
242
-
243
- - **Lite mode** (`LTX23_AIO_LITE=1`) for free HF Spaces tier
244
- - **Custom LoRA** add/remove rows (Power-Lora-Loader clone)
245
- - **GGUF Q4 transformer** / "Low VRAM" preset (the GGUF is loaded but always BF16-served at the moment)
246
- - **Auto-launch of user's external ComfyUI** (`LTX23_AIO_COMFYUI_URL`)
247
- - **Multi-prompt queueing**
248
- - **Output history persistence** across sessions
249
- - **Visual regression tests** for the Gradio UI
250
- - **Property-based / fuzz testing** of workflow parameters
251
- - **Persistent Storage add-on integration** (see future_improvements.md item 6)
252
- - **Telemetry-driven duration estimator** (see future_improvements.md item, requires persistent storage)
253
-
254
- If a task feels like it needs one of these, stop and ask the user.
255
-
256
- ---
257
-
258
- ## When in doubt
259
-
260
- 1. Read the spec and plan. 15 min of reading vs a day of wrong implementation.
261
- 2. Read `docs/future_improvements.md` to see if the change you're considering is already on a known list.
262
- 3. Check `git log --oneline` for similar changes — most non-obvious decisions have a fix-commit explaining the reasoning.
263
- 4. Ask the user before changing architectural shape.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
LICENSE CHANGED
@@ -1,21 +1,674 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Mayank Gupta
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU General Public License is a free, copyleft license for
11
+ software and other kinds of works.
12
+
13
+ The licenses for most software and other practical works are designed
14
+ to take away your freedom to share and change the works. By contrast,
15
+ the GNU General Public License is intended to guarantee your freedom to
16
+ share and change all versions of a program--to make sure it remains free
17
+ software for all its users. We, the Free Software Foundation, use the
18
+ GNU General Public License for most of our software; it applies also to
19
+ any other work released this way by its authors. You can apply it to
20
+ your programs, too.
21
+
22
+ When we speak of free software, we are referring to freedom, not
23
+ price. Our General Public Licenses are designed to make sure that you
24
+ have the freedom to distribute copies of free software (and charge for
25
+ them if you wish), that you receive source code or can get it if you
26
+ want it, that you can change the software or use pieces of it in new
27
+ free programs, and that you know you can do these things.
28
+
29
+ To protect your rights, we need to prevent others from denying you
30
+ these rights or asking you to surrender the rights. Therefore, you have
31
+ certain responsibilities if you distribute copies of the software, or if
32
+ you modify it: responsibilities to respect the freedom of others.
33
+
34
+ For example, if you distribute copies of such a program, whether
35
+ gratis or for a fee, you must pass on to the recipients the same
36
+ freedoms that you received. You must make sure that they, too, receive
37
+ or can get the source code. And you must show them these terms so they
38
+ know their rights.
39
+
40
+ Developers that use the GNU GPL protect your rights with two steps:
41
+ (1) assert copyright on the software, and (2) offer you this License
42
+ giving you legal permission to copy, distribute and/or modify it.
43
+
44
+ For the developers' and authors' protection, the GPL clearly explains
45
+ that there is no warranty for this free software. For both users' and
46
+ authors' sake, the GPL requires that modified versions be marked as
47
+ changed, so that their problems will not be attributed erroneously to
48
+ authors of previous versions.
49
+
50
+ Some devices are designed to deny users access to install or run
51
+ modified versions of the software inside them, although the manufacturer
52
+ can do so. This is fundamentally incompatible with the aim of
53
+ protecting users' freedom to change the software. The systematic
54
+ pattern of such abuse occurs in the area of products for individuals to
55
+ use, which is precisely where it is most unacceptable. Therefore, we
56
+ have designed this version of the GPL to prohibit the practice for those
57
+ products. If such problems arise substantially in other domains, we
58
+ stand ready to extend this provision to those domains in future versions
59
+ of the GPL, as needed to protect the freedom of users.
60
+
61
+ Finally, every program is threatened constantly by software patents.
62
+ States should not allow patents to restrict development and use of
63
+ software on general-purpose computers, but in those that do, we wish to
64
+ avoid the special danger that patents applied to a free program could
65
+ make it effectively proprietary. To prevent this, the GPL assures that
66
+ patents cannot be used to render the program non-free.
67
+
68
+ The precise terms and conditions for copying, distribution and
69
+ modification follow.
70
+
71
+ TERMS AND CONDITIONS
72
+
73
+ 0. Definitions.
74
+
75
+ "This License" refers to version 3 of the GNU General Public License.
76
+
77
+ "Copyright" also means copyright-like laws that apply to other kinds of
78
+ works, such as semiconductor masks.
79
+
80
+ "The Program" refers to any copyrightable work licensed under this
81
+ License. Each licensee is addressed as "you". "Licensees" and
82
+ "recipients" may be individuals or organizations.
83
+
84
+ To "modify" a work means to copy from or adapt all or part of the work
85
+ in a fashion requiring copyright permission, other than the making of an
86
+ exact copy. The resulting work is called a "modified version" of the
87
+ earlier work or a work "based on" the earlier work.
88
+
89
+ A "covered work" means either the unmodified Program or a work based
90
+ on the Program.
91
+
92
+ To "propagate" a work means to do anything with it that, without
93
+ permission, would make you directly or secondarily liable for
94
+ infringement under applicable copyright law, except executing it on a
95
+ computer or modifying a private copy. Propagation includes copying,
96
+ distribution (with or without modification), making available to the
97
+ public, and in some countries other activities as well.
98
+
99
+ To "convey" a work means any kind of propagation that enables other
100
+ parties to make or receive copies. Mere interaction with a user through
101
+ a computer network, with no transfer of a copy, is not conveying.
102
+
103
+ An interactive user interface displays "Appropriate Legal Notices"
104
+ to the extent that it includes a convenient and prominently visible
105
+ feature that (1) displays an appropriate copyright notice, and (2)
106
+ tells the user that there is no warranty for the work (except to the
107
+ extent that warranties are provided), that licensees may convey the
108
+ work under this License, and how to view a copy of this License. If
109
+ the interface presents a list of user commands or options, such as a
110
+ menu, a prominent item in the list meets this criterion.
111
+
112
+ 1. Source Code.
113
+
114
+ The "source code" for a work means the preferred form of the work
115
+ for making modifications to it. "Object code" means any non-source
116
+ form of a work.
117
+
118
+ A "Standard Interface" means an interface that either is an official
119
+ standard defined by a recognized standards body, or, in the case of
120
+ interfaces specified for a particular programming language, one that
121
+ is widely used among developers working in that language.
122
+
123
+ The "System Libraries" of an executable work include anything, other
124
+ than the work as a whole, that (a) is included in the normal form of
125
+ packaging a Major Component, but which is not part of that Major
126
+ Component, and (b) serves only to enable use of the work with that
127
+ Major Component, or to implement a Standard Interface for which an
128
+ implementation is available to the public in source code form. A
129
+ "Major Component", in this context, means a major essential component
130
+ (kernel, window system, and so on) of the specific operating system
131
+ (if any) on which the executable work runs, or a compiler used to
132
+ produce the work, or an object code interpreter used to run it.
133
+
134
+ The "Corresponding Source" for a work in object code form means all
135
+ the source code needed to generate, install, and (for an executable
136
+ work) run the object code and to modify the work, including scripts to
137
+ control those activities. However, it does not include the work's
138
+ System Libraries, or general-purpose tools or generally available free
139
+ programs which are used unmodified in performing those activities but
140
+ which are not part of the work. For example, Corresponding Source
141
+ includes interface definition files associated with source files for
142
+ the work, and the source code for shared libraries and dynamically
143
+ linked subprograms that the work is specifically designed to require,
144
+ such as by intimate data communication or control flow between those
145
+ subprograms and other parts of the work.
146
+
147
+ The Corresponding Source need not include anything that users
148
+ can regenerate automatically from other parts of the Corresponding
149
+ Source.
150
+
151
+ The Corresponding Source for a work in source code form is that
152
+ same work.
153
+
154
+ 2. Basic Permissions.
155
+
156
+ All rights granted under this License are granted for the term of
157
+ copyright on the Program, and are irrevocable provided the stated
158
+ conditions are met. This License explicitly affirms your unlimited
159
+ permission to run the unmodified Program. The output from running a
160
+ covered work is covered by this License only if the output, given its
161
+ content, constitutes a covered work. This License acknowledges your
162
+ rights of fair use or other equivalent, as provided by copyright law.
163
+
164
+ You may make, run and propagate covered works that you do not
165
+ convey, without conditions so long as your license otherwise remains
166
+ in force. You may convey covered works to others for the sole purpose
167
+ of having them make modifications exclusively for you, or provide you
168
+ with facilities for running those works, provided that you comply with
169
+ the terms of this License in conveying all material for which you do
170
+ not control copyright. Those thus making or running the covered works
171
+ for you must do so exclusively on your behalf, under your direction
172
+ and control, on terms that prohibit them from making any copies of
173
+ your copyrighted material outside their relationship with you.
174
+
175
+ Conveying under any other circumstances is permitted solely under
176
+ the conditions stated below. Sublicensing is not allowed; section 10
177
+ makes it unnecessary.
178
+
179
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180
+
181
+ No covered work shall be deemed part of an effective technological
182
+ measure under any applicable law fulfilling obligations under article
183
+ 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184
+ similar laws prohibiting or restricting circumvention of such
185
+ measures.
186
+
187
+ When you convey a covered work, you waive any legal power to forbid
188
+ circumvention of technological measures to the extent such circumvention
189
+ is effected by exercising rights under this License with respect to
190
+ the covered work, and you disclaim any intention to limit operation or
191
+ modification of the work as a means of enforcing, against the work's
192
+ users, your or third parties' legal rights to forbid circumvention of
193
+ technological measures.
194
+
195
+ 4. Conveying Verbatim Copies.
196
+
197
+ You may convey verbatim copies of the Program's source code as you
198
+ receive it, in any medium, provided that you conspicuously and
199
+ appropriately publish on each copy an appropriate copyright notice;
200
+ keep intact all notices stating that this License and any
201
+ non-permissive terms added in accord with section 7 apply to the code;
202
+ keep intact all notices of the absence of any warranty; and give all
203
+ recipients a copy of this License along with the Program.
204
+
205
+ You may charge any price or no price for each copy that you convey,
206
+ and you may offer support or warranty protection for a fee.
207
+
208
+ 5. Conveying Modified Source Versions.
209
+
210
+ You may convey a work based on the Program, or the modifications to
211
+ produce it from the Program, in the form of source code under the
212
+ terms of section 4, provided that you also meet all of these conditions:
213
+
214
+ a) The work must carry prominent notices stating that you modified
215
+ it, and giving a relevant date.
216
+
217
+ b) The work must carry prominent notices stating that it is
218
+ released under this License and any conditions added under section
219
+ 7. This requirement modifies the requirement in section 4 to
220
+ "keep intact all notices".
221
+
222
+ c) You must license the entire work, as a whole, under this
223
+ License to anyone who comes into possession of a copy. This
224
+ License will therefore apply, along with any applicable section 7
225
+ additional terms, to the whole of the work, and all its parts,
226
+ regardless of how they are packaged. This License gives no
227
+ permission to license the work in any other way, but it does not
228
+ invalidate such permission if you have separately received it.
229
+
230
+ d) If the work has interactive user interfaces, each must display
231
+ Appropriate Legal Notices; however, if the Program has interactive
232
+ interfaces that do not display Appropriate Legal Notices, your
233
+ work need not make them do so.
234
+
235
+ A compilation of a covered work with other separate and independent
236
+ works, which are not by their nature extensions of the covered work,
237
+ and which are not combined with it such as to form a larger program,
238
+ in or on a volume of a storage or distribution medium, is called an
239
+ "aggregate" if the compilation and its resulting copyright are not
240
+ used to limit the access or legal rights of the compilation's users
241
+ beyond what the individual works permit. Inclusion of a covered work
242
+ in an aggregate does not cause this License to apply to the other
243
+ parts of the aggregate.
244
+
245
+ 6. Conveying Non-Source Forms.
246
+
247
+ You may convey a covered work in object code form under the terms
248
+ of sections 4 and 5, provided that you also convey the
249
+ machine-readable Corresponding Source under the terms of this License,
250
+ in one of these ways:
251
+
252
+ a) Convey the object code in, or embodied in, a physical product
253
+ (including a physical distribution medium), accompanied by the
254
+ Corresponding Source fixed on a durable physical medium
255
+ customarily used for software interchange.
256
+
257
+ b) Convey the object code in, or embodied in, a physical product
258
+ (including a physical distribution medium), accompanied by a
259
+ written offer, valid for at least three years and valid for as
260
+ long as you offer spare parts or customer support for that product
261
+ model, to give anyone who possesses the object code either (1) a
262
+ copy of the Corresponding Source for all the software in the
263
+ product that is covered by this License, on a durable physical
264
+ medium customarily used for software interchange, for a price no
265
+ more than your reasonable cost of physically performing this
266
+ conveying of source, or (2) access to copy the
267
+ Corresponding Source from a network server at no charge.
268
+
269
+ c) Convey individual copies of the object code with a copy of the
270
+ written offer to provide the Corresponding Source. This
271
+ alternative is allowed only occasionally and noncommercially, and
272
+ only if you received the object code with such an offer, in accord
273
+ with subsection 6b.
274
+
275
+ d) Convey the object code by offering access from a designated
276
+ place (gratis or for a charge), and offer equivalent access to the
277
+ Corresponding Source in the same way through the same place at no
278
+ further charge. You need not require recipients to copy the
279
+ Corresponding Source along with the object code. If the place to
280
+ copy the object code is a network server, the Corresponding Source
281
+ may be on a different server (operated by you or a third party)
282
+ that supports equivalent copying facilities, provided you maintain
283
+ clear directions next to the object code saying where to find the
284
+ Corresponding Source. Regardless of what server hosts the
285
+ Corresponding Source, you remain obligated to ensure that it is
286
+ available for as long as needed to satisfy these requirements.
287
+
288
+ e) Convey the object code using peer-to-peer transmission, provided
289
+ you inform other peers where the object code and Corresponding
290
+ Source of the work are being offered to the general public at no
291
+ charge under subsection 6d.
292
+
293
+ A separable portion of the object code, whose source code is excluded
294
+ from the Corresponding Source as a System Library, need not be
295
+ included in conveying the object code work.
296
+
297
+ A "User Product" is either (1) a "consumer product", which means any
298
+ tangible personal property which is normally used for personal, family,
299
+ or household purposes, or (2) anything designed or sold for incorporation
300
+ into a dwelling. In determining whether a product is a consumer product,
301
+ doubtful cases shall be resolved in favor of coverage. For a particular
302
+ product received by a particular user, "normally used" refers to a
303
+ typical or common use of that class of product, regardless of the status
304
+ of the particular user or of the way in which the particular user
305
+ actually uses, or expects or is expected to use, the product. A product
306
+ is a consumer product regardless of whether the product has substantial
307
+ commercial, industrial or non-consumer uses, unless such uses represent
308
+ the only significant mode of use of the product.
309
+
310
+ "Installation Information" for a User Product means any methods,
311
+ procedures, authorization keys, or other information required to install
312
+ and execute modified versions of a covered work in that User Product from
313
+ a modified version of its Corresponding Source. The information must
314
+ suffice to ensure that the continued functioning of the modified object
315
+ code is in no case prevented or interfered with solely because
316
+ modification has been made.
317
+
318
+ If you convey an object code work under this section in, or with, or
319
+ specifically for use in, a User Product, and the conveying occurs as
320
+ part of a transaction in which the right of possession and use of the
321
+ User Product is transferred to the recipient in perpetuity or for a
322
+ fixed term (regardless of how the transaction is characterized), the
323
+ Corresponding Source conveyed under this section must be accompanied
324
+ by the Installation Information. But this requirement does not apply
325
+ if neither you nor any third party retains the ability to install
326
+ modified object code on the User Product (for example, the work has
327
+ been installed in ROM).
328
+
329
+ The requirement to provide Installation Information does not include a
330
+ requirement to continue to provide support service, warranty, or updates
331
+ for a work that has been modified or installed by the recipient, or for
332
+ the User Product in which it has been modified or installed. Access to a
333
+ network may be denied when the modification itself materially and
334
+ adversely affects the operation of the network or violates the rules and
335
+ protocols for communication across the network.
336
+
337
+ Corresponding Source conveyed, and Installation Information provided,
338
+ in accord with this section must be in a format that is publicly
339
+ documented (and with an implementation available to the public in
340
+ source code form), and must require no special password or key for
341
+ unpacking, reading or copying.
342
+
343
+ 7. Additional Terms.
344
+
345
+ "Additional permissions" are terms that supplement the terms of this
346
+ License by making exceptions from one or more of its conditions.
347
+ Additional permissions that are applicable to the entire Program shall
348
+ be treated as though they were included in this License, to the extent
349
+ that they are valid under applicable law. If additional permissions
350
+ apply only to part of the Program, that part may be used separately
351
+ under those permissions, but the entire Program remains governed by
352
+ this License without regard to the additional permissions.
353
+
354
+ When you convey a copy of a covered work, you may at your option
355
+ remove any additional permissions from that copy, or from any part of
356
+ it. (Additional permissions may be written to require their own
357
+ removal in certain cases when you modify the work.) You may place
358
+ additional permissions on material, added by you to a covered work,
359
+ for which you have or can give appropriate copyright permission.
360
+
361
+ Notwithstanding any other provision of this License, for material you
362
+ add to a covered work, you may (if authorized by the copyright holders of
363
+ that material) supplement the terms of this License with terms:
364
+
365
+ a) Disclaiming warranty or limiting liability differently from the
366
+ terms of sections 15 and 16 of this License; or
367
+
368
+ b) Requiring preservation of specified reasonable legal notices or
369
+ author attributions in that material or in the Appropriate Legal
370
+ Notices displayed by works containing it; or
371
+
372
+ c) Prohibiting misrepresentation of the origin of that material, or
373
+ requiring that modified versions of such material be marked in
374
+ reasonable ways as different from the original version; or
375
+
376
+ d) Limiting the use for publicity purposes of names of licensors or
377
+ authors of the material; or
378
+
379
+ e) Declining to grant rights under trademark law for use of some
380
+ trade names, trademarks, or service marks; or
381
+
382
+ f) Requiring indemnification of licensors and authors of that
383
+ material by anyone who conveys the material (or modified versions of
384
+ it) with contractual assumptions of liability to the recipient, for
385
+ any liability that these contractual assumptions directly impose on
386
+ those licensors and authors.
387
+
388
+ All other non-permissive additional terms are considered "further
389
+ restrictions" within the meaning of section 10. If the Program as you
390
+ received it, or any part of it, contains a notice stating that it is
391
+ governed by this License along with a term that is a further
392
+ restriction, you may remove that term. If a license document contains
393
+ a further restriction but permits relicensing or conveying under this
394
+ License, you may add to a covered work material governed by the terms
395
+ of that license document, provided that the further restriction does
396
+ not survive such relicensing or conveying.
397
+
398
+ If you add terms to a covered work in accord with this section, you
399
+ must place, in the relevant source files, a statement of the
400
+ additional terms that apply to those files, or a notice indicating
401
+ where to find the applicable terms.
402
+
403
+ Additional terms, permissive or non-permissive, may be stated in the
404
+ form of a separately written license, or stated as exceptions;
405
+ the above requirements apply either way.
406
+
407
+ 8. Termination.
408
+
409
+ You may not propagate or modify a covered work except as expressly
410
+ provided under this License. Any attempt otherwise to propagate or
411
+ modify it is void, and will automatically terminate your rights under
412
+ this License (including any patent licenses granted under the third
413
+ paragraph of section 11).
414
+
415
+ However, if you cease all violation of this License, then your
416
+ license from a particular copyright holder is reinstated (a)
417
+ provisionally, unless and until the copyright holder explicitly and
418
+ finally terminates your license, and (b) permanently, if the copyright
419
+ holder fails to notify you of the violation by some reasonable means
420
+ prior to 60 days after the cessation.
421
+
422
+ Moreover, your license from a particular copyright holder is
423
+ reinstated permanently if the copyright holder notifies you of the
424
+ violation by some reasonable means, this is the first time you have
425
+ received notice of violation of this License (for any work) from that
426
+ copyright holder, and you cure the violation prior to 30 days after
427
+ your receipt of the notice.
428
+
429
+ Termination of your rights under this section does not terminate the
430
+ licenses of parties who have received copies or rights from you under
431
+ this License. If your rights have been terminated and not permanently
432
+ reinstated, you do not qualify to receive new licenses for the same
433
+ material under section 10.
434
+
435
+ 9. Acceptance Not Required for Having Copies.
436
+
437
+ You are not required to accept this License in order to receive or
438
+ run a copy of the Program. Ancillary propagation of a covered work
439
+ occurring solely as a consequence of using peer-to-peer transmission
440
+ to receive a copy likewise does not require acceptance. However,
441
+ nothing other than this License grants you permission to propagate or
442
+ modify any covered work. These actions infringe copyright if you do
443
+ not accept this License. Therefore, by modifying or propagating a
444
+ covered work, you indicate your acceptance of this License to do so.
445
+
446
+ 10. Automatic Licensing of Downstream Recipients.
447
+
448
+ Each time you convey a covered work, the recipient automatically
449
+ receives a license from the original licensors, to run, modify and
450
+ propagate that work, subject to this License. You are not responsible
451
+ for enforcing compliance by third parties with this License.
452
+
453
+ An "entity transaction" is a transaction transferring control of an
454
+ organization, or substantially all assets of one, or subdividing an
455
+ organization, or merging organizations. If propagation of a covered
456
+ work results from an entity transaction, each party to that
457
+ transaction who receives a copy of the work also receives whatever
458
+ licenses to the work the party's predecessor in interest had or could
459
+ give under the previous paragraph, plus a right to possession of the
460
+ Corresponding Source of the work from the predecessor in interest, if
461
+ the predecessor has it or can get it with reasonable efforts.
462
+
463
+ You may not impose any further restrictions on the exercise of the
464
+ rights granted or affirmed under this License. For example, you may
465
+ not impose a license fee, royalty, or other charge for exercise of
466
+ rights granted under this License, and you may not initiate litigation
467
+ (including a cross-claim or counterclaim in a lawsuit) alleging that
468
+ any patent claim is infringed by making, using, selling, offering for
469
+ sale, or importing the Program or any portion of it.
470
+
471
+ 11. Patents.
472
+
473
+ A "contributor" is a copyright holder who authorizes use under this
474
+ License of the Program or a work on which the Program is based. The
475
+ work thus licensed is called the contributor's "contributor version".
476
+
477
+ A contributor's "essential patent claims" are all patent claims
478
+ owned or controlled by the contributor, whether already acquired or
479
+ hereafter acquired, that would be infringed by some manner, permitted
480
+ by this License, of making, using, or selling its contributor version,
481
+ but do not include claims that would be infringed only as a
482
+ consequence of further modification of the contributor version. For
483
+ purposes of this definition, "control" includes the right to grant
484
+ patent sublicenses in a manner consistent with the requirements of
485
+ this License.
486
+
487
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
488
+ patent license under the contributor's essential patent claims, to
489
+ make, use, sell, offer for sale, import and otherwise run, modify and
490
+ propagate the contents of its contributor version.
491
+
492
+ In the following three paragraphs, a "patent license" is any express
493
+ agreement or commitment, however denominated, not to enforce a patent
494
+ (such as an express permission to practice a patent or covenant not to
495
+ sue for patent infringement). To "grant" such a patent license to a
496
+ party means to make such an agreement or commitment not to enforce a
497
+ patent against the party.
498
+
499
+ If you convey a covered work, knowingly relying on a patent license,
500
+ and the Corresponding Source of the work is not available for anyone
501
+ to copy, free of charge and under the terms of this License, through a
502
+ publicly available network server or other readily accessible means,
503
+ then you must either (1) cause the Corresponding Source to be so
504
+ available, or (2) arrange to deprive yourself of the benefit of the
505
+ patent license for this particular work, or (3) arrange, in a manner
506
+ consistent with the requirements of this License, to extend the patent
507
+ license to downstream recipients. "Knowingly relying" means you have
508
+ actual knowledge that, but for the patent license, your conveying the
509
+ covered work in a country, or your recipient's use of the covered work
510
+ in a country, would infringe one or more identifiable patents in that
511
+ country that you have reason to believe are valid.
512
+
513
+ If, pursuant to or in connection with a single transaction or
514
+ arrangement, you convey, or propagate by procuring conveyance of, a
515
+ covered work, and grant a patent license to some of the parties
516
+ receiving the covered work authorizing them to use, propagate, modify
517
+ or convey a specific copy of the covered work, then the patent license
518
+ you grant is automatically extended to all recipients of the covered
519
+ work and works based on it.
520
+
521
+ A patent license is "discriminatory" if it does not include within
522
+ the scope of its coverage, prohibits the exercise of, or is
523
+ conditioned on the non-exercise of one or more of the rights that are
524
+ specifically granted under this License. You may not convey a covered
525
+ work if you are a party to an arrangement with a third party that is
526
+ in the business of distributing software, under which you make payment
527
+ to the third party based on the extent of your activity of conveying
528
+ the work, and under which the third party grants, to any of the
529
+ parties who would receive the covered work from you, a discriminatory
530
+ patent license (a) in connection with copies of the covered work
531
+ conveyed by you (or copies made from those copies), or (b) primarily
532
+ for and in connection with specific products or compilations that
533
+ contain the covered work, unless you entered into that arrangement,
534
+ or that patent license was granted, prior to 28 March 2007.
535
+
536
+ Nothing in this License shall be construed as excluding or limiting
537
+ any implied license or other defenses to infringement that may
538
+ otherwise be available to you under applicable patent law.
539
+
540
+ 12. No Surrender of Others' Freedom.
541
+
542
+ If conditions are imposed on you (whether by court order, agreement or
543
+ otherwise) that contradict the conditions of this License, they do not
544
+ excuse you from the conditions of this License. If you cannot convey a
545
+ covered work so as to satisfy simultaneously your obligations under this
546
+ License and any other pertinent obligations, then as a consequence you may
547
+ not convey it at all. For example, if you agree to terms that obligate you
548
+ to collect a royalty for further conveying from those to whom you convey
549
+ the Program, the only way you could satisfy both those terms and this
550
+ License would be to refrain entirely from conveying the Program.
551
+
552
+ 13. Use with the GNU Affero General Public License.
553
+
554
+ Notwithstanding any other provision of this License, you have
555
+ permission to link or combine any covered work with a work licensed
556
+ under version 3 of the GNU Affero General Public License into a single
557
+ combined work, and to convey the resulting work. The terms of this
558
+ License will continue to apply to the part which is the covered work,
559
+ but the special requirements of the GNU Affero General Public License,
560
+ section 13, concerning interaction through a network will apply to the
561
+ combination as such.
562
+
563
+ 14. Revised Versions of this License.
564
+
565
+ The Free Software Foundation may publish revised and/or new versions of
566
+ the GNU General Public License from time to time. Such new versions will
567
+ be similar in spirit to the present version, but may differ in detail to
568
+ address new problems or concerns.
569
+
570
+ Each version is given a distinguishing version number. If the
571
+ Program specifies that a certain numbered version of the GNU General
572
+ Public License "or any later version" applies to it, you have the
573
+ option of following the terms and conditions either of that numbered
574
+ version or of any later version published by the Free Software
575
+ Foundation. If the Program does not specify a version number of the
576
+ GNU General Public License, you may choose any version ever published
577
+ by the Free Software Foundation.
578
+
579
+ If the Program specifies that a proxy can decide which future
580
+ versions of the GNU General Public License can be used, that proxy's
581
+ public statement of acceptance of a version permanently authorizes you
582
+ to choose that version for the Program.
583
+
584
+ Later license versions may give you additional or different
585
+ permissions. However, no additional obligations are imposed on any
586
+ author or copyright holder as a result of your choosing to follow a
587
+ later version.
588
+
589
+ 15. Disclaimer of Warranty.
590
+
591
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592
+ APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593
+ HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594
+ OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595
+ THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596
+ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597
+ IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598
+ ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599
+
600
+ 16. Limitation of Liability.
601
+
602
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603
+ WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604
+ THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605
+ GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606
+ USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607
+ DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608
+ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609
+ EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610
+ SUCH DAMAGES.
611
+
612
+ 17. Interpretation of Sections 15 and 16.
613
+
614
+ If the disclaimer of warranty and limitation of liability provided
615
+ above cannot be given local legal effect according to their terms,
616
+ reviewing courts shall apply local law that most closely approximates
617
+ an absolute waiver of all civil liability in connection with the
618
+ Program, unless a warranty or assumption of liability accompanies a
619
+ copy of the Program in return for a fee.
620
+
621
+ END OF TERMS AND CONDITIONS
622
+
623
+ How to Apply These Terms to Your New Programs
624
+
625
+ If you develop a new program, and you want it to be of the greatest
626
+ possible use to the public, the best way to achieve this is to make it
627
+ free software which everyone can redistribute and change under these terms.
628
+
629
+ To do so, attach the following notices to the program. It is safest
630
+ to attach them to the start of each source file to most effectively
631
+ state the exclusion of warranty; and each file should have at least
632
+ the "copyright" line and a pointer to where the full notice is found.
633
+
634
+ <one line to give the program's name and a brief idea of what it does.>
635
+ Copyright (C) <year> <name of author>
636
+
637
+ This program is free software: you can redistribute it and/or modify
638
+ it under the terms of the GNU General Public License as published by
639
+ the Free Software Foundation, either version 3 of the License, or
640
+ (at your option) any later version.
641
+
642
+ This program is distributed in the hope that it will be useful,
643
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
644
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645
+ GNU General Public License for more details.
646
+
647
+ You should have received a copy of the GNU General Public License
648
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
649
+
650
+ Also add information on how to contact you by electronic and paper mail.
651
+
652
+ If the program does terminal interaction, make it output a short
653
+ notice like this when it starts in an interactive mode:
654
+
655
+ <program> Copyright (C) <year> <name of author>
656
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657
+ This is free software, and you are welcome to redistribute it
658
+ under certain conditions; type `show c' for details.
659
+
660
+ The hypothetical commands `show w' and `show c' should show the appropriate
661
+ parts of the General Public License. Of course, your program's commands
662
+ might be different; for a GUI interface, you would use an "about box".
663
+
664
+ You should also get your employer (if you work as a programmer) or school,
665
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
666
+ For more information on this, and how to apply and follow the GNU GPL, see
667
+ <https://www.gnu.org/licenses/>.
668
+
669
+ The GNU General Public License does not permit incorporating your program
670
+ into proprietary programs. If your program is a subroutine library, you
671
+ may consider it more useful to permit linking proprietary applications with
672
+ the library. If this is what you want to do, use the GNU Lesser General
673
+ Public License instead of this License. But first, please read
674
+ <https://www.gnu.org/licenses/why-not-lgpl.html>.
README.md CHANGED
@@ -1,81 +1,13 @@
1
  ---
2
- title: LTX 2.3 Studio
3
  emoji: 🎬
4
  colorFrom: purple
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: "5.50.0"
8
  app_file: app.py
9
- python_version: "3.11"
10
- suggested_hardware: zero-a10g
11
- hf_oauth: false
12
  models:
13
- - openai/clip-vit-large-patch14
14
- - Qwen/Qwen3-0.6B
15
- - facebook/opt-125m
16
- - openai/whisper-large-v3-turbo
17
- - k2-fsa/OmniVoice
18
- - Lightricks/LTX-2.3
19
- - Kijai/LTX2.3_comfy
20
- - unsloth/LTX-2.3-GGUF
21
- - facebook/opt-350m
22
- - eustlb/higgs-audio-v2-tokenizer
23
- - google/gemma-3-12b-it-qat-q4_0-unquantized
24
- - ai-forever/Real-ESRGAN
25
- - Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control
26
- - Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control
27
- - k2-fsa/TTS_eval_models
28
- - Lightricks/LTX-2-19b-IC-LoRA-Pose-Control
29
- - Lightricks/LTX-2-19b-IC-LoRA-Detailer
30
- - Comfy-Org/ltx-2
31
- preload_from_hub:
32
- - Comfy-Org/ltx-2 split_files/text_encoders/gemma_3_12B_it.safetensors
33
- - Kijai/LTX2.3_comfy diffusion_models/ltx-2.3-22b-dev_transformer_only_bf16.safetensors,loras/ltx-2.3-22b-distilled-lora-dynamic_fro09_avg_rank_105_bf16.safetensors,text_encoders/ltx-2.3_text_projection_bf16.safetensors,vae/LTX23_audio_vae_bf16.safetensors,vae/LTX23_video_vae_bf16.safetensors,vae/taeltx2_3.safetensors
34
- - Lightricks/LTX-2-19b-IC-LoRA-Detailer ltx-2-19b-ic-lora-detailer.safetensors
35
- - Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down ltx-2-19b-lora-camera-control-jib-down.safetensors
36
- - Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up ltx-2-19b-lora-camera-control-jib-up.safetensors
37
- - Lightricks/LTX-2-19b-LoRA-Camera-Control-Static ltx-2-19b-lora-camera-control-static.safetensors
38
- - Lightricks/LTX-2.3 ltx-2.3-22b-distilled-lora-384.safetensors,ltx-2.3-spatial-upscaler-x2-1.0.safetensors
39
- - Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors
40
- - google/gemma-3-12b-it-qat-q4_0-unquantized gemma-3-12b-it/model-00001-of-00005.safetensors,gemma-3-12b-it/model-00002-of-00005.safetensors,gemma-3-12b-it/model-00003-of-00005.safetensors,gemma-3-12b-it/model-00004-of-00005.safetensors,gemma-3-12b-it/model-00005-of-00005.safetensors,gemma-3-12b-it/model.safetensors.index.json,gemma-3-12b-it/preprocessor_config.json,gemma-3-12b-it/tokenizer.model
41
  ---
42
-
43
- # LTX 2.3 All-in-One Video Generator
44
-
45
- A Gradio app for [LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3) wrapping all six modes of the official ComfyUI All-In-One workflow under a single, focused UI. Runs locally on Apple Silicon (MPS) or NVIDIA (CUDA), and deploys to Hugging Face Spaces (ZeroGPU).
46
-
47
- ## Modes
48
-
49
- 1. **Text → Video** (+ optional Audio)
50
- 2. **Audio → Video** (Text + Audio → Video + Audio)
51
- 3. **Image → Video** (+ optional Audio)
52
- 4. **Lipsync** (Image + Audio → Video + Audio)
53
- 5. **First / Last Frame → Video** (keyframe interpolation)
54
- 6. **Style Transfer** (Video → Video, motion control)
55
-
56
- ## Local quickstart
57
-
58
- Requires Python 3.11, ~80 GB free disk for model weights, and ~24 GB+ GPU memory (CUDA) or 32 GB+ unified memory (Apple Silicon).
59
-
60
- ```bash
61
- git clone --recurse-submodules https://github.com/<your-handle>/ltx2.3-AIO-generator
62
- cd ltx2.3-AIO-generator
63
- bash setup.sh
64
- source .venv/bin/activate
65
- python app.py
66
- ```
67
-
68
- The first run downloads ~70 GB of models into your existing `~/.cache/huggingface/hub` (no duplicate copies in this repo) and symlinks them into `comfyui/models/`.
69
-
70
- ## HF Spaces deployment
71
-
72
- This repo is a Gradio Space. The required Pro tier provides ~50 GB persistent `/data` storage and longer per-call ZeroGPU budgets needed for Balanced and Quality presets.
73
-
74
- ```bash
75
- git remote add space https://huggingface.co/spaces/<your-handle>/ltx2.3-aio
76
- git push space main
77
- ```
78
-
79
- ## License
80
-
81
- MIT for the AIO app code. ComfyUI and LTX-2.3 retain their respective licenses.
 
1
  ---
2
+ title: LTX-2.5
3
  emoji: 🎬
4
  colorFrom: purple
5
+ colorTo: red
6
  sdk: gradio
7
+ sdk_version: 5.50.0
8
  app_file: app.py
9
+ python_version: 3.12
10
+ short_description: Multi-task image generator with dynamic, chainable workflows
 
11
  models:
12
+ - Lightricks/LTX-2.5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
SKILLS.md DELETED
@@ -1,299 +0,0 @@
1
- # Skills — how to make changes in this project
2
-
3
- Process rules and habits for AI assistants working on this repo. Companion to `CLAUDE.md` (which is *what & why*); this file is *how*.
4
-
5
- > Default rule when in doubt: **stop and ask the user**. The user prefers a question over wrong work.
6
-
7
- ---
8
-
9
- ## Investigation before fix
10
-
11
- ### Reproduce the bug visually before patching CSS / UI
12
-
13
- When the user reports a layout, color, click, or visibility issue, **the first action is Playwright + screenshot, not code**. The user has called this out explicitly:
14
-
15
- > "Make sure to check playwright with screenshot to verify issues before making fix."
16
-
17
- Skipping the visual repro twice in a row produced patches that addressed a different symptom than what the user was seeing. Reproduce, then fix, then re-screenshot to verify the fix.
18
-
19
- **Tools:** local dev server (port 7860, see "Running locally" below) + `mcp__playwright__browser_*` tools. Resize to the affected viewport (typically 380 px / 900 px / 1280 px). `browser_evaluate` is the most reliable way to inspect DOM state — getBoundingClientRect, getComputedStyle, elementFromPoint.
20
-
21
- ### Pull HF Space logs first when something runs there
22
-
23
- For Spaces failures, the run logs are the source of truth. Pull and search:
24
-
25
- ```bash
26
- HF_TOKEN=$(cat ~/.cache/huggingface/token)
27
- curl -s -H "Authorization: Bearer ${HF_TOKEN}" \
28
- "https://huggingface.co/api/spaces/techfreakworm/LTX2.3-Studio/logs/run" \
29
- -o /tmp/hf_run.log
30
-
31
- # Find last submit and tail from there
32
- python3 << 'PY'
33
- import json
34
- events = []
35
- for line in open('/tmp/hf_run.log'):
36
- line = line.strip()
37
- if line.startswith('data: '):
38
- try: events.append(json.loads(line[6:]))
39
- except Exception: pass
40
- last = max(i for i, e in enumerate(events) if 'submitting workflow' in e.get('data', ''))
41
- for ev in events[last:]:
42
- print(ev.get('timestamp', '')[:19], ev.get('data', '').rstrip()[:240])
43
- PY
44
- ```
45
-
46
- `/logs/build` is the other endpoint. Build logs show preload, image-build, pip; run logs show container output.
47
-
48
- ### Stage check before action
49
-
50
- ```bash
51
- HF_TOKEN=$(cat ~/.cache/huggingface/token)
52
- curl -s -H "Authorization: Bearer ${HF_TOKEN}" \
53
- "https://huggingface.co/api/spaces/techfreakworm/LTX2.3-Studio" | jq -r '.runtime'
54
- ```
55
-
56
- Stages: `BUILDING` (image), `APP_STARTING` (boot), `RUNNING`, `RUNTIME_ERROR`, `RUNNING_BUILDING` (live serving + new build queued). If `RUNTIME_ERROR` is non-null, that's your headline.
57
-
58
- ### Sequential thinking for repeated failures
59
-
60
- The user has called this out:
61
-
62
- > "On 2nd failed fix, stop patching; use sequential-thinking MCP + brainstorming skill"
63
-
64
- If your first fix didn't land, **stop patching**. Use `mcp__sequential-thinking__sequentialthinking` to think through the failure mode end-to-end, plus web search for canonical solutions. Do not loop on speculative one-line patches.
65
-
66
- ### Web-search for HF / Gradio errors with the literal message
67
-
68
- HF docs change. The `Spaces Configuration Reference` and `Spaces ZeroGPU` pages often have undocumented behavior captured in forum threads. When you hit a Gradio/Spaces error, web-search the literal exception message. Examples that paid off:
69
-
70
- - `gradio.exceptions.InvalidPathError` → fix was `allowed_paths=` (Gradio 5 file-access policy)
71
- - `'Workload evicted, storage limit exceeded (150G)'` → 150 GB ephemeral cap
72
- - `'No @spaces.GPU function detected during startup'` → must be module-level decorator
73
- - `'GPU task aborted'` → `@spaces.GPU(duration=...)` cap
74
-
75
- ---
76
-
77
- ## Verification
78
-
79
- ### Run the full repro in Playwright before declaring done
80
-
81
- After a UI fix, re-run the same Playwright sequence that exposed the bug. Take a screenshot. Read the DOM state. Don't trust "it should work now" — show that it does.
82
-
83
- ### Local before push
84
-
85
- When iterating on app behavior, the local dev server gives instant feedback. The user explicitly asks for this — they do most testing on the WiFi-accessible local URL. **Never push during HF testing windows.** When the user is testing on the live Space, hold local commits until they say push.
86
-
87
- ```bash
88
- # In repo root
89
- source .venv/bin/activate
90
- python app.py # or background it; see "Running locally"
91
- ```
92
-
93
- The user has stated:
94
-
95
- > "DO NOT PUSH since testing is happening on HF"
96
-
97
- When in doubt, hold and ask.
98
-
99
- ### Smoke import + build_app after backend/app changes
100
-
101
- ```bash
102
- python -c "import app; b = app.build_app(); print(type(b).__name__)"
103
- ```
104
-
105
- Should print `Blocks`. Catches most syntax / import-cycle issues without spinning up the full server.
106
-
107
- ### Sanity-test isolated functions when changing logic
108
-
109
- For workflow walkers, model registry, duration estimators — write a tiny `python3 -c '...'` or HEREDOC to feed synthetic inputs and verify outputs. Faster than running the full app, catches regressions that the full app would mask.
110
-
111
- ---
112
-
113
- ## Running locally
114
-
115
- ### Standard launch (port 7860)
116
-
117
- ```bash
118
- cd /Users/techfreakworm/Projects/llm/ltx2.3-AIO-generator
119
- source .venv/bin/activate
120
- nohup python app.py > /tmp/ltx_studio_run.log 2>&1 &
121
- echo $! > /tmp/ltx_studio.pid
122
- ```
123
-
124
- Wait ~18 seconds for ComfyUI to import + Gradio to bind, then check:
125
-
126
- ```bash
127
- lsof -nP -iTCP:7860 -sTCP:LISTEN
128
- ```
129
-
130
- ### LAN-accessible URL
131
-
132
- Bound to `0.0.0.0:7860` by default. Get the LAN IP:
133
-
134
- ```bash
135
- ipconfig getifaddr en0 || ipconfig getifaddr en1
136
- ```
137
-
138
- Open `http://<LAN_IP>:7860` on phone/tablet on the same WiFi. macOS firewall: allow inbound for `python` if connection refused.
139
-
140
- ### Stop
141
-
142
- ```bash
143
- PID=$(cat /tmp/ltx_studio.pid)
144
- kill -9 $PID
145
- lsof -nP -iTCP:7860 -sTCP:LISTEN | awk 'NR>1 {print $2}' | xargs -r kill -9
146
- ```
147
-
148
- ---
149
-
150
- ## Pushing changes
151
-
152
- ### Two remotes
153
-
154
- ```bash
155
- git push origin master # GitHub
156
- HF_TOKEN=$(cat ~/.cache/huggingface/token) # HF auth (cli removed `hf auth token`)
157
- git push "https://techfreakworm:${HF_TOKEN}@huggingface.co/spaces/techfreakworm/LTX2.3-Studio" master:main
158
- ```
159
-
160
- GitHub: `master`. HF Space: `main`. The Space accepts force-push only with explicit user consent.
161
-
162
- ### When to push
163
-
164
- - Default: hold all commits locally, ask the user before pushing.
165
- - The user usually says "push" or "push them" when ready.
166
- - During the user's HF testing windows, NEVER push.
167
- - After a successful local Playwright verification of a fix, summarize the queued commits and ask.
168
-
169
- ---
170
-
171
- ## Spaces deploy lifecycle
172
-
173
- Each push triggers a Docker image rebuild. Most layers are cached unless requirements.txt or README YAML changes. The first push that adds/changes `preload_from_hub:` triggers a long preload step (download all listed files into `~/.cache/huggingface/hub`).
174
-
175
- Container start sequence (after image push):
176
- 1. HF brings up the container as user 1000
177
- 2. Our `_bootstrap()` runs:
178
- - clones ComfyUI + custom nodes (cold-start only — frozen ZeroGPU containers retain them)
179
- - pip installs each custom node's requirements
180
- - `_mirror_preload_hf_cache()` builds writable cache mirror
181
- - copies seed inputs
182
- - sets HF_HOME / HF_HUB_CACHE env vars
183
- 3. `gr.Blocks(...).launch()` binds 7860
184
- 4. Stage transitions to `RUNNING`
185
-
186
- ZeroGPU container freeze on idle: keeps `~/comfyui`, `~/hf-cache-rw`, etc. Wake on next request restores in seconds. Push or rebuild loses everything.
187
-
188
- ---
189
-
190
- ## When the user says "deep think"
191
-
192
- The user explicitly invokes deeper investigation when stuck:
193
-
194
- > "Use deep thinking using sequential thinking and web search and code exploration."
195
-
196
- Use `mcp__sequential-thinking__sequentialthinking` to lay out the problem end-to-end. Web-search literal error messages. Read code beyond the immediate failure site. Avoid speculative one-line patches when in this mode.
197
-
198
- ---
199
-
200
- ## What never to do
201
-
202
- - **Push without explicit permission** during HF test windows.
203
- - **Add Co-Authored-By** or any agent attribution to commit messages.
204
- - **Hand-edit `workflows/*.json`** — the user re-exports from ComfyUI editor.
205
- - **`chmod` the HF preload cache** — we don't own it. See cache-mirror approach in CLAUDE.md.
206
- - **Switch `sdk: gradio` → `sdk: docker`** in README. Loses ZeroGPU.
207
- - **Move models into the repo via git LFS without asking.** Pro has 1 TB LFS but bandwidth is finite.
208
- - **Implement out-of-scope v1.1+ features** without asking. See "Out of scope" in CLAUDE.md.
209
- - **Eagerly load models at module import.** `_bootstrap()` only ensures clones + cache mirroring. Model load happens when ComfyUI's executor evaluates a node.
210
-
211
- ---
212
-
213
- ## Memory (cross-session)
214
-
215
- The user's preferences live at `~/.claude/projects/-Users-techfreakworm-Projects/memory/`. Key entries:
216
-
217
- - **Git authorship:** sole author, no co-author footers
218
- - **Verify before fix:** Playwright + screenshot first
219
- - **Don't push during HF testing:** hold local commits
220
- - **Autonomous execution:** prefer scripts over notebooks, report results
221
- - **No conda:** `python3.11 -m venv`, brew for system bins
222
- - **Tests folder:** keep `~/Projects/tests/` separate from `~/Projects/`
223
-
224
- When the user asks to remember something new, save it as a memory file and update `MEMORY.md` index.
225
-
226
- ---
227
-
228
- ## When stuck for too long
229
-
230
- Three escalation steps:
231
-
232
- 1. **`mcp__sequential-thinking__sequentialthinking`** — think the whole flow through, identify the unknown.
233
- 2. **WebSearch + WebFetch** — find canonical fix or known issue.
234
- 3. **Ask the user** — describe what's been tried, what's still unknown, propose options.
235
-
236
- Do not loop on patches when you've patched twice and it's still broken.
237
-
238
- ---
239
-
240
- ## Repo structure (high level)
241
-
242
- ```
243
- .
244
- ├── app.py # Gradio entry, _bootstrap, _on_generate, build_app
245
- ├── backend.py # ComfyUILibraryBackend, _execute_workflow, _GPU
246
- ├── modes.py # MODE_REGISTRY + per-mode parameterize_fn + node-id constants
247
- ├── models.py # MODEL_REGISTRY, walk_workflow_for_models, ensure_models
248
- ├── ui.py # render_status, _render_idle, mode-form layout primitives
249
- ├── workflow.py # load_template, set_input
250
- ├── workflows/ # API-format mode JSONs (do not hand-edit)
251
- │ ├── t2v.json
252
- │ ├── i2v.json
253
- │ ├── a2v.json
254
- │ ├── lipsync.json
255
- │ ├── keyframe.json
256
- │ └── style.json
257
- ├── assets/seed_inputs/ # placeholder image/audio/video for cold-start (gitignored except this dir)
258
- ├── docs/
259
- │ ├── superpowers/specs/ # design specs (per-feature)
260
- │ ├── superpowers/plans/ # implementation plans (per-feature)
261
- │ └── future_improvements.md
262
- ├── tools/extract_modes.py # regenerate workflows/ from master
263
- ├── tests/
264
- ├── README.md # HF Space YAML + project description
265
- ├── CLAUDE.md # what & why (this project's facts)
266
- ├── SKILLS.md # how (this file)
267
- ├── requirements.txt
268
- └── comfyui/ # git submodule (local) / runtime clone target (Spaces)
269
- ```
270
-
271
- ---
272
-
273
- ## Useful one-liners
274
-
275
- ```bash
276
- # What's the Space's current SHA vs local HEAD
277
- hf_sha=$(curl -s -H "Authorization: Bearer $(cat ~/.cache/huggingface/token)" \
278
- "https://huggingface.co/api/spaces/techfreakworm/LTX2.3-Studio" \
279
- | jq -r '.sha')
280
- echo "HF: ${hf_sha:0:8} local: $(git rev-parse HEAD | cut -c1-8)"
281
-
282
- # Local commits ahead of origin
283
- git log origin/master..HEAD --oneline
284
-
285
- # All class_types referenced by workflows (cross-check against custom_nodes)
286
- python3 -c "import json, glob, sys
287
- seen = set()
288
- for p in glob.glob('workflows/*.json'):
289
- seen |= {n.get('class_type','') for n in json.load(open(p)).values()}
290
- for c in sorted(seen): print(c)"
291
-
292
- # Models referenced by workflows but not in registry
293
- python3 -c "import json, glob, models
294
- needed = set()
295
- for p in glob.glob('workflows/*.json'):
296
- needed |= models.walk_workflow_for_models(json.load(open(p)))
297
- unmapped = needed - set(models.MODEL_REGISTRY)
298
- print('unmapped:', sorted(unmapped) or 'none')"
299
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,1242 +1,101 @@
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 imageio_ffmpeg
8
- import tempfile
9
- import shutil
10
- import subprocess
11
- from datetime import datetime
12
- import pathlib
13
- import random
14
  import sys
15
- import time
16
- import math
17
- from typing import Any
18
-
19
- import gradio as gr
20
-
21
- import backend as backend_module
22
- import modes
23
- import ui
24
- import workflow as wf_module
25
-
26
- # ---------------------------------------------------------------------------
27
- # Bootstrap — runs once on cold start.
28
- # ---------------------------------------------------------------------------
29
-
30
-
31
- def _on_spaces() -> bool:
32
- return bool(os.environ.get("SPACES_ZERO_GPU"))
33
-
34
-
35
- COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
36
- COMFYUI_COMMIT = os.environ.get(
37
- "LTX23_AIO_COMFYUI_COMMIT",
38
- "eb0686bbb60c83e44c3a3e4f7defd0f589cfef10",
39
- )
40
-
41
- CUSTOM_NODES_PINNED: list[tuple[str, str]] = [
42
- ("https://github.com/Lightricks/ComfyUI-LTXVideo.git", "2acf7af8991f33b5cc06ec26753cb6e88e057d04"),
43
- ("https://github.com/kijai/ComfyUI-KJNodes.git", "01d9fa9c983273532cacdf9532c74a93c7dc86d2"),
44
- ("https://github.com/rgthree/rgthree-comfy.git", "683836c46e898668936c433502504cc0627482c5"),
45
- ("https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite.git", "2984ec4c4b93292421888f38db74a5e8802a8ff8"),
46
- ("https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git", "609f3afaa74b2f88ef9ce8d939626065e3247469"),
47
- ("https://github.com/city96/ComfyUI-GGUF.git", "6ea2651e7df66d7585f6ffee804b20e92fb38b8a"),
48
- ("https://github.com/Fannovel16/comfyui_controlnet_aux.git", "e8b689a513c3e6b63edc44066560ca5919c0576e"),
49
- ("https://github.com/evanspearman/ComfyMath.git", "c01177221c31b8e5fbc062778fc8254aeb541638"),
50
- ("https://github.com/Smirnov75/ComfyUI-mxToolkit.git", "7f7a0e584f12078a1c589645d866ae96bad0cc35"),
51
- ("https://github.com/DoctorDiffusion/ComfyUI-MediaMixer.git", "2bae7b5ea8fc52d8a4d668d62fed76265f4eec2c"),
52
- ]
53
-
54
- mode_debug_value = [None]
55
- input_image_debug_value = [None]
56
- end_image_debug_value = [None]
57
- audio_debug_value = [None]
58
- prompt_debug_value = [None]
59
- negative_prompt_debug_value = [None]
60
- total_second_length_debug_value = [None]
61
- width_debug_value = [None]
62
- height_debug_value = [None]
63
- resolution_debug_value = [None]
64
- allocation_time_debug_value = [None]
65
-
66
-
67
- def _git_clone(url: str, dst: pathlib.Path, ref: str) -> None:
68
- """Clone *url* at *ref* into *dst*. *ref* may be a branch, tag, or SHA.
69
-
70
- `git clone --branch` only accepts branch/tag names, so we use init+fetch
71
- which works for any object GitHub allows fetching (default: reachable
72
- commits in public repos).
73
- """
74
- import subprocess
75
-
76
- dst = pathlib.Path(dst)
77
- dst.mkdir(parents=True, exist_ok=True)
78
- subprocess.check_call(["git", "-C", str(dst), "init", "-q"])
79
- subprocess.check_call(["git", "-C", str(dst), "remote", "add", "origin", url])
80
- subprocess.check_call(["git", "-C", str(dst), "fetch", "--depth", "1", "origin", ref])
81
- subprocess.check_call(["git", "-C", str(dst), "checkout", "-q", "FETCH_HEAD"])
82
-
83
-
84
- def _mirror_preload_hf_cache() -> None:
85
- """Mirror the build-populated HF cache into a writable runtime tree.
86
-
87
- HF Spaces' build pipeline runs `preload_from_hub` as a different user
88
- than the runtime container, so the populated `~/.cache/huggingface/`
89
- is read-only for us (uid 1000). Any subsequent `hf_hub_download` call
90
- that needs to write a NEW file (lazy-loaded LoRAs, GGUF, etc.) fails
91
- with "Permission denied" because the parent dir isn't writable.
92
-
93
- Fix: build a parallel tree at `~/hf-cache-rw/` that we own, with:
94
- - dirs: created fresh via mkdir
95
- - blob files (`blobs/<sha>`): hardlinked (shared inode, instant)
96
- - relative snapshot symlinks: preserved as symlinks
97
- - `refs/<branch>` files: byte-copied (HF lib overwrites these)
98
- - everything else: byte-copied (safest default)
99
- Then set HF_HOME / HF_HUB_CACHE so HF lib reads/writes through the
100
- mirror. Reads are zero-copy via hardlink/symlink; new downloads land
101
- in dirs we created.
102
- """
103
- import shutil
104
-
105
- src_root = pathlib.Path.home() / ".cache" / "huggingface"
106
- dst_root = pathlib.Path.home() / "hf-cache-rw"
107
- dst_root.mkdir(parents=True, exist_ok=True)
108
- os.environ["HF_HOME"] = str(dst_root)
109
- os.environ["HF_HUB_CACHE"] = str(dst_root / "hub")
110
-
111
- if not src_root.exists():
112
- return
113
-
114
- counts = {"dirs": 0, "hardlinks": 0, "symlinks": 0, "copies": 0, "errors": 0}
115
-
116
- def _treat_as_copy(rel_path: pathlib.PurePath) -> bool:
117
- # Anything under a refs/ dir, anywhere in the tree.
118
- return any(part == "refs" for part in rel_path.parts)
119
-
120
- def _walk(s: pathlib.Path, d: pathlib.Path) -> None:
121
- try:
122
- d.mkdir(parents=True, exist_ok=True)
123
- counts["dirs"] += 1
124
- except OSError as exc:
125
- print(f"[bootstrap] mirror mkdir fail {d}: {exc}", flush=True)
126
- counts["errors"] += 1
127
- return
128
-
129
- for entry in s.iterdir():
130
- de = d / entry.name
131
- try:
132
- if entry.is_symlink():
133
- if de.exists() or de.is_symlink():
134
- continue
135
- target = os.readlink(str(entry))
136
- de.symlink_to(target)
137
- counts["symlinks"] += 1
138
- elif entry.is_dir():
139
- _walk(entry, de)
140
- elif entry.is_file():
141
- if de.exists():
142
- continue
143
- rel = de.relative_to(dst_root)
144
- if _treat_as_copy(rel):
145
- shutil.copy2(entry, de)
146
- counts["copies"] += 1
147
- else:
148
- try:
149
- os.link(str(entry), str(de))
150
- counts["hardlinks"] += 1
151
- except OSError:
152
- # Cross-device or other — fall back to symlink.
153
- de.symlink_to(entry)
154
- counts["symlinks"] += 1
155
- except OSError as exc:
156
- print(f"[bootstrap] mirror skip {entry}: {exc}", flush=True)
157
- counts["errors"] += 1
158
-
159
- _walk(src_root, dst_root)
160
- print(
161
- f"[bootstrap] hf cache mirrored to {dst_root}: "
162
- f"{counts['dirs']} dirs, {counts['hardlinks']} hardlinks, "
163
- f"{counts['symlinks']} symlinks, {counts['copies']} copies, "
164
- f"{counts['errors']} errors",
165
- flush=True,
166
- )
167
-
168
-
169
- def _bootstrap() -> None:
170
- on_spaces = _on_spaces()
171
- # /data requires the paid persistent-storage add-on (separate from Pro).
172
- # Without it, /data is unwritable. $HOME is writable and — because ZeroGPU
173
- # containers freeze on sleep rather than tear down — the clone persists
174
- # across calls within a single deploy.
175
- comfy_dir = (pathlib.Path.home() / "comfyui") if on_spaces else pathlib.Path("comfyui")
176
-
177
- if on_spaces and not comfy_dir.exists():
178
- print(f"[bootstrap] cold start on Spaces; cloning ComfyUI to {comfy_dir}", flush=True)
179
- comfy_dir.parent.mkdir(parents=True, exist_ok=True)
180
- _git_clone(COMFYUI_REPO, comfy_dir, ref=COMFYUI_COMMIT)
181
- for node_url, node_ref in CUSTOM_NODES_PINNED:
182
- name = node_url.rstrip(".git").rsplit("/", 1)[-1]
183
- _git_clone(node_url, comfy_dir / "custom_nodes" / name, ref=node_ref)
184
- import subprocess
185
-
186
- # ComfyUI core requirements + each custom node's requirements
187
- for req_path in [
188
- comfy_dir / "requirements.txt",
189
- *(cn / "requirements.txt" for cn in (comfy_dir / "custom_nodes").iterdir()),
190
- ]:
191
- if req_path.exists():
192
- print(f"[bootstrap] pip install -r {req_path}", flush=True)
193
- subprocess.check_call(
194
- [sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_path)]
195
- )
196
-
197
- if str(comfy_dir) not in sys.path:
198
- sys.path.insert(0, str(comfy_dir))
199
- os.environ.setdefault("COMFY_MODELS_DIR", str(comfy_dir / "models"))
200
-
201
- # Mirror the build-time HF cache (populated by preload_from_hub, owned by
202
- # build user → read-only for runtime user 1000) into a writable parallel
203
- # tree under $HOME, then point HF_HUB_CACHE / HF_HOME at it. After this:
204
- # - preloaded blobs are accessible via hardlink (no data copy, instant reads)
205
- # - relative snapshot symlinks resolve within the mirror
206
- # - refs/* are byte-copies so HF lib can overwrite when commits advance
207
- # - new lazy-downloaded files write to dirs we own → no permission errors
208
- if on_spaces:
209
- _mirror_preload_hf_cache()
210
-
211
- # Stage placeholder input files so the workflow's hard-referenced loaders
212
- # (LoadImage/VHS_Load*) don't error at runtime even when the active mode
213
- # doesn't actually use the file. Real user uploads are placed alongside via
214
- # `_stage_to_comfy_input` later.
215
- seed_dir = pathlib.Path(__file__).parent / "assets" / "seed_inputs"
216
- inputs_dir = comfy_dir / "input"
217
- inputs_dir.mkdir(parents=True, exist_ok=True)
218
- if seed_dir.exists():
219
- import shutil
220
 
221
- for src in seed_dir.iterdir():
222
- if not src.is_file():
223
- continue
224
- dst = inputs_dir / src.name
225
- if not dst.exists():
226
- try:
227
- shutil.copy2(src, dst)
228
- except OSError as exc:
229
- print(f"[bootstrap] could not seed {src.name}: {exc}", flush=True)
230
 
 
231
 
232
- _bootstrap()
 
 
233
 
 
 
 
 
234
 
235
- # ---------------------------------------------------------------------------
236
- # Styling: hide the default top tab strip (drawer nav drives selection),
237
- # add status-card styling, plus single responsive breakpoint at 1023 px
238
- # (drawer slides over body) / 1024 px+ (drawer pinned).
239
- # ---------------------------------------------------------------------------
240
 
241
- _CUSTOM_CSS = """
242
- /* Hide Gradio's top tab strip — sidebar drives selection. */
243
- .aio-tabs > .tab-nav,
244
- .aio-tabs > div:first-child[role="tablist"],
245
- .aio-tabs > div:first-child:has([role="tab"]) {
246
- position: absolute !important;
247
- left: -99999px !important;
248
- top: -99999px !important;
249
- height: 0 !important;
250
- overflow: hidden !important;
251
- visibility: visible !important;
252
- pointer-events: auto !important;
253
- }
254
 
255
- /* === Header === */
256
- .aio-header {
257
- display: flex;
258
- align-items: center;
259
- gap: 12px;
260
- padding: 11px 18px;
261
- border-bottom: 1px solid #262C35;
262
- position: relative;
263
- /* HF injects #huggingface-space-header at fixed z-index 20 (top-right
264
- like/share widget). Stay below it by default so we don't cover it. */
265
- z-index: 15;
266
- }
267
- /* When drawer is open, lift header above scrim (z-45) and drawer (z-50) so
268
- the hamburger flips to × and remains clickable as a close affordance.
269
- Toggled in lockstep with .aio-shell.drawer-open via the inline JS below. */
270
- .aio-header.drawer-elevated {
271
- z-index: 60;
272
- }
273
- .aio-ham-label {
274
- display: none;
275
- width: 32px; height: 32px;
276
- border: 1px solid #262C35;
277
- border-radius: 5px;
278
- cursor: pointer;
279
- align-items: center; justify-content: center;
280
- font-size: 18px; font-weight: 300;
281
- user-select: none;
282
- }
283
- .aio-ham-label:hover { border-color: #E0A458; }
284
- .aio-title {
285
- font-size: 15px; font-weight: 600; letter-spacing: -0.01em;
286
- }
287
- .aio-mode-tag {
288
- margin-left: auto;
289
- padding: 4px 9px;
290
- font-family: 'IBM Plex Mono', ui-monospace, monospace;
291
- font-size: 11px; font-weight: 500; letter-spacing: 0.04em;
292
- border: 1px solid #E0A458;
293
- border-radius: 4px;
294
- }
295
-
296
- .aio-tipbar {
297
- margin: 0 0 6px 0;
298
- padding: 6px 14px;
299
- font-family: 'IBM Plex Sans', system-ui, sans-serif;
300
- font-size: 12px;
301
- border-bottom: 1px solid #262C35;
302
- text-align: center;
303
- }
304
- .aio-tipbar strong { font-weight: 500; }
305
-
306
- /* === Drawer === */
307
- .aio-shell { position: relative; }
308
- .aio-drawer {
309
- width: 220px;
310
- border-right: 1px solid #262C35;
311
- padding: 14px 10px !important;
312
- flex-shrink: 0;
313
- transition: left 0.2s ease;
314
- }
315
- .aio-drawer-heading {
316
- font-family: 'IBM Plex Mono', ui-monospace, monospace;
317
- font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em;
318
- padding: 6px 8px 4px !important;
319
- margin: 0 !important;
320
- }
321
-
322
- /* Mode buttons */
323
- .aio-mode-btn { width: 100%; text-align: left; margin: 2px 0 !important; }
324
- .aio-mode-btn-active {
325
- border-left: 3px solid #E0A458 !important;
326
- }
327
-
328
- /* Model status / settings panels */
329
- .aio-model-badge {
330
- padding: 9px 11px;
331
- border-radius: 6px;
332
- border: 1px solid #262C35;
333
- font-size: 11.5px;
334
- font-family: 'IBM Plex Mono', ui-monospace, monospace;
335
- }
336
- #default_examples {
337
- display:none;
338
- }
339
-
340
- /* === Status banner === */
341
- .status-card {
342
- padding: 12px 16px;
343
- border-radius: 6px;
344
- border: 1px solid #262C35;
345
- }
346
- .status-row { display: flex; gap: 14px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
347
- .status-stage { font-weight: 600; }
348
- .status-meta { font-size: 12px; font-family: 'IBM Plex Mono', ui-monospace, monospace; }
349
- .status-bar { height: 4px; border-radius: 99px; overflow: hidden; }
350
- .status-fill { height: 100%; transition: width .3s; }
351
- .status-mem { font-size: 11px; margin-top: 6px; font-family: 'IBM Plex Mono', ui-monospace, monospace; }
352
- .status-error {
353
- border-color: #F4A6A8 !important;
354
- }
355
-
356
- /* === Drawer toggle behavior at the desktop boundary === */
357
- @media (max-width: 1023px) {
358
- .aio-ham-label { display: flex; }
359
- .aio-drawer {
360
- position: fixed;
361
- top: 0; bottom: 0;
362
- left: -100%;
363
- z-index: 50;
364
- box-shadow: 4px 0 24px rgba(0,0,0,0.6);
365
- max-width: 80vw;
366
- overflow-y: auto;
367
- overflow-x: hidden;
368
- padding-top: 80px !important;
369
- }
370
- /* `.aio-shell.drawer-open` is toggled by the hamburger's inline JS.
371
- `body:has(:checked)` would be cleaner but Gradio prefixes user CSS
372
- with `.gradio-container .contain `, breaking ancestor selectors. */
373
- .aio-shell.drawer-open .aio-drawer { left: 0; }
374
- .aio-shell.drawer-open::before {
375
- content: ""; position: fixed; inset: 0;
376
- z-index: 45;
377
- backdrop-filter: blur(10px);
378
- -webkit-backdrop-filter: blur(10px);
379
- }
380
-
381
- /* Mobile sub-tweaks */
382
- .aio-mode-btn { font-size: 13px !important; padding: 7px 10px !important; }
383
- .aio-body [class*="row"] { flex-wrap: wrap !important; }
384
- .aio-body [class*="row"] > div { flex: 1 1 100% !important; min-width: 0 !important; }
385
- }
386
-
387
- @media (min-width: 1024px) {
388
- .aio-ham-label { display: none; }
389
- }
390
- """
391
-
392
-
393
- # ---------------------------------------------------------------------------
394
- # UI
395
- # ---------------------------------------------------------------------------
396
-
397
-
398
-
399
- _HEAD_HTML = """
400
- <script>
401
- (function(){
402
- if (window._aioDismissInstalled) return;
403
- window._aioDismissInstalled = true;
404
- document.addEventListener("click", function(e) {
405
- var s = document.querySelector(".aio-shell");
406
- if (!s || !s.classList.contains("drawer-open")) return;
407
- if (e.target.closest(".aio-drawer") || e.target.closest(".aio-ham-label")) return;
408
- s.classList.remove("drawer-open");
409
- var h = document.querySelector(".aio-header");
410
- if (h) h.classList.remove("drawer-elevated");
411
- var b = document.querySelector(".aio-ham-label");
412
- if (b) {
413
- b.textContent = "\\u2261";
414
- b.setAttribute("aria-expanded", "false");
415
- }
416
- });
417
- })();
418
- </script>
419
- """
420
-
421
-
422
- def build_app() -> gr.Blocks:
423
- with gr.Blocks(title="LTX 2.3 Studio", css=_CUSTOM_CSS, head=_HEAD_HTML) as app:
424
- # Header: hamburger button toggles `.drawer-open` on `.aio-shell`.
425
- # The click-outside dismisser is registered via gr.Blocks(head=...)
426
- # below — Gradio strips <script> tags inside gr.HTML so it has to
427
- # live in <head> to actually run.
428
- gr.HTML(
429
- '<div class="aio-header">'
430
- ' <button type="button" class="aio-ham-label" '
431
- ' onclick="(function(b){var s=document.querySelector(\'.aio-shell\');'
432
- 'var o=s.classList.toggle(\'drawer-open\');'
433
- 'var h=document.querySelector(\'.aio-header\');'
434
- 'if(h)h.classList.toggle(\'drawer-elevated\',o);'
435
- 'b.textContent=o?\'\\u00d7\':\'\\u2261\';'
436
- 'b.setAttribute(\'aria-expanded\',o?\'true\':\'false\');})(this)" '
437
- ' aria-expanded="false" aria-label="Toggle navigation">≡</button>'
438
- ' <span class="aio-title">LTX 2.3 <span class="accent">Studio</span></span>'
439
- ' <span class="aio-mode-tag" id="aio-mode-tag">T2V</span>'
440
- '</div>'
441
- )
442
- gr.HTML(
443
- '<div class="aio-tipbar">'
444
- 'Liking this project? '
445
- '<strong>Drop a <span class="aio-heart">♥</span> at the top of this page</strong> '
446
- 'to support it.'
447
- '<br/>'
448
- 'If you don\'t care about the sound, I advise you Wan 2.2 instead.'
449
- '</div>'
450
- )
451
-
452
- with gr.Row(elem_classes=["aio-shell"]):
453
- # Drawer (drawer behaves as fixed sidebar ≥1024 px;
454
- # absolute-positioned overlay <1024 px — see _CUSTOM_CSS).
455
- with gr.Column(scale=1, min_width=200, elem_classes=["aio-drawer"]):
456
- gr.Markdown("Modes", elem_classes=["aio-drawer-heading"])
457
- mode_buttons = {
458
- name: gr.Button(
459
- f"{m.icon} {m.label}",
460
- elem_classes=["aio-mode-btn"],
461
- variant="secondary",
462
- )
463
- for name, m in modes.MODE_REGISTRY.items()
464
- }
465
- gr.Markdown("Models", elem_classes=["aio-drawer-heading"])
466
- model_status = gr.HTML(_render_model_status_idle(), elem_id="aio-model-status")
467
- refresh_btn = gr.Button("Refresh", size="sm", variant="secondary")
468
- unload_btn = gr.Button("Unload all models", size="sm", variant="secondary")
469
- gr.Markdown("Settings", elem_classes=["aio-drawer-heading"])
470
- gr.Markdown(
471
- "Output: `comfyui/output/LTX2.3/`<br>"
472
- "Set `LTX23_AIO_VRAM=lowvram|normalvram|highvram` to override "
473
- "the auto-detected VRAM tier.",
474
- elem_classes=["aio-model-badge"],
475
- )
476
-
477
- # Body — unchanged, still hosts the 6 mode tabs.
478
- with gr.Column(scale=4, elem_classes=["aio-body"]):
479
- handles, tabs_component = _render_mode_panels()
480
-
481
- # Wire generate buttons
482
- for name, h in handles.items():
483
- inputs = _collect_inputs_for_mode(name, h)
484
- h["generate_btn"].click(
485
- fn=_make_handler(name, h),
486
- inputs=inputs,
487
- outputs=[h["status"], h["video_out"]],
488
- )
489
-
490
- # JS to update the header mode tag without a server round-trip.
491
- # Each mode button injects a tiny on-click that rewrites #aio-mode-tag
492
- # and (on mobile) auto-collapses the drawer.
493
- _MODE_TAG_BY_NAME = {
494
- "t2v": "T2V", "a2v": "A2V", "i2v": "I2V",
495
- "lipsync": "LIPSYNC", "keyframe": "KEY", "style": "STYLE",
496
- }
497
- for name, btn in mode_buttons.items():
498
- tag = _MODE_TAG_BY_NAME.get(name, name.upper())
499
- btn.click(
500
- fn=lambda mode_id=name: gr.Tabs(selected=mode_id),
501
- inputs=None,
502
- outputs=[tabs_component],
503
- js=f"() => {{ "
504
- f"const el = document.getElementById('aio-mode-tag'); "
505
- f"if (el) el.textContent = {tag!r}; "
506
- f"if (window.matchMedia('(max-width: 1023px)').matches) {{ "
507
- f" document.querySelector('.aio-shell')?.classList.remove('drawer-open'); "
508
- f" document.querySelector('.aio-header')?.classList.remove('drawer-elevated'); "
509
- f" const hb = document.querySelector('.aio-ham-label'); "
510
- f" if (hb) {{ hb.textContent = '\\u2261'; hb.setAttribute('aria-expanded', 'false'); }} "
511
- f"}} return []; }}",
512
- )
513
-
514
- # Sidebar model info wiring
515
- refresh_btn.click(fn=_render_model_status, inputs=None, outputs=[model_status])
516
- unload_btn.click(fn=_unload_models, inputs=None, outputs=[model_status])
517
-
518
- return app
519
-
520
-
521
- def _render_model_status_idle() -> str:
522
- return (
523
- '<div class="aio-model-badge">device: detecting…<br>'
524
- "loaded: —<br>free: —</div>"
525
- )
526
-
527
-
528
- def _render_model_status() -> str:
529
- """Best-effort device + memory readout for the sidebar."""
530
  try:
531
- be = _get_backend() # ensure ComfyUI is loaded
532
- except Exception as exc:
533
- return f'<div class="aio-model-badge">backend not ready<br>{exc}</div>'
534
- try:
535
- import comfy.model_management as mm
536
- import torch
537
-
538
- device = mm.get_torch_device()
539
- free_gb = mm.get_free_memory(device) / (1024**3)
540
- if torch.backends.mps.is_available():
541
- # MPS unified memory: total physical = total system RAM. The
542
- # "recommended max" from torch.mps is a soft cap (~75% of total)
543
- # used by the allocator, but actual free can exceed it because
544
- # macOS shares RAM between CPU and GPU.
545
- try:
546
- import psutil
547
-
548
- total_gb = psutil.virtual_memory().total / (1024**3)
549
- except Exception:
550
- total_gb = torch.mps.recommended_max_memory() / (1024**3)
551
- cap_gb = torch.mps.recommended_max_memory() / (1024**3)
552
- label = "MPS (unified)"
553
- extra = f"<br>mps cap: {cap_gb:.1f} GB"
554
- elif torch.cuda.is_available():
555
- total_gb = torch.cuda.get_device_properties(0).total_memory / (1024**3)
556
- label = "CUDA"
557
- extra = ""
558
- else:
559
- total_gb = 0.0
560
- label = "CPU"
561
- extra = ""
562
- loaded = len(getattr(mm, "current_loaded_models", []))
563
- return (
564
- '<div class="aio-model-badge">'
565
- f"device: {label}<br>"
566
- f"loaded: {loaded} model(s)<br>"
567
- f"free: {free_gb:.1f} GB / {total_gb:.1f} GB total"
568
- f"{extra}"
569
- "</div>"
570
- )
571
- except Exception as exc:
572
- return f'<div class="aio-model-badge">memory probe failed: {exc}</div>'
573
-
574
-
575
- def _unload_models() -> str:
576
- try:
577
- import comfy.model_management as mm
578
- import torch
579
-
580
- mm.unload_all_models()
581
- if torch.backends.mps.is_available():
582
- torch.mps.empty_cache()
583
- if torch.cuda.is_available():
584
- torch.cuda.empty_cache()
585
- except Exception as exc:
586
- return f'<div class="aio-model-badge">unload failed: {exc}</div>'
587
- return _render_model_status()
588
-
589
-
590
- def _render_mode_panels() -> tuple[dict[str, dict], gr.Tabs]:
591
- """Render one (hidden-tab) panel per mode. Returns the component handles + the Tabs component."""
592
- handles: dict[str, dict] = {}
593
- with gr.Tabs(elem_classes=["aio-tabs"]) as tabs:
594
- for name, mode in modes.MODE_REGISTRY.items():
595
- with gr.Tab(label=f"{mode.icon} {mode.label}", id=name):
596
- handles[name] = _render_one_mode(name)
597
- return handles, tabs
598
-
599
-
600
- def _render_one_mode(name: str) -> dict:
601
- """Render a per-mode form. Returns component handles for the generate handler."""
602
- handles: dict = {"mode": name}
603
-
604
- with gr.Row():
605
- with gr.Column(scale=2, min_width=280):
606
- handles["prompt"] = gr.Textbox(
607
- label="Prompt", lines=4, placeholder="The creature moves. We clearly hear the motion of the creature. Then the creature says in Italian: \"Ciao\". Realistic behavior and character motion. Dynamic characters. Audio: birdsong."
608
- )
609
-
610
- # Mode-specific media inputs
611
- if name == "i2v":
612
- handles["image"] = gr.Image(label="Source image (crop before to go faster)", type="filepath")
613
- elif name == "a2v":
614
- handles["audio"] = gr.Audio(label="Source audio", type="filepath")
615
- elif name == "lipsync":
616
- handles["image"] = gr.Image(label="Portrait (if the AI doesn't follow the lips, crop the face even more)", type="filepath")
617
- handles["audio"] = gr.Audio(label="Speech audio", type="filepath")
618
- elif name == "keyframe":
619
- handles["first_frame"] = gr.Image(label="First frame (crop before to go faster)", type="filepath")
620
- handles["last_frame"] = gr.Image(label="Last frame (crop before to go faster)", type="filepath")
621
- elif name == "style":
622
- handles["image"] = gr.Image(label="Style reference (crop before to go faster)", type="filepath")
623
- handles["input_video"] = gr.Video(label="Source video")
624
-
625
- handles["preset"] = ui.preset_bar()
626
-
627
- # Resolution — up to 4K, /32 step
628
- with gr.Row():
629
- handles["width"] = gr.Slider(
630
- 256, 4096, value=512, step=32, label="Width"
631
- )
632
- handles["height"] = gr.Slider(
633
- 256, 4096, value=768, step=32, label="Height"
634
- )
635
-
636
- # Length controlled in seconds (matches the master workflow's mxSlider).
637
- # Frames are derived: frames = round(seconds * fps / 8) * 8 + 1.
638
- with gr.Row():
639
- handles["seconds"] = gr.Slider(
640
- minimum=1, maximum=30, value=3, step=1,
641
- label="Length (seconds)",
642
- info="Frames are computed as 8·round(seconds·fps/8)+1 (LTX requires 8k+1)",
643
- )
644
- handles["fps"] = gr.Slider(8, 30, value=24, step=1, label="FPS")
645
-
646
- handles["frames_display"] = gr.Markdown("Frames: 73", elem_classes=["aio-frames-display"])
647
-
648
- with gr.Row():
649
- handles["negative_prompt"] = gr.Textbox(label="Negative prompt", info="music, speech, jumpcut, crossfader...", lines=2, value="unrealistic position, shapeshifting, morphing, ugly, low quality, average quality, translation, subtitle, text, logo, error, bug, artefact, white noise, recording sound")
650
- handles["lora"] = ui.lora_chrome(name)
651
-
652
- with gr.Accordion("Advanced ▾", open=False):
653
- handles["seed"] = gr.Number(label="Seed", value=42, precision=0, minimum=0)
654
- handles["randomize_seed"] = gr.Checkbox(label="Randomize seed each run", value=True)
655
-
656
- handles["generate_btn"] = gr.Button("🚀 Generate", variant="primary", size="lg")
657
-
658
- # Live frames-display update when seconds/fps change
659
- def _update_frames(seconds, fps):
660
- f = max(9, int(round(float(seconds) * float(fps) / 8) * 8) + 1)
661
- return f"**Frames:** {f} (`{seconds}s` × `{fps} fps`)"
662
-
663
- handles["seconds"].change(
664
- fn=_update_frames,
665
- inputs=[handles["seconds"], handles["fps"]],
666
- outputs=[handles["frames_display"]],
667
- )
668
- handles["fps"].change(
669
- fn=_update_frames,
670
- inputs=[handles["seconds"], handles["fps"]],
671
- outputs=[handles["frames_display"]],
672
- )
673
-
674
- with gr.Column(scale=2, min_width=280):
675
- handles["status"] = ui.status_banner()
676
- handles["video_out"] = gr.Video(label="Output", autoplay=True)
677
- handles["history"] = gr.Markdown("")
678
-
679
-
680
- with gr.Row(elem_id="default_examples"):
681
- dummy_debug = gr.Text(visible = False)
682
- mode_image_debug = gr.Radio(
683
- label="Mode Debug",
684
- choices=["t2v", "i2v", "a2v", "lipsync", "keyframe", "style"],
685
- value="keyframe",
686
- )
687
- input_image_debug = gr.Image(type="filepath", label="Image Debug")
688
- end_image_debug = gr.Image(type="filepath", label="End image Debug")
689
- audio_debug = gr.Audio(label="Source audio Debug", type="filepath")
690
- allocation_time_debug = gr.Slider(1, 60 * 60, value=1000, step=1, label="Time Debug")
691
- output_video_debug = gr.Video(label="Generated Video", autoplay = False, loop = True, interactive = False)
692
- reset_button_debug = gr.Button(value="Reset", visible = True, interactive = True)
693
- download_button_debug = gr.DownloadButton(elem_id="download_btn", visible = True, interactive = True)
694
- video_information = gr.HTML(value = "")
695
- gr.Examples(
696
- label=" ",
697
- examples=[[""]],
698
- inputs=[dummy_debug],
699
- outputs=[output_video_debug, download_button_debug, video_information],
700
- fn=run_example,
701
- run_on_click=True,
702
- cache_examples=True,
703
- cache_mode='lazy',
704
- )
705
-
706
- video_information.change(
707
- fn=lambda output: output,
708
- inputs=[output_video_debug],
709
- js="(v) => { try {is_to_download = !is_to_download;} catch (e) {is_to_download = true;} if (is_to_download) {document.getElementById('download_btn').click();}}"
710
- )
711
-
712
- def handle_field_debug_change(
713
- mode_image_debug_data,
714
- input_image_debug_data,
715
- end_image_debug_data,
716
- audio_debug_data,
717
- prompt_debug_data,
718
- negative_prompt_debug_data,
719
- total_second_length_debug_data,
720
- width_debug_data,
721
- height_debug_data,
722
- allocation_time_debug_data
723
- ):
724
- mode_debug_value[0] = mode_image_debug_data
725
- input_image_debug_value[0] = input_image_debug_data
726
- end_image_debug_value[0] = end_image_debug_data
727
- audio_debug_value[0] = audio_debug_data
728
- prompt_debug_value[0] = prompt_debug_data
729
- negative_prompt_debug_value[0] = negative_prompt_debug_data
730
- total_second_length_debug_value[0] = total_second_length_debug_data
731
- width_debug_value[0] = width_debug_data
732
- height_debug_value[0] = height_debug_data
733
- allocation_time_debug_value[0] = allocation_time_debug_data
734
- print("All done!")
735
- print(str(mode_debug_value[0]))
736
- print(str(input_image_debug_value[0]))
737
- print(str(end_image_debug_value[0]))
738
- print(str(audio_debug_value[0]))
739
- print(str(prompt_debug_value[0]))
740
- print(str(negative_prompt_debug_value[0]))
741
- print(str(total_second_length_debug_value[0]))
742
- print(str(width_debug_value[0]))
743
- print(str(height_debug_value[0]))
744
- print(str(allocation_time_debug_value[0]))
745
 
746
- inputs_debug=[mode_image_debug, input_image_debug, end_image_debug, audio_debug, handles["prompt"], handles["negative_prompt"], handles["seconds"], handles["width"], handles["height"], allocation_time_debug]
747
- mode_image_debug.change(fn=handle_field_debug_change, inputs=inputs_debug)
748
- input_image_debug.upload(fn=handle_field_debug_change, inputs=inputs_debug)
749
- end_image_debug.upload(fn=handle_field_debug_change, inputs=inputs_debug)
750
- audio_debug.upload(fn=handle_field_debug_change, inputs=inputs_debug)
751
- handles["prompt"].change(fn=handle_field_debug_change, inputs=inputs_debug)
752
- handles["negative_prompt"].change(fn=handle_field_debug_change, inputs=inputs_debug)
753
- handles["width"].change(fn=handle_field_debug_change, inputs=inputs_debug)
754
- handles["height"].change(fn=handle_field_debug_change, inputs=inputs_debug)
755
- handles["seconds"].change(fn=handle_field_debug_change, inputs=inputs_debug)
756
- allocation_time_debug.change(fn=handle_field_debug_change, inputs=inputs_debug)
757
-
758
- def handle_field_debug_reset():
759
- return ["keyframe", None, None, None, "", "cut, jumpcut, crossfader, unrealistic position, shapeshifting, morphing, ugly, low quality, average quality, translation, subtitle, text, logo, error, bug, artefact, white noise, recording sound", 22, 2048, 1536, 1000]
760
-
761
- reset_button_debug.click(fn=handle_field_debug_reset, inputs=[], outputs=inputs_debug).then(fn=handle_field_debug_change, inputs=inputs_debug)
762
-
763
- return handles
764
 
765
- # ---------------------------------------------------------------------------
766
- # Backend wiring
767
- # ---------------------------------------------------------------------------
768
-
769
- _BACKEND: backend_module.ComfyUILibraryBackend | None = None
770
-
771
 
772
- def _get_backend() -> backend_module.ComfyUILibraryBackend:
773
- global _BACKEND
774
- if _BACKEND is None:
775
- _BACKEND = backend_module.ComfyUILibraryBackend()
776
- return _BACKEND
777
-
778
-
779
- # Must match the comfy_dir used in _bootstrap() — on Spaces this is
780
- # ~/comfyui (mirroring backend.py's _comfy_dir), otherwise repo-local.
781
- _COMFY_INPUT_DIR = (
782
- (pathlib.Path.home() / "comfyui" / "input")
783
- if _on_spaces()
784
- else pathlib.Path(__file__).parent / "comfyui" / "input"
785
- )
786
-
787
-
788
- def _stage_to_comfy_input(file_path) -> str | None:
789
- """Copy/stage a path into comfyui/input/ so ComfyUI's LoadImage etc. can find it."""
790
- if not file_path:
791
- return None
792
- if not isinstance(file_path, (str, pathlib.Path)):
793
- file_path = (
794
- file_path.get("name") or file_path.get("path") or file_path.get("orig_name")
795
- if isinstance(file_path, dict)
796
- else None
797
- )
798
- if not file_path:
799
- return None
800
- src = pathlib.Path(file_path)
801
- if not src.exists() or not src.is_file():
802
- print(f"[_stage] skip {file_path!r}", flush=True)
803
- return None
804
- _COMFY_INPUT_DIR.mkdir(parents=True, exist_ok=True)
805
  try:
806
- if src.resolve().is_relative_to(_COMFY_INPUT_DIR.resolve()):
807
- return src.name
808
- except (ValueError, OSError):
809
- pass
810
- dst = _COMFY_INPUT_DIR / src.name
811
- if not dst.exists() or dst.stat().st_size != src.stat().st_size:
812
- import shutil
813
-
814
- shutil.copy2(src, dst)
815
- return src.name
816
-
817
-
818
- PRESET_DURATION = {"Fast": 60, "Balanced": 120, "Quality": 300}
819
-
820
-
821
- _FRIENDLY_ERRORS: dict[str, tuple[str, str]] = {
822
- "gpu_timeout": (
823
- "Hit the GPU time limit",
824
- "This run took longer than the GPU budget. Try the Fast preset, a "
825
- "shorter video, or a smaller resolution — then click Generate again.",
826
- ),
827
- "expired_token": (
828
- "Session timed out",
829
- "Your sign-in session expired. Refresh the page and try again — "
830
- "you'll keep your spot in the GPU queue.",
831
- ),
832
- "illegal_duration": (
833
- "GPU budget too high",
834
- "The estimator asked for more GPU time than the server allows. "
835
- "Try Fast preset or a shorter video.",
836
- ),
837
- "unlogged": (
838
- "Sign-in not detected",
839
- "Make sure you're signed into huggingface.co (top-right avatar), "
840
- "then refresh this page. Pro accounts get 25 min of GPU per day.",
841
- ),
842
- "quota_exceeded": (
843
- "Daily GPU quota used up",
844
- "You've used today's GPU minutes. Wait for the rolling 24-hour "
845
- "reset, or upgrade Pro at huggingface.co/subscribe/pro for more.",
846
- ),
847
- "oom": (
848
- "Ran out of GPU memory",
849
- "Try a smaller resolution, fewer frames, or the Fast preset.",
850
- ),
851
- "interrupt": (
852
- "Cancelled",
853
- "Generation was cancelled. Click Generate to start a fresh run.",
854
- ),
855
- "download": (
856
- "Model download failed",
857
- "Couldn't fetch a required model file. Check your internet and try again.",
858
- ),
859
- }
860
-
861
-
862
- def _friendly_error(category: str, raw_message: str) -> tuple[str, str]:
863
- """Translate a backend error category into (title, body) the user can act on."""
864
- if category in _FRIENDLY_ERRORS:
865
- return _FRIENDLY_ERRORS[category]
866
- return (
867
- "Generation failed",
868
- "Something went wrong. Click Generate to retry, or check the Space "
869
- "logs if it keeps happening.",
870
- )
871
-
872
-
873
- def _seconds_to_frames(seconds: float, fps: int) -> int:
874
- return max(9, int(round(float(seconds) * float(fps) / 8) * 8) + 1)
875
-
876
-
877
- async def _on_generate(mode_name: str, *, progress: Any = None, **inputs: Any):
878
- """Generate handler — async generator yielding (status_html, video_path).
879
-
880
- `progress` is a `gr.Progress` instance injected by Gradio. It's the only
881
- progress channel that survives the @spaces.GPU subprocess boundary on HF
882
- Spaces; we forward it to the backend so ComfyUI's per-step counter renders
883
- a real progress bar instead of a generic Gradio spinner.
884
- """
885
- if mode_name == "i2v":
886
- if inputs["image"] is None:
887
- raise gr.Error("Please upload an image.")
888
- if not os.path.exists(inputs["image"]):
889
- raise gr.Error("The file has been lost. Please upload an image again.")
890
- elif mode_name == "a2v":
891
- if inputs["audio"] is None:
892
- raise gr.Error("Please upload an audio.")
893
- if not os.path.exists(inputs["audio"]):
894
- raise gr.Error("The file has been lost. Please upload an audio again.")
895
- elif mode_name == "lipsync":
896
- if inputs["image"] is None:
897
- raise gr.Error("Please upload an image.")
898
- if not os.path.exists(inputs["image"]):
899
- raise gr.Error("The file has been lost. Please upload an image again.")
900
- if inputs["audio"] is None:
901
- raise gr.Error("Please upload an audio.")
902
- if not os.path.exists(inputs["audio"]):
903
- raise gr.Error("The file has been lost. Please upload an audio again.")
904
- elif mode_name == "keyframe":
905
- if inputs["first_frame"] is None:
906
- raise gr.Error("Please upload an image for the begining.")
907
- if not os.path.exists(inputs["first_frame"]):
908
- raise gr.Error("The file has been lost. Please upload an image for the begining again.")
909
- if inputs["last_frame"] is None:
910
- raise gr.Error("Please upload an image for the end.")
911
- if not os.path.exists(inputs["last_frame"]):
912
- raise gr.Error("The file has been lost. Please upload an image for the end again.")
913
- elif mode_name == "style":
914
- if inputs["image"] is None:
915
- raise gr.Error("Please upload an image.")
916
- if not os.path.exists(inputs["image"]):
917
- raise gr.Error("The file has been lost. Please upload an image again.")
918
- if inputs["input_video"] is None:
919
- raise gr.Error("Please upload a video.")
920
- if not os.path.exists(inputs["input_video"]):
921
- raise gr.Error("The file has been lost. Please upload a video again.")
922
-
923
- mode = modes.MODE_REGISTRY[mode_name]
924
-
925
- fps = int(inputs.get("fps", 24))
926
- seconds = float(inputs.get("seconds", 3))
927
- frames = _seconds_to_frames(seconds, fps)
928
- gpu_duration = int(inputs.get("gpu_duration", -1))
929
-
930
- # Seed: respect the explicit value unless the "randomize" checkbox is on.
931
- seed = int(inputs.get("seed", 42))
932
- if inputs.get("randomize_seed"):
933
- seed = random.randint(0, 2**31 - 1)
934
-
935
- print("prompt:" + str(inputs.get("prompt", "")))
936
- print("negative_prompt:" + str(inputs.get("negative_prompt", "")))
937
- print("preset:" + str(inputs.get("preset", "Balanced")).lower())
938
- print("width:" + str(int(inputs.get("width", 512))))
939
- print("height:" + str(int(inputs.get("height", 768))))
940
- print("frames:" + str(frames))
941
- print("fps:" + str(fps))
942
- print("seed:" + str(seed))
943
-
944
- params: dict[str, Any] = {
945
- "prompt": inputs.get("prompt", ""),
946
- "negative_prompt": inputs.get("negative_prompt", ""),
947
- "preset": str(inputs.get("preset", "Balanced")).lower(),
948
- "width": int(inputs.get("width", 512)),
949
- "height": int(inputs.get("height", 768)),
950
- "frames": frames,
951
- "fps": fps,
952
- "seed": seed,
953
- }
954
-
955
- for k in (
956
- "image", "audio", "first_frame", "last_frame", "input_video",
957
- "camera_lora", "camera_strength", "detailer_on", "detailer_strength",
958
- "ic_lora", "ic_strength", "pose_on", "audio_cfg", "image_strength",
959
- ):
960
- if k in inputs:
961
- params[k] = inputs[k]
962
-
963
- for key in ("image", "audio", "first_frame", "last_frame", "input_video"):
964
- if key in params and params[key]:
965
- staged = _stage_to_comfy_input(params[key])
966
- if staged is None:
967
- params.pop(key, None)
968
- else:
969
- params[key] = staged
970
-
971
- patches = mode.parameterize_fn(params)
972
- workflow = wf_module.load_template(mode_name)
973
- for patch in patches:
974
- wf_module.set_input(workflow, *patch)
975
-
976
- backend = _get_backend()
977
- preset = params["preset"] # already lowercased above
978
-
979
- async def _translate(event, started_at):
980
- """Translate one backend event into Gradio (status_html, video) yields.
981
-
982
- Returns the tuple to yield, plus a flag indicating terminal state.
983
- """
984
- elapsed = time.time() - started_at
985
- if isinstance(event, backend_module.DownloadEvent):
986
- return (
987
- ui.render_status(
988
- stage_index=0,
989
- stage_label=f"Downloading {event.filename}",
990
- step=int(event.mb_done),
991
- total_steps=int(max(event.mb_total, 1)),
992
- elapsed_s=elapsed,
993
- eta_s=0,
994
- ),
995
- gr.update(),
996
- )
997
- if isinstance(event, backend_module.ProgressEvent):
998
- label = f"Diffusion (Stage {event.stage})"
999
- eta = (elapsed / max(event.step, 1)) * (event.total_steps - event.step)
1000
- return (
1001
- ui.render_status(
1002
- stage_index=event.stage,
1003
- stage_label=label,
1004
- step=event.step,
1005
- total_steps=event.total_steps,
1006
- elapsed_s=elapsed,
1007
- eta_s=eta,
1008
- ),
1009
- gr.update(),
1010
- )
1011
- if isinstance(event, backend_module.OutputEvent):
1012
- video_update = event.video_path if event.video_path else gr.update()
1013
- return (ui._render_idle(), video_update)
1014
- if isinstance(event, backend_module.ErrorEvent):
1015
- title, body = _friendly_error(event.category, event.message)
1016
- return (
1017
- f'<div class="status-card status-error">'
1018
- f' <div class="status-row"><span class="status-stage">{title}</span></div>'
1019
- f" <div>{body}</div>"
1020
- f"</div>",
1021
- gr.update(),
1022
- )
1023
- return None
1024
-
1025
- # Single attempt. ZeroGPU-side abort (duration cap) and 401 expired-token
1026
- # surface as friendly messages via _friendly_error; user clicks Generate
1027
- # again to retry with a fresh request and fresh X-IP-Token.
1028
- started = time.time()
1029
- async for event in backend.submit(
1030
- mode_name,
1031
- workflow,
1032
- preset=preset,
1033
- duration_multiplier=1.0,
1034
- gpu_duration=gpu_duration,
1035
- progress=progress,
1036
- ):
1037
- translated = await _translate(event, started)
1038
- if translated is not None:
1039
- yield translated
1040
-
1041
-
1042
- def _input_keys_for_mode(mode_name: str, h: dict) -> list[str]:
1043
- base = ["prompt", "preset", "width", "height", "seconds", "fps", "seed", "randomize_seed"]
1044
- if mode_name == "i2v":
1045
- base.append("image")
1046
- elif mode_name == "a2v":
1047
- base.append("audio")
1048
- elif mode_name == "lipsync":
1049
- base.extend(["image", "audio"])
1050
- elif mode_name == "keyframe":
1051
- base.extend(["first_frame", "last_frame"])
1052
- elif mode_name == "style":
1053
- base.extend(["image", "input_video"])
1054
- base.append("negative_prompt")
1055
- base.extend(["camera_lora", "camera_strength", "detailer_on", "detailer_strength"])
1056
- if h["lora"].ic_lora is not None:
1057
- base.extend(["ic_lora", "ic_strength"])
1058
- if h["lora"].pose_on is not None:
1059
- base.append("pose_on")
1060
- return base
1061
-
1062
-
1063
- def _collect_inputs_for_mode(mode_name: str, h: dict) -> list:
1064
- base = [
1065
- h["prompt"], h["preset"], h["width"], h["height"],
1066
- h["seconds"], h["fps"], h["seed"], h["randomize_seed"],
1067
- ]
1068
- if mode_name == "i2v":
1069
- base.append(h["image"])
1070
- elif mode_name == "a2v":
1071
- base.append(h["audio"])
1072
- elif mode_name == "lipsync":
1073
- base.extend([h["image"], h["audio"]])
1074
- elif mode_name == "keyframe":
1075
- base.extend([h["first_frame"], h["last_frame"]])
1076
- elif mode_name == "style":
1077
- base.extend([h["image"], h["input_video"]])
1078
- base.append(h["negative_prompt"])
1079
- base.extend([
1080
- h["lora"].camera_lora, h["lora"].camera_strength,
1081
- h["lora"].detailer_on, h["lora"].detailer_strength,
1082
- ])
1083
- if h["lora"].ic_lora is not None:
1084
- base.extend([h["lora"].ic_lora, h["lora"].ic_strength])
1085
- if h["lora"].pose_on is not None:
1086
- base.append(h["lora"].pose_on)
1087
- return base
1088
-
1089
-
1090
- def _make_handler(mode_name: str, h: dict):
1091
- keys = _input_keys_for_mode(mode_name, h)
1092
-
1093
- async def handler(*values, progress=gr.Progress()):
1094
- kwargs = dict(zip(keys, values, strict=False))
1095
- async for output in _on_generate(mode_name, progress=progress, **kwargs):
1096
- #async for output in []:
1097
- yield output
1098
-
1099
- return handler
1100
 
1101
- def set_mp4_comments_imageio_ffmpeg(input_file, comments):
1102
  try:
1103
- # Get the path to the bundled FFmpeg binary from imageio-ffmpeg
1104
- ffmpeg_path = imageio_ffmpeg.get_ffmpeg_exe()
1105
-
1106
- # Check if input file exists
1107
- if not os.path.exists(input_file):
1108
- #print(f"Error: Input file {input_file} does not exist")
1109
- return False
1110
 
1111
- # Create a temporary file path
1112
- temp_file = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False).name
1113
 
1114
- # FFmpeg command using the bundled binary
1115
- command = [
1116
- ffmpeg_path, # Use imageio-ffmpeg's FFmpeg
1117
- '-i', input_file, # input file
1118
- '-metadata', f'comment={comments}', # set comment metadata
1119
- '-c:v', 'copy', # copy video stream without re-encoding
1120
- '-c:a', 'copy', # copy audio stream without re-encoding
1121
- '-y', # overwrite output file if it exists
1122
- temp_file # temporary output file
1123
- ]
1124
 
1125
- # Run the FFmpeg command
1126
- result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
 
 
 
1127
 
1128
- if result.returncode == 0:
1129
- # Replace the original file with the modified one
1130
- shutil.move(temp_file, input_file)
1131
- #print(f"Successfully added comments to {input_file}")
1132
- return True
1133
- else:
1134
- # Clean up temp file if FFmpeg fails
1135
- if os.path.exists(temp_file):
1136
- os.remove(temp_file)
1137
- #print(f"Error: FFmpeg failed with message:\n{result.stderr}")
1138
- return False
1139
 
1140
- except Exception as e:
1141
- # Clean up temp file in case of other errors
1142
- if 'temp_file' in locals() and os.path.exists(temp_file):
1143
- os.remove(temp_file)
1144
- print(f"Error saving prompt to video metadata, ffmpeg may be required: "+str(e))
1145
- return False
1146
 
1147
- async def run_example(dummy_image):
1148
- start = time.time()
1149
- example_inputs = {
1150
- "prompt": prompt_debug_value[0],
1151
- "negative_prompt": negative_prompt_debug_value[0],
1152
- "preset": str("Quality").lower(),
1153
- "width": int(width_debug_value[0]),
1154
- "height": int(height_debug_value[0]),
1155
- "frames": [],
1156
- "randomize_seed": True,
1157
- "seed": 42,
1158
- "fps": 24,
1159
- "seconds": total_second_length_debug_value[0],
1160
- "image": input_image_debug_value[0],
1161
- "audio": audio_debug_value[0],
1162
- "first_frame": input_image_debug_value[0] if input_image_debug_value[0] is not None else end_image_debug_value[0],
1163
- "last_frame": end_image_debug_value[0] if end_image_debug_value[0] is not None else input_image_debug_value[0],
1164
- "input_video": None,
1165
- "camera_lora": None,
1166
- "camera_strength": None,
1167
- "detailer_on": None,
1168
- "detailer_strength": None,
1169
- "ic_lora": None,
1170
- "ic_strength": None,
1171
- "pose_on": None,
1172
- "audio_cfg": None,
1173
- "image_strength": None,
1174
- "gpu_duration": allocation_time_debug_value[0]
1175
- }
1176
- mode_name=mode_debug_value[0]
1177
- allocation_time = allocation_time_debug_value[0]
1178
- target_width = int(width_debug_value[0])
1179
- target_height = int(height_debug_value[0])
1180
- mode_debug_value[0] = input_image_debug_value[0] = end_image_debug_value[0] = audio_debug_value[0] = prompt_debug_value[0] = negative_prompt_debug_value[0] = total_second_length_debug_value[0] = width_debug_value[0] = height_debug_value[0] = allocation_time_debug_value[0] = None
1181
- final_output = None
1182
- count = 10
1183
- while count > 0:
1184
- try:
1185
- async for output in _on_generate(mode_name=mode_name, progress=gr.Progress(), **example_inputs):
1186
- final_output = output
1187
- count = 0
1188
- except BaseException as err:
1189
- print("An exception occurred: " + str(err))
1190
- try:
1191
- print('e.message: ' + err.message) # No GPU is currently available for you after 60s
1192
- except Exception as e2:
1193
- print('Failure')
1194
- if not str(err.message if err is not None and hasattr(err, 'message') else '').startswith("No GPU is currently available for you after 60s"):
1195
- count = 0
1196
- else:
1197
- count = int(count) - 1
1198
- except:
1199
- print("An error occurred")
1200
- if not str(e).startswith("No GPU is currently available for you after 60s"):
1201
- count = 0
1202
- else:
1203
- count = int(count) - 1
1204
 
1205
- print("Video exported: " + str(final_output))
1206
- set_mp4_comments_imageio_ffmpeg(final_output[1], f"Prompt: {example_inputs['prompt']} | Negative Prompt: {example_inputs['negative_prompt']}")
1207
- end = time.time()
1208
- secondes = int(end - start)
1209
- minutes = math.floor(secondes / 60)
1210
- secondes = secondes - (minutes * 60)
1211
- hours = math.floor(minutes / 60)
1212
- minutes = minutes - (hours * 60)
1213
- information = ("Start the process again if you want a different result. " if True else "") + \
1214
- "The video has been generated in " + \
1215
- ((str(hours) + " h, ") if hours != 0 else "") + \
1216
- ((str(minutes) + " min, ") if hours != 0 or minutes != 0 else "") + \
1217
- str(secondes) + " sec (including " + str(allocation_time) + " seconds of GPU). " + \
1218
- "The video resolution is " + str(target_width) + \
1219
- " pixels large and " + str(target_height) + \
1220
- " pixels high, so a resolution of " + f'{target_width * target_height:,}' + " pixels." + \
1221
- " Your prompt is saved into the metadata of the video."
1222
- return final_output[1], final_output[1], information
1223
 
1224
  if __name__ == "__main__":
1225
- # Gradio 5's file-access policy refuses to serve files outside cwd /
1226
- # tempdir / allowed_paths. ComfyUI writes generated videos to
1227
- # `<comfy_dir>/output/...` which is outside our cwd on Spaces, so
1228
- # whitelist that directory tree explicitly.
1229
- _on_spaces_at_launch = bool(os.environ.get("SPACES_ZERO_GPU"))
1230
- _comfy_dir_at_launch = (
1231
- (pathlib.Path.home() / "comfyui") if _on_spaces_at_launch
1232
- else pathlib.Path(__file__).parent / "comfyui"
1233
- )
1234
- _output_dir = _comfy_dir_at_launch / "output"
1235
- _output_dir.mkdir(parents=True, exist_ok=True)
1236
-
1237
- app = build_app()
1238
- app.launch(
1239
- server_name="0.0.0.0",
1240
- server_port=7860,
1241
- allowed_paths=[str(_output_dir)],
1242
- )
 
1
+ import spaces
 
 
 
 
2
  import os
 
 
 
 
 
 
 
3
  import sys
4
+ import site
5
+ import logging
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
+ class IgnoreUnetUnexpectedFilter(logging.Filter):
8
+ def filter(self, record: logging.LogRecord) -> bool:
9
+ return "unet unexpected" not in record.getMessage()
 
 
 
 
 
 
10
 
11
+ logging.getLogger().addFilter(IgnoreUnetUnexpectedFilter())
12
 
13
+ if "--use-sage-attention" not in sys.argv:
14
+ sys.argv.append("--use-sage-attention")
15
+ print("🚀 [SageAttention] Injected '--use-sage-attention' into sys.argv.")
16
 
17
+ APP_DIR = os.path.dirname(os.path.abspath(__file__))
18
+ if APP_DIR not in sys.path:
19
+ sys.path.insert(0, APP_DIR)
20
+ print(f"✅ Added project root '{APP_DIR}' to sys.path.")
21
 
22
+ SAGE_PATCH_APPLIED = False
 
 
 
 
23
 
24
+ def apply_sage_attention_patch():
25
+ global SAGE_PATCH_APPLIED
26
+ if SAGE_PATCH_APPLIED:
27
+ return "SageAttention patch already applied."
 
 
 
 
 
 
 
 
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  try:
30
+ from comfy import model_management
31
+ import sageattention
32
+
33
+ print("--- [Runtime Patch] sageattention package found. Applying patch... ---")
34
+ model_management.sage_attention_enabled = lambda: True
35
+ model_management.pytorch_attention_enabled = lambda: False
36
+
37
+ SAGE_PATCH_APPLIED = True
38
+ return "✅ Successfully enabled SageAttention."
39
+ except ImportError:
40
+ SAGE_PATCH_APPLIED = False
41
+ msg = "--- [Runtime Patch] ⚠️ sageattention package not found. Continuing with default attention. ---"
42
+ print(msg)
43
+ return msg
44
+ except Exception as e:
45
+ SAGE_PATCH_APPLIED = False
46
+ msg = f"--- [Runtime Patch] ❌ An error occurred while applying SageAttention patch: {e} ---"
47
+ print(msg)
48
+ return msg
49
+
50
+ @spaces.GPU
51
+ def dummy_gpu_for_startup():
52
+ print("--- [GPU Startup] Dummy function for startup check initiated. ---")
53
+ patch_result = apply_sage_attention_patch()
54
+ print(f"--- [GPU Startup] {patch_result} ---")
55
+ print("--- [GPU Startup] Startup check passed. ---")
56
+ return "Startup check passed."
57
+
58
+
59
+ def main():
60
+ from comfy_integration import setup as setup_comfyui
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
+ print("--- [Setup] Starting ComfyUI initialization ---")
63
+ setup_comfyui.initialize_comfyui()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
+ print("--- [Setup] Applying SageAttention Runtime Patch ---")
66
+ patch_result = apply_sage_attention_patch()
67
+ print(f"--- [Setup] {patch_result} ---")
 
 
 
68
 
69
+ print("--- [Setup] Reloading site-packages to detect newly installed packages... ---")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  try:
71
+ site.main()
72
+ print("--- [Setup] ✅ Site-packages reloaded. ---")
73
+ except Exception as e:
74
+ print(f"--- [Setup] ⚠️ Warning: Could not fully reload site-packages: {e} ---")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
+ print("--- Initiating GPU Startup Check & SageAttention Patch Verification ---")
77
  try:
78
+ dummy_gpu_for_startup()
79
+ except BaseException as e:
80
+ err_msg = f"{type(e).__name__}: {str(e)}"
81
+ print(f"--- [GPU Startup] ⚠️ Warning: Startup check failed: {err_msg} ---")
 
 
 
82
 
83
+ print("--- Starting Application Setup ---")
 
84
 
 
 
 
 
 
 
 
 
 
 
85
 
86
+ print("--- Environment configured. Proceeding with module imports. ---")
87
+ from ui.layout import build_ui
88
+ from ui.events import attach_event_handlers
89
+ import mcp_tools as mcp
90
+ print(f"✅ Loaded MCP module with tools: {[fn.__name__ for fn in mcp.MCP_FUNCTIONS]}")
91
 
92
+ print(f"✅ Working directory is stable: {os.getcwd()}")
 
 
 
 
 
 
 
 
 
 
93
 
94
+ demo = build_ui(attach_event_handlers)
 
 
 
 
 
95
 
96
+ print("--- Launching Gradio Interface ---")
97
+ demo.queue().launch(mcp_server=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  if __name__ == "__main__":
101
+ main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
assets/seed_inputs/5.FLF2.png DELETED
Binary file (1.47 kB)
 
assets/seed_inputs/IMG-20210721-WA0008.jpg DELETED
Binary file (3.03 kB)
 
assets/seed_inputs/Sway Dance Lesson 31s.mp4 DELETED
Binary file (3.16 kB)
 
assets/seed_inputs/beauty_pagent_dialogue.mp3 DELETED
Binary file (17 kB)
 
assets/seed_inputs/influencer_mic_hd.png DELETED
Binary file (1.47 kB)
 
backend.py DELETED
@@ -1,603 +0,0 @@
1
- """ComfyUI library-mode backend.
2
-
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
10
- import contextvars
11
- import os
12
- import pathlib
13
- import sys
14
- import threading
15
- import traceback as tb_mod
16
- from collections.abc import AsyncIterator, Iterable
17
- from dataclasses import dataclass, field
18
- from typing import Any
19
-
20
- import models
21
-
22
-
23
- @dataclass
24
- class DownloadEvent:
25
- filename: str
26
- mb_done: float
27
- mb_total: float
28
-
29
-
30
- @dataclass
31
- class ProgressEvent:
32
- stage: int
33
- stage_label: str
34
- step: int
35
- total_steps: int
36
-
37
-
38
- @dataclass
39
- class OutputEvent:
40
- video_path: str
41
- audio_path: str | None = None
42
- meta: dict = field(default_factory=dict)
43
-
44
-
45
- @dataclass
46
- class ErrorEvent:
47
- category: str # "oom" | "zerogpu_timeout" | "execution" | "interrupt" | "download"
48
- message: str
49
- stage: int | None = None
50
- traceback: str = ""
51
-
52
-
53
- def _on_spaces() -> bool:
54
- return bool(os.environ.get("SPACES_ZERO_GPU"))
55
-
56
-
57
- try:
58
- import spaces # type: ignore
59
- except ImportError:
60
- spaces = None # type: ignore[assignment]
61
-
62
-
63
- def _identity(fn):
64
- return fn
65
-
66
-
67
- # --- Per-call ZeroGPU duration estimator -----------------------------------
68
- # `duration` is a per-call timeout. Shorter declared duration → faster queue
69
- # priority on the shared ZeroGPU pool. Estimating from (mode, preset, frames)
70
- # instead of using a one-size-fits-all 600s cap means light T2V calls jump
71
- # the queue while heavy modes (lipsync, style) reserve real headroom.
72
-
73
- _BASE_DURATION_S: dict[str, int] = {
74
- # Rough sampler+decode time at ~120 frames, balanced preset, warm cache.
75
- "t2v": 90,
76
- "i2v": 90,
77
- "a2v": 120,
78
- "lipsync": 240, # extra: audio encoder + audio VAE + extra LoRAs
79
- "keyframe": 180,
80
- "style": 360, # extra: preprocessor (canny/dwpose/depth) + IC-LoRAs
81
- }
82
- _PRESET_MULT: dict[str, float] = {"fast": 1.0, "balanced": 1.5, "quality": 3.0}
83
-
84
-
85
- def _frames_from_workflow(workflow: dict) -> int:
86
- """Read the frame count from the workflow's EmptyLTXVLatentVideo node."""
87
- for node in workflow.values():
88
- if isinstance(node, dict) and node.get("class_type") == "EmptyLTXVLatentVideo":
89
- try:
90
- return int((node.get("inputs") or {}).get("length", 121))
91
- except (TypeError, ValueError):
92
- return 121
93
- return 121
94
-
95
-
96
- def _duration_for(
97
- executor: Any,
98
- workflow: dict,
99
- output_ids: list[str],
100
- mode: str,
101
- preset: str,
102
- multiplier: float = 1.0,
103
- gpu_duration: int = -1,
104
- progress: Any = None,
105
- ) -> int:
106
- """ZeroGPU duration estimator. Same signature as _execute_workflow.
107
-
108
- `progress` is a gr.Progress instance forwarded by the caller; we ignore it
109
- here (estimator doesn't emit progress) but must accept it positionally so
110
- ZeroGPU can call us with the same arg list it'll use for _execute_workflow.
111
-
112
- Estimate = (base × preset multiplier + cold-cache buffer + per-frame VAE
113
- decode time) × retry multiplier, clamped to [60s, 240s]. ZeroGPU rejects
114
- durations above the server's per-call max with "ZeroGPU illegal duration"
115
- (client.py:137); 240s is observed to work for Pro identity (~2 min runs
116
- needed for style + lipsync detailer paths). If the server rejects values
117
- in this range, the user will see a clear error and can retry.
118
- """
119
- if gpu_duration != -1:
120
- return gpu_duration
121
-
122
- base = _BASE_DURATION_S.get(mode, 180)
123
- mult = _PRESET_MULT.get(preset.lower(), 1.5)
124
- frames = _frames_from_workflow(workflow)
125
- est = int((base * mult + 60 + frames * 0.3) * multiplier)
126
- return max(60, min(est, 240))
127
-
128
-
129
- # Decorate at module load time so ZeroGPU's startup analyzer detects it.
130
- _GPU = (
131
- spaces.GPU(duration=_duration_for)
132
- if (spaces is not None and _on_spaces())
133
- else _identity
134
- )
135
-
136
-
137
- @_GPU
138
- def _execute_workflow(
139
- executor: Any,
140
- workflow: dict,
141
- output_ids: list[str],
142
- mode: str,
143
- preset: str,
144
- multiplier: float = 1.0,
145
- gpu_duration: int = -1,
146
- progress: Any = None,
147
- ) -> str:
148
- """Run the workflow on GPU and return the path of the first video output.
149
-
150
- Returns just the video path (a plain string, picklable across the
151
- @spaces.GPU subprocess boundary). The `mode`, `preset`, and `multiplier`
152
- args are consumed by `_duration_for` to estimate the GPU slot to reserve.
153
-
154
- `progress` is an optional `gr.Progress` instance. It's the only progress
155
- channel that crosses the @spaces.GPU subprocess boundary on HF Spaces —
156
- Gradio + the `spaces` library wrap it with cross-process IPC. When set,
157
- we mirror ComfyUI's step counter into it via the global progress hook,
158
- chaining to whatever hook was already installed (so the local event-based
159
- status banner keeps working alongside).
160
- """
161
- if progress is not None:
162
- import comfy.utils as _cu
163
- _saved_hook = getattr(_cu, "PROGRESS_BAR_HOOK", None)
164
-
165
- def _gp_hook(value, total, _preview=None, **_kw):
166
- try:
167
- v, t = int(value), int(total)
168
- progress(v / max(t, 1), desc=f"Sampling step {v}/{t}")
169
- except Exception:
170
- pass
171
- if _saved_hook is not None:
172
- try:
173
- _saved_hook(value, total, _preview)
174
- except Exception:
175
- pass
176
-
177
- _cu.set_progress_bar_global_hook(_gp_hook)
178
-
179
- executor.execute(
180
- workflow,
181
- prompt_id="ltx23-aio",
182
- extra_data={"client_id": "ltx23-aio"},
183
- execute_outputs=output_ids,
184
- )
185
- hist = getattr(executor, "history_result", {}) or {}
186
- outs = hist.get("outputs") or {}
187
- for output in outs.values():
188
- if not isinstance(output, dict):
189
- continue
190
- for value in output.values():
191
- if not isinstance(value, list):
192
- continue
193
- for item in value:
194
- if isinstance(item, dict):
195
- fn = item.get("filename") or ""
196
- if fn.endswith((".mp4", ".webm", ".mov")):
197
- return item.get("fullpath") or fn
198
- return ""
199
-
200
-
201
- class _StubServer:
202
- """Minimal stub matching the surface ComfyUI's PromptExecutor expects."""
203
-
204
- client_id: str | None = "ltx23-aio"
205
- last_node_id: str | None = None
206
-
207
- def send_sync(self, event: str, data: dict, sid: str | None = None) -> None:
208
- pass
209
-
210
- def queue_updated(self) -> None:
211
- pass
212
-
213
-
214
- class _StubPromptQueue:
215
- """Stub matching the surface VideoHelperSuite + others touch."""
216
-
217
- currently_running: dict = {}
218
- history: dict = {}
219
- flags: dict = {}
220
-
221
- def get_current_queue(self) -> tuple[list, list]:
222
- return ([], [])
223
-
224
- def get_tasks_remaining(self) -> int:
225
- return 0
226
-
227
- def set_flag(self, name: str, data) -> None:
228
- pass
229
-
230
- def get_flags(self, *a, **kw) -> dict:
231
- return {}
232
-
233
- def task_done(self, *a, **kw) -> None:
234
- pass
235
-
236
- def put(self, *a, **kw) -> None:
237
- pass
238
-
239
- def wipe_queue(self) -> None:
240
- pass
241
-
242
- def delete_queue_item(self, *a, **kw) -> None:
243
- pass
244
-
245
-
246
- class _StubPromptServerInstance:
247
- """Surface that ComfyUI's `server.PromptServer.instance` exposes to custom nodes.
248
-
249
- VideoHelperSuite, KJNodes, and others read this at import time. They mostly
250
- use it to register HTTP routes or send WS events or peek at the prompt queue.
251
- No-ops here are fine — we have no real server.
252
- """
253
-
254
- client_id: str | None = "ltx23-aio"
255
- # KJNodes' preview thread reads `last_node_id.encode('ascii')` directly.
256
- # ComfyUI's real server keeps it as a string per executing node and resets
257
- # to None at end-of-prompt — which races the preview thread. Keep it a
258
- # safe non-empty string so .encode() never NPEs.
259
- last_node_id: str = "ltx23-aio"
260
- web_root: str = ""
261
-
262
- class _Routes:
263
- def get(self, *a, **kw):
264
- return lambda fn: fn
265
-
266
- def post(self, *a, **kw):
267
- return lambda fn: fn
268
-
269
- def static(self, *a, **kw):
270
- return None
271
-
272
- routes = _Routes()
273
- sockets: dict = {}
274
- prompt_queue = _StubPromptQueue()
275
- # Custom-Scripts checks PromptServer.instance.supports — claim the
276
- # "custom_nodes_from_web" capability so it skips its JS install path.
277
- supports: list[str] = ["custom_nodes_from_web"]
278
- web_root: str = ""
279
-
280
- def add_routes(self) -> None:
281
- pass
282
-
283
- def send_sync(self, event: str, data: dict, sid: str | None = None) -> None:
284
- pass
285
-
286
- def send_progress_text(self, text: str, node_id=None, sid=None) -> None:
287
- # Comfy_extras nodes call this; we just no-op since we don't have a UI
288
- # to surface intermediate text on.
289
- pass
290
-
291
- def queue_updated(self) -> None:
292
- pass
293
-
294
- def get_node_class_def(self, *a, **kw):
295
- return None
296
-
297
- def __getattr__(self, name):
298
- # Anything else our custom nodes might reach for — give them a no-op.
299
- # This is a deliberate liberal catch-all so the inference path doesn't
300
- # die on cosmetic UI hooks. Inspection-style access (hasattr) gets True.
301
- def _noop(*a, **kw):
302
- return None
303
- return _noop
304
-
305
-
306
- def _comfy_dir() -> pathlib.Path:
307
- if _on_spaces():
308
- return pathlib.Path.home() / "comfyui"
309
- return pathlib.Path(__file__).parent / "comfyui"
310
-
311
-
312
- class ComfyUILibraryBackend:
313
- """Wraps PromptExecutor for in-process workflow execution."""
314
-
315
- def __init__(self) -> None:
316
- self._comfy_dir = _comfy_dir()
317
- if not self._comfy_dir.exists():
318
- raise RuntimeError(
319
- f"ComfyUI not found at {self._comfy_dir}. "
320
- f"Local: run `bash setup.sh`. Spaces: see app.py:_bootstrap()."
321
- )
322
- if str(self._comfy_dir) not in sys.path:
323
- sys.path.insert(0, str(self._comfy_dir))
324
-
325
- # Defer comfy imports until the path is set up.
326
- # NOTE: ComfyUI ships PromptExecutor in the top-level `execution.py`
327
- # module, NOT under `comfy.execution`. Same for `nodes`. Both must be
328
- # imported AFTER the sys.path insert above.
329
- import asyncio
330
- import threading
331
-
332
- import comfy.cli_args # noqa: F401 — side-effect: registers CLI flags
333
- import execution # top-level module — provides PromptExecutor
334
- import nodes # top-level module — provides init_extra_nodes (async)
335
-
336
- # CRITICAL ordering fix: ComfyUI's nodes.py:24 inserts `comfyui/comfy/`
337
- # at sys.path[0]. That dir contains a module-style `utils.py`, which
338
- # shadows `comfyui/utils/` (a package containing install_util.py).
339
- # Some custom nodes (KJNodes, VideoHelperSuite via app.frontend_management)
340
- # do `from utils.install_util import …` and get `comfy/utils.py` instead,
341
- # raising "'utils' is not a package". Rewrite sys.path so comfy_dir is
342
- # ahead of comfy_dir/comfy and force-clear any cached `utils` binding.
343
- comfy_subdir = str(self._comfy_dir / "comfy")
344
- sys.path = [p for p in sys.path if p not in (str(self._comfy_dir), comfy_subdir)]
345
- sys.path.insert(0, comfy_subdir)
346
- sys.path.insert(0, str(self._comfy_dir))
347
- if "utils" in sys.modules and not getattr(sys.modules["utils"], "__path__", None):
348
- del sys.modules["utils"]
349
-
350
- # Some custom nodes (e.g. VideoHelperSuite) read `server.PromptServer.instance`
351
- # at import time. We don't run a real ComfyUI server, so install a stub
352
- # that exposes the attributes those nodes touch (sockets, send, etc.).
353
- import server as comfy_server
354
-
355
- if getattr(comfy_server.PromptServer, "instance", None) is None:
356
- comfy_server.PromptServer.instance = _StubPromptServerInstance()
357
-
358
- # `nodes.init_extra_nodes` is async. We may be called from within a
359
- # running event loop (Gradio's handler) — running `asyncio.run()` there
360
- # raises. Run the coroutine in a fresh loop on a worker thread instead.
361
- def _init_in_thread() -> None:
362
- loop = asyncio.new_event_loop()
363
- asyncio.set_event_loop(loop)
364
- try:
365
- loop.run_until_complete(nodes.init_extra_nodes())
366
- finally:
367
- loop.close()
368
-
369
- thread = threading.Thread(target=_init_in_thread, daemon=False)
370
- thread.start()
371
- thread.join()
372
- # PromptExecutor expects a `server` with client_id, send_sync, last_node_id,
373
- # queue_updated. A minimal stub no-ops all of them — we don't run a real
374
- # websocket server, we surface progress via comfy.utils.PROGRESS_BAR_HOOK.
375
- # cache_args["ram"] is read unconditionally inside execute_async even when
376
- # cache_type is the default false — provide a sensible default so it doesn't
377
- # NoneType-subscript at line 727.
378
- self._executor = execution.PromptExecutor(
379
- server=_StubServer(),
380
- cache_args={"ram": 16.0, "lru": 0},
381
- )
382
-
383
- def __repr__(self) -> str:
384
- return f"ComfyUILibraryBackend(comfy_dir={self._comfy_dir!r})"
385
-
386
- async def submit(
387
- self,
388
- mode: str,
389
- workflow: dict,
390
- *,
391
- preset: str = "balanced",
392
- duration_multiplier: float = 1.0,
393
- gpu_duration: int = -1,
394
- progress: Any = None,
395
- ) -> AsyncIterator[Any]:
396
- """Run a workflow end-to-end. Yields Download/Progress/Output/Error events.
397
-
398
- `preset` and `duration_multiplier` flow through to the @spaces.GPU
399
- duration estimator. The handler can re-call submit() with
400
- duration_multiplier=2.0 if the first attempt aborts on timeout.
401
- """
402
- # Pre-flight: ensure all model files exist.
403
- try:
404
- needed = models.walk_workflow_for_models(workflow)
405
- for download_event in models.ensure_models(needed):
406
- yield download_event
407
- except Exception as e:
408
- yield ErrorEvent(
409
- category="download",
410
- message=str(e),
411
- traceback=tb_mod.format_exc(),
412
- )
413
- return
414
-
415
- # Run the inference in a worker thread; pass progress events through a queue.
416
- queue: asyncio.Queue = asyncio.Queue()
417
- loop = asyncio.get_running_loop()
418
-
419
- def _push(event: Any) -> None:
420
- asyncio.run_coroutine_threadsafe(queue.put(event), loop)
421
-
422
- # Track stage progression. ComfyUI fires the progress hook from inside
423
- # samplers, so we advance the stage every time we observe a new sampler
424
- # starting (step==0 with a different total than before, or a "new run"
425
- # signal — value smaller than the running max for the same total).
426
- progress_state = {"stage": 0, "prev_total": -1, "max_step": -1}
427
-
428
- def _hook(value: int, total: int, _preview=None, **_kwargs: Any) -> None:
429
- v, t = int(value), int(total)
430
- # New sampler started (different total, or step rewound)
431
- if t != progress_state["prev_total"] or v < progress_state["max_step"]:
432
- progress_state["stage"] += 1
433
- progress_state["prev_total"] = t
434
- progress_state["max_step"] = v
435
- else:
436
- progress_state["max_step"] = max(progress_state["max_step"], v)
437
- _push(
438
- ProgressEvent(
439
- stage=progress_state["stage"],
440
- stage_label="diffusion",
441
- step=v,
442
- total_steps=t,
443
- )
444
- )
445
-
446
- def _worker() -> None:
447
- import comfy.utils
448
-
449
- saved_hook = getattr(comfy.utils, "PROGRESS_BAR_HOOK", None)
450
- try:
451
- # Workflow is already API-format (saved from ComfyUI editor's
452
- # "Save (API Format)"), so it can be handed to PromptExecutor
453
- # directly. The execute_outputs list pinpoints which output
454
- # nodes to evaluate — we let PromptExecutor walk the whole
455
- # graph by passing every output-class node id.
456
- output_ids = [
457
- nid for nid, n in workflow.items()
458
- if n.get("class_type", "").startswith(("SaveVideo", "VHS_VideoCombine", "PreviewAudio", "CreateVideo"))
459
- ]
460
- print(
461
- f"[backend] submitting workflow: {len(workflow)} nodes, "
462
- f"output_ids={output_ids}",
463
- file=sys.stderr,
464
- flush=True,
465
- )
466
- # Use the public setter; it writes the same global the
467
- # ProgressBar class reads, but is the documented API.
468
- comfy.utils.set_progress_bar_global_hook(_hook)
469
- # _execute_workflow is module-level and decorated with a
470
- # @spaces.GPU(duration=callable) on Spaces — the callable
471
- # estimates per-call timeout from (mode, preset, frames) so
472
- # light calls get fast queue priority while heavy ones reserve
473
- # real headroom. Off-Spaces it's a plain call.
474
- video_path = _execute_workflow(
475
- self._executor, workflow, output_ids, mode, preset, duration_multiplier, gpu_duration, progress,
476
- )
477
- # Fallback: if history_result didn't surface a path (rare on
478
- # Spaces — happens when ZeroGPU's subprocess boundary drops
479
- # mutated state), scan the output dir for the newest mp4
480
- # written within the last 60 s.
481
- if not video_path:
482
- video_path = _newest_recent_video(self._comfy_dir / "output") or ""
483
- print(
484
- f"[backend] workflow done; video_path={video_path!r}",
485
- file=sys.stderr,
486
- flush=True,
487
- )
488
- _push(OutputEvent(video_path=video_path))
489
- except Exception as exc:
490
- tb_text = tb_mod.format_exc()
491
- print(f"[backend] worker exception:\n{tb_text}", file=sys.stderr, flush=True)
492
- _push(
493
- ErrorEvent(
494
- category=_classify(exc),
495
- message=str(exc),
496
- traceback=tb_text,
497
- )
498
- )
499
- finally:
500
- comfy.utils.set_progress_bar_global_hook(saved_hook)
501
- _free_memory()
502
- _push(None) # sentinel: stop the consumer
503
-
504
- # ZeroGPU's @spaces.GPU wrapper reads the user's identity from the
505
- # current Gradio request via gradio.context.LocalContext.request,
506
- # which is a contextvar. Plain threads don't inherit contextvars, so
507
- # without this the worker sees request=None, X-IP-Token never gets
508
- # read, and `client.schedule` raises "Space app has reached its GPU
509
- # limit" (token-is-None branch in spaces/zero/client.py:138). Copy
510
- # the calling task's context so the request — and therefore the Pro
511
- # user's quota attribution — survives the thread boundary.
512
- ctx = contextvars.copy_context()
513
- thread = threading.Thread(target=ctx.run, args=(_worker,), daemon=True)
514
- thread.start()
515
-
516
- while True:
517
- event = await queue.get()
518
- if event is None:
519
- return
520
- yield event
521
-
522
- def interrupt(self) -> None:
523
- """Cancel the currently running workflow (if any)."""
524
- try:
525
- import comfy.model_management as mm
526
-
527
- mm.interrupt_current_processing()
528
- except Exception:
529
- pass
530
-
531
-
532
- def _classify(exc: Exception) -> str:
533
- name = type(exc).__name__.lower()
534
- msg = str(exc).lower()
535
- if "outofmemory" in name or "cuda out of memory" in msg:
536
- return "oom"
537
- if "expired zerogpu proxy token" in msg or "expired" in msg and "token" in msg:
538
- return "expired_token"
539
- if "illegal duration" in msg:
540
- return "illegal_duration"
541
- if "unlogged user" in msg:
542
- return "unlogged"
543
- if "exceeded your" in msg and "gpu" in msg:
544
- return "quota_exceeded"
545
- # ZeroGPU enforces the @spaces.GPU(duration=N) cap and re-raises as
546
- # gradio.exceptions.Error('GPU task aborted').
547
- if "gpu task aborted" in msg or ("gpu" in msg and "aborted" in msg):
548
- return "gpu_timeout"
549
- if "interrupt" in name:
550
- return "interrupt"
551
- return "execution"
552
-
553
-
554
- def _free_memory() -> None:
555
- """Free VRAM after a workflow finishes (success or failure)."""
556
- try:
557
- import comfy.model_management as mm
558
-
559
- mm.unload_all_models()
560
- except Exception:
561
- pass
562
- try:
563
- import torch
564
-
565
- if torch.backends.mps.is_available():
566
- torch.mps.empty_cache()
567
- except Exception:
568
- pass
569
- try:
570
- import torch
571
-
572
- if torch.cuda.is_available():
573
- torch.cuda.empty_cache()
574
- except Exception:
575
- pass
576
-
577
-
578
- def _newest_recent_video(output_root: pathlib.Path, within_seconds: float = 60.0) -> str | None:
579
- """Filesystem fallback: return the newest .mp4/.webm/.mov under *output_root*
580
- that was modified within the last *within_seconds* seconds.
581
-
582
- Used when the executor's history_result didn't surface a path — typically
583
- happens when ZeroGPU's subprocess boundary drops the mutation. The disk
584
- is shared, so the file is there even when the in-memory state isn't.
585
- """
586
- import time
587
-
588
- if not output_root.exists():
589
- return None
590
- cutoff = time.time() - within_seconds
591
- candidates: list[tuple[float, pathlib.Path]] = []
592
- for ext in (".mp4", ".webm", ".mov"):
593
- for p in output_root.rglob(f"*{ext}"):
594
- try:
595
- mtime = p.stat().st_mtime
596
- except OSError:
597
- continue
598
- if mtime >= cutoff:
599
- candidates.append((mtime, p))
600
- if not candidates:
601
- return None
602
- candidates.sort(reverse=True)
603
- return str(candidates[0][1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
chain_injectors/__init__.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import importlib
3
+ import pkgutil
4
+
5
+ def discover_injectors():
6
+ injectors = {}
7
+ package_dir = os.path.dirname(__file__)
8
+
9
+ for _, module_name, is_pkg in pkgutil.iter_modules([package_dir]):
10
+ if is_pkg or module_name.startswith('_'):
11
+ continue
12
+
13
+ full_module_name = f"chain_injectors.{module_name}"
14
+ try:
15
+ module = importlib.import_module(full_module_name)
16
+ if hasattr(module, 'inject') and callable(module.inject):
17
+ feature_name = getattr(module, 'FEATURE_NAME', None)
18
+ if not feature_name:
19
+ feature_name = module_name[:-9] if module_name.endswith('_injector') else module_name
20
+
21
+ chain_type = getattr(module, 'CHAIN_TYPE', None)
22
+ if not chain_type:
23
+ chain_type = f"dynamic_{feature_name}_chains"
24
+
25
+ injectors[chain_type] = module.inject
26
+ else:
27
+ print(f"Warning: Module '{full_module_name}' does not have a callable 'inject' function.")
28
+ except Exception as e:
29
+ print(f"Error importing injector module '{full_module_name}': {e}")
30
+
31
+ return injectors
32
+
33
+ def get_registered_features():
34
+ features = {}
35
+ package_dir = os.path.dirname(__file__)
36
+
37
+ for _, module_name, is_pkg in pkgutil.iter_modules([package_dir]):
38
+ if is_pkg or module_name.startswith('_'):
39
+ continue
40
+
41
+ feature_name = module_name[:-9] if module_name.endswith('_injector') else module_name
42
+ full_module_name = f"chain_injectors.{module_name}"
43
+ chain_type = f"dynamic_{feature_name}_chains"
44
+
45
+ features[feature_name] = {
46
+ 'module': full_module_name,
47
+ 'chain_type': chain_type
48
+ }
49
+
50
+ return features
chain_injectors/lora_injector.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from copy import deepcopy
2
+
3
+ def inject(assembler, chain_definition, chain_items):
4
+ if not chain_items:
5
+ return
6
+
7
+ start_node_name = chain_definition.get('start')
8
+ start_node_id = None
9
+ if start_node_name:
10
+ if start_node_name not in assembler.node_map:
11
+ print(f"Warning: Start node '{start_node_name}' for dynamic LoRA chain not found. Skipping chain.")
12
+ return
13
+ start_node_id = assembler.node_map[start_node_name]
14
+
15
+ output_map = chain_definition.get('output_map', {})
16
+ current_connections = {}
17
+ for key, type_name in output_map.items():
18
+ if ':' in str(key):
19
+ node_name, idx_str = key.split(':')
20
+ if node_name not in assembler.node_map:
21
+ print(f"Warning: Node '{node_name}' in chain's output_map not found. Skipping.")
22
+ continue
23
+ node_id = assembler.node_map[node_name]
24
+ start_output_idx = int(idx_str)
25
+ current_connections[type_name] = [node_id, start_output_idx]
26
+ elif start_node_id:
27
+ start_output_idx = int(key)
28
+ current_connections[type_name] = [start_node_id, start_output_idx]
29
+ else:
30
+ print(f"Warning: LoRA chain has no 'start' node defined, and an output_map key '{key}' is not in 'node:index' format. Skipping this connection.")
31
+
32
+
33
+ input_map = chain_definition.get('input_map', {})
34
+ chain_output_map = chain_definition.get('template_output_map', { "0": "model", "1": "clip" })
35
+
36
+ for item_data in chain_items:
37
+ template_name = chain_definition['template']
38
+ template = assembler._get_node_template(template_name)
39
+ node_data = deepcopy(template)
40
+
41
+ for param_name, value in item_data.items():
42
+ if param_name in node_data['inputs']:
43
+ node_data['inputs'][param_name] = value
44
+
45
+ if 'scale' in item_data:
46
+ scale_val = item_data['scale']
47
+ if 'strength_model' in node_data['inputs'] and (node_data['inputs']['strength_model'] is None or node_data['inputs']['strength_model'] == 1.0):
48
+ node_data['inputs']['strength_model'] = scale_val
49
+ if 'strength_clip' in node_data['inputs'] and (node_data['inputs']['strength_clip'] is None or node_data['inputs']['strength_clip'] == 1.0):
50
+ node_data['inputs']['strength_clip'] = scale_val
51
+ if 'lora_value' in item_data and ('lora_name' in node_data['inputs'] and not node_data['inputs']['lora_name']):
52
+ node_data['inputs']['lora_name'] = item_data['lora_value']
53
+
54
+ for type_name, input_name in input_map.items():
55
+ if type_name in current_connections:
56
+ node_data['inputs'][input_name] = current_connections[type_name]
57
+
58
+ new_node_id = assembler._get_unique_id()
59
+ assembler.workflow[new_node_id] = node_data
60
+
61
+ for idx_str, type_name in chain_output_map.items():
62
+ current_connections[type_name] = [new_node_id, int(idx_str)]
63
+
64
+ end_input_map = chain_definition.get('end_input_map', {})
65
+ for type_name, targets in end_input_map.items():
66
+ if type_name in current_connections:
67
+ if not isinstance(targets, list):
68
+ targets = [targets]
69
+
70
+ for target_str in targets:
71
+ end_node_name, end_input_name = target_str.split(':')
72
+ if end_node_name in assembler.node_map:
73
+ end_node_id = assembler.node_map[end_node_name]
74
+ assembler.workflow[end_node_id]['inputs'][end_input_name] = current_connections[type_name]
75
+ else:
76
+ print(f"Warning: End node '{end_node_name}' for dynamic chain not found. Skipping connection.")
{tests → comfy_integration}/__init__.py RENAMED
File without changes
comfy_integration/nodes.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import execution
3
+ import server
4
+ from nodes import (
5
+ init_extra_nodes, CheckpointLoaderSimple, EmptyLatentImage, KSampler,
6
+ VAEDecode, VAEDecodeTiled, SaveImage, NODE_CLASS_MAPPINGS, LoadImage, VAEEncode,
7
+ VAEEncodeForInpaint, ImagePadForOutpaint, LatentUpscaleBy, RepeatLatentBatch
8
+ )
9
+
10
+
11
+ def import_custom_nodes() -> None:
12
+ loop = asyncio.new_event_loop()
13
+ asyncio.set_event_loop(loop)
14
+ server_instance = server.PromptServer(loop)
15
+ execution.PromptQueue(server_instance)
16
+
17
+ loop.run_until_complete(init_extra_nodes())
18
+
19
+ import_custom_nodes()
20
+
21
+ CLIPTextEncode = NODE_CLASS_MAPPINGS['CLIPTextEncode']
22
+ CLIPTextEncodeSDXL = NODE_CLASS_MAPPINGS['CLIPTextEncodeSDXL']
23
+ LoraLoader = NODE_CLASS_MAPPINGS['LoraLoader']
24
+ CLIPSetLastLayer = NODE_CLASS_MAPPINGS['CLIPSetLastLayer']
25
+
26
+ if 'EmptyHunyuanImageLatent' in NODE_CLASS_MAPPINGS:
27
+ EmptyHunyuanImageLatent = NODE_CLASS_MAPPINGS['EmptyHunyuanImageLatent']
28
+ else:
29
+ print("⚠️ Warning: 'EmptyHunyuanImageLatent' not found in NODE_CLASS_MAPPINGS. HunyuanImage txt2img may fail if this node is required.")
30
+
31
+ try:
32
+ KSamplerNode = NODE_CLASS_MAPPINGS['KSampler']
33
+ SAMPLER_CHOICES = KSamplerNode.INPUT_TYPES()["required"]["sampler_name"][0]
34
+ SCHEDULER_CHOICES = KSamplerNode.INPUT_TYPES()["required"]["scheduler"][0]
35
+ except Exception:
36
+ print("⚠️ Could not dynamically get sampler/scheduler choices, using fallback list.")
37
+ SAMPLER_CHOICES = ['euler', 'dpmpp_2m_sde_gpu']
38
+ SCHEDULER_CHOICES = ['normal', 'karras']
39
+
40
+ checkpointloadersimple = CheckpointLoaderSimple()
41
+ loraloader = LoraLoader()
42
+
43
+
44
+ print("✅ ComfyUI custom nodes and class mappings are ready.")
comfy_integration/setup.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import shutil
4
+
5
+ from core.settings import *
6
+
7
+ def move_and_overwrite(src, dst):
8
+ if os.path.isdir(src):
9
+ os.makedirs(dst, exist_ok=True)
10
+ for item in os.listdir(src):
11
+ s_child = os.path.join(src, item)
12
+ d_child = os.path.join(dst, item)
13
+ move_and_overwrite(s_child, d_child)
14
+ elif os.path.isfile(src):
15
+ if os.path.exists(dst):
16
+ try:
17
+ os.remove(dst)
18
+ except OSError:
19
+ pass
20
+ shutil.move(src, dst)
21
+
22
+ def initialize_comfyui():
23
+ APP_DIR = sys.path[0]
24
+ COMFYUI_TEMP_DIR = "ComfyUI_temp"
25
+
26
+ print("--- Cloning ComfyUI Repository ---")
27
+ if not os.path.exists(COMFYUI_TEMP_DIR):
28
+ os.system(f"git clone https://github.com/comfy-Org/ComfyUI {COMFYUI_TEMP_DIR}")
29
+ print("✅ ComfyUI repository cloned.")
30
+ else:
31
+ print("✅ ComfyUI repository already exists.")
32
+
33
+ print(f"--- Merging ComfyUI from '{COMFYUI_TEMP_DIR}' to '{APP_DIR}' ---")
34
+ for item in os.listdir(COMFYUI_TEMP_DIR):
35
+ src_path = os.path.join(COMFYUI_TEMP_DIR, item)
36
+ dst_path = os.path.join(APP_DIR, item)
37
+ if item == '.git':
38
+ continue
39
+ move_and_overwrite(src_path, dst_path)
40
+
41
+ try:
42
+ shutil.rmtree(COMFYUI_TEMP_DIR)
43
+ print("✅ ComfyUI merged and temporary directory removed.")
44
+ except OSError as e:
45
+ print(f"⚠️ Could not remove temporary directory '{COMFYUI_TEMP_DIR}': {e}")
46
+
47
+
48
+
49
+
50
+ print(f"✅ Current working directory is: {os.getcwd()}")
51
+
52
+ import comfy.model_management
53
+ print("--- Environment Ready ---")
54
+
55
+ print("✅ ComfyUI initialized with default attention mechanism.")
56
+
57
+ try:
58
+ import comfy.sd
59
+ orig_vae_init = comfy.sd.VAE.__init__
60
+ def patched_vae_init(self, *args, **kwargs):
61
+ orig_vae_init(self, *args, **kwargs)
62
+ self.process_output = lambda image: (image.clone() if getattr(image, 'is_inference', False) else image).add_(1.0).div_(2.0).clamp_(0.0, 1.0)
63
+ comfy.sd.VAE.__init__ = patched_vae_init
64
+ print("✅ Applied VAE process_output inference tensor safety patch.")
65
+ except Exception as e:
66
+ print(f"⚠️ Could not patch VAE process_output: {e}")
67
+
68
+ for dir_path in CATEGORY_TO_DIR_MAP.values():
69
+ os.makedirs(os.path.join(APP_DIR, dir_path), exist_ok=True)
70
+
71
+ os.makedirs(os.path.join(APP_DIR, INPUT_DIR), exist_ok=True)
72
+ os.makedirs(os.path.join(APP_DIR, OUTPUT_DIR), exist_ok=True)
73
+
74
+ print("✅ All required model directories are present.")
{tools → core}/__init__.py RENAMED
File without changes
core/generation_logic.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import glob
3
+ from typing import Any, Dict, List
4
+ import gradio as gr
5
+ import spaces
6
+
7
+ from core.pipelines.workflow_executor import WorkflowExecutor
8
+ from core.model_manager import model_manager
9
+
10
+ def _extract_required_models(workflow: Dict[str, Any]) -> List[str]:
11
+ required_models = []
12
+ MODEL_KEYS = {'unet_name', 'clip_name', 'vae_name', 'model_name', 'ckpt_name', 'clip_name1', 'clip_name2'}
13
+ MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.bin', '.pth')
14
+
15
+ for node_id, node_data in workflow.items():
16
+ if node_data.get('class_type') == 'LoraLoader':
17
+ continue
18
+ inputs = node_data.get('inputs', {})
19
+ for k, v in inputs.items():
20
+ if isinstance(v, str) and v and k != 'lora_name':
21
+ if k in MODEL_KEYS or any(v.lower().endswith(ext) for ext in MODEL_EXTENSIONS):
22
+ if v not in required_models and not v.startswith('[') and not v.endswith(']'):
23
+ required_models.append(v)
24
+ return required_models
25
+
26
+ def _extract_video_paths(out: Any) -> List[str]:
27
+ video_files = []
28
+
29
+ def process_item(item):
30
+ if item is None:
31
+ return
32
+ if isinstance(item, (list, tuple)):
33
+ for sub in item:
34
+ process_item(sub)
35
+ return
36
+ if isinstance(item, str) and os.path.exists(item):
37
+ if item.lower().endswith(('.mp4', '.webm', '.gif', '.mov', '.mkv')):
38
+ video_files.append(item)
39
+ return
40
+ for attr in ['path', 'saved_path', 'filepath', 'filename', 'full_path']:
41
+ val = getattr(item, attr, None)
42
+ if isinstance(val, str) and os.path.exists(val):
43
+ video_files.append(val)
44
+ return
45
+ elif isinstance(val, str):
46
+ try:
47
+ import folder_paths
48
+ possible = os.path.join(folder_paths.get_output_directory(), val)
49
+ if os.path.exists(possible):
50
+ video_files.append(possible)
51
+ return
52
+ except Exception:
53
+ pass
54
+ if hasattr(item, '__dict__'):
55
+ for k, v in item.__dict__.items():
56
+ if isinstance(v, str) and (v.endswith('.mp4') or v.endswith('.webm') or v.endswith('.gif')):
57
+ if os.path.exists(v):
58
+ video_files.append(v)
59
+ return
60
+ try:
61
+ import folder_paths
62
+ possible = os.path.join(folder_paths.get_output_directory(), v)
63
+ if os.path.exists(possible):
64
+ video_files.append(possible)
65
+ return
66
+ except Exception:
67
+ pass
68
+ if isinstance(item, dict):
69
+ ui_info = item.get("ui", {})
70
+ for key in ["images", "videos", "video"]:
71
+ for sub in ui_info.get(key, []):
72
+ if isinstance(sub, dict) and "filename" in sub:
73
+ fn = sub["filename"]
74
+ subfolder = sub.get("subfolder", "")
75
+ try:
76
+ import folder_paths
77
+ full = os.path.join(folder_paths.get_output_directory(), subfolder, fn) if subfolder else os.path.join(folder_paths.get_output_directory(), fn)
78
+ if os.path.exists(full):
79
+ video_files.append(full)
80
+ return
81
+ except Exception:
82
+ pass
83
+
84
+ process_item(out)
85
+
86
+ if not video_files:
87
+ try:
88
+ import folder_paths
89
+ out_dir = folder_paths.get_output_directory()
90
+ mp4_files = glob.glob(os.path.join(out_dir, "**", "*.mp4"), recursive=True)
91
+ if mp4_files:
92
+ mp4_files.sort(key=os.path.getmtime, reverse=True)
93
+ video_files.append(mp4_files[0])
94
+ except Exception:
95
+ pass
96
+
97
+ return video_files
98
+
99
+ def _execute_ltx_workflow_gpu(workflow: Dict[str, Any]):
100
+ initial_objects = {}
101
+ return WorkflowExecutor.execute_workflow(workflow, initial_objects=initial_objects)
102
+
103
+ def generate_ltx_video_wrapper(process_inputs_func, ui_inputs: dict, progress=gr.Progress(track_tqdm=True)):
104
+ progress(0.1, desc="Assembling LTX-2.5 Video Workflow...")
105
+ batch_count = int(ui_inputs.get('batch_count', 1))
106
+ all_video_files = []
107
+
108
+ for b_idx in range(batch_count):
109
+ batch_msg = f" (Batch {b_idx + 1}/{batch_count})" if batch_count > 1 else ""
110
+ current_ui_inputs = ui_inputs.copy()
111
+
112
+ orig_seed = int(current_ui_inputs.get('seed', -1))
113
+ if orig_seed != -1 and b_idx > 0:
114
+ current_ui_inputs['seed'] = orig_seed + b_idx
115
+
116
+ try:
117
+ workflow, extra_data = process_inputs_func(current_ui_inputs, progress=progress)
118
+ except TypeError:
119
+ workflow, extra_data = process_inputs_func(current_ui_inputs)
120
+
121
+ required_models = _extract_required_models(workflow)
122
+ if required_models:
123
+ progress(0.2, desc=f"Ensuring models are downloaded: {len(required_models)} file(s)...")
124
+ model_manager.ensure_models_downloaded(required_models, progress=progress)
125
+
126
+ zero_gpu_duration = current_ui_inputs.get('zero_gpu_duration', 60)
127
+ try:
128
+ duration = int(zero_gpu_duration)
129
+ except (ValueError, TypeError):
130
+ duration = 60
131
+ if duration <= 0:
132
+ duration = 60
133
+
134
+ progress(0.4, desc=f"Executing LTX-2.5 Workflow on GPU{batch_msg} (ZeroGPU {duration}s)...")
135
+ gpu_runner = spaces.GPU(duration=duration)(_execute_ltx_workflow_gpu)
136
+ raw_output = gpu_runner(workflow)
137
+
138
+ batch_videos = _extract_video_paths(raw_output)
139
+ if batch_videos:
140
+ all_video_files.extend(batch_videos)
141
+ print(f"✅ LTX-2.5 Video generated{batch_msg}")
142
+
143
+ progress(1.0, desc="LTX-2.5 Video Generation Complete!")
144
+ if not all_video_files:
145
+ return None
146
+ return all_video_files[0] if batch_count == 1 else all_video_files
147
+
148
+ # Alias for backwards compatibility
149
+ generate_h3_video_wrapper = generate_ltx_video_wrapper
core/model_manager.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gc
2
+ from typing import List
3
+ import gradio as gr
4
+ from utils.app_utils import _ensure_model_downloaded
5
+ from core.settings import ALL_MODEL_MAP
6
+
7
+ class ModelManager:
8
+ _instance = None
9
+
10
+ def __new__(cls, *args, **kwargs):
11
+ if not cls._instance:
12
+ cls._instance = super(ModelManager, cls).__new__(cls, *args, **kwargs)
13
+ return cls._instance
14
+
15
+ def __init__(self):
16
+ if hasattr(self, 'initialized'):
17
+ return
18
+ self.initialized = True
19
+ print("✅ ModelManager initialized.")
20
+
21
+ def ensure_models_downloaded(self, required_models: List[str], progress):
22
+ print(f"--- [ModelManager] Ensuring models are downloaded: {required_models} ---")
23
+ for i, display_name in enumerate(required_models):
24
+ if progress and hasattr(progress, '__call__'):
25
+ progress(i / max(len(required_models), 1), desc=f"Checking file: {display_name}")
26
+ try:
27
+ _ensure_model_downloaded(display_name, progress)
28
+ except Exception as e:
29
+ raise gr.Error(f"Failed to download model '{display_name}'. Reason: {e}")
30
+ print(f"--- [ModelManager] ✅ All required models are present on disk. ---")
31
+
32
+ model_manager = ModelManager()
core/pipelines/__init__.py ADDED
File without changes
core/pipelines/base_pipeline.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ from typing import List, Any, Dict
3
+ import gradio as gr
4
+ import spaces
5
+ import tempfile
6
+ import imageio
7
+ import numpy as np
8
+ import sys
9
+ import os
10
+
11
+ class BasePipeline(ABC):
12
+ def __init__(self):
13
+ from core.model_manager import model_manager
14
+ self.model_manager = model_manager
15
+
16
+ @abstractmethod
17
+ def get_required_models(self, **kwargs) -> List[str]:
18
+ pass
19
+
20
+ @abstractmethod
21
+ def run(self, *args, progress: gr.Progress, **kwargs) -> Any:
22
+ pass
23
+
24
+ def _ensure_models_downloaded(self, progress: gr.Progress, **kwargs):
25
+ """Ensures model files are downloaded before requesting GPU."""
26
+ required_models = self.get_required_models(**kwargs)
27
+ self.model_manager.ensure_models_downloaded(required_models, progress=progress)
28
+
29
+ def _execute_gpu_logic(self, gpu_function: callable, duration: int, default_duration: int, task_name: str, *args, **kwargs):
30
+ final_duration = default_duration
31
+ try:
32
+ if duration is not None and int(duration) > 0:
33
+ final_duration = int(duration)
34
+ except (ValueError, TypeError):
35
+ print(f"Invalid ZeroGPU duration input for {task_name}. Using default {default_duration}s.")
36
+ pass
37
+
38
+ print(f"Requesting ZeroGPU for {task_name} with duration: {final_duration} seconds.")
39
+ gpu_runner = spaces.GPU(duration=final_duration)(gpu_function)
40
+
41
+ return gpu_runner(*args, **kwargs)
42
+
43
+ def _encode_video_from_frames(self, frames_tensor_cpu: 'torch.Tensor', fps: int, progress: gr.Progress) -> str:
44
+ progress(0.9, desc="Encoding video on CPU...")
45
+ frames_np = (frames_tensor_cpu.numpy() * 255.0).astype(np.uint8)
46
+
47
+ with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_video_file:
48
+ video_path = temp_video_file.name
49
+ writer = imageio.get_writer(video_path, fps=fps, codec='libx264', quality=8)
50
+ for frame in frames_np:
51
+ writer.append_data(frame)
52
+ writer.close()
53
+
54
+ progress(1.0, desc="Done!")
55
+ return video_path
core/pipelines/pipeline_input_processor.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import gradio as gr
3
+ from typing import Dict, Any
4
+
5
+ from core.settings import LORA_DIR
6
+ from utils.app_utils import sanitize_filename, get_lora_path
7
+
8
+ def process_pipeline_inputs(ui_inputs: Dict[str, Any], progress: gr.Progress, workflow_model_type: str = "h3") -> Dict[str, Any]:
9
+ active_loras_for_gpu, active_loras_for_meta = [], []
10
+ lora_data = ui_inputs.get('lora_data', [])
11
+ if lora_data:
12
+ sources, ids, scales, files = lora_data[0::4], lora_data[1::4], lora_data[2::4], lora_data[3::4]
13
+ for i, (source, lora_id, scale, _) in enumerate(zip(sources, ids, scales, files)):
14
+ if scale > 0 and lora_id and lora_id.strip():
15
+ lora_filename = None
16
+ if source == "File":
17
+ lora_filename = sanitize_filename(lora_id)
18
+ local_path = os.path.join(LORA_DIR, lora_filename)
19
+ if not os.path.exists(local_path):
20
+ raise gr.Error(f"Uploaded LoRA file '{lora_id}' no longer exists on server. Please re-upload it.")
21
+ elif source in ("Civitai", "Hugging Face"):
22
+ local_path, status = get_lora_path(source, lora_id, os.environ.get("CIVITAI_API_KEY", ""), progress)
23
+ if local_path:
24
+ lora_filename = os.path.basename(local_path)
25
+ else:
26
+ raise gr.Error(f"Failed to prepare LoRA {lora_id}: {status}")
27
+
28
+ if lora_filename:
29
+ active_loras_for_gpu.append({"lora_name": lora_filename, "strength_model": scale, "strength_clip": scale})
30
+ active_loras_for_meta.append(f"{source} {lora_id}:{scale}")
31
+
32
+ return {
33
+ "active_loras_for_gpu": active_loras_for_gpu,
34
+ "active_loras_for_meta": active_loras_for_meta,
35
+ "temp_files_to_clean": []
36
+ }
core/pipelines/workflow_executor.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+ from collections import defaultdict, deque
4
+ from typing import Dict, Any, List
5
+ from comfy_integration.nodes import NODE_CLASS_MAPPINGS
6
+ from utils.app_utils import get_value_at_index
7
+
8
+ class DummyHidden:
9
+ extra_pnginfo = None
10
+ prompt = None
11
+
12
+ class WorkflowExecutor:
13
+ @staticmethod
14
+ def topological_sort(workflow: Dict[str, Any]) -> List[str]:
15
+ graph = defaultdict(list)
16
+ in_degree = {node_id: 0 for node_id in workflow}
17
+
18
+ for node_id, node_info in workflow.items():
19
+ for input_value in node_info.get('inputs', {}).values():
20
+ if isinstance(input_value, list) and len(input_value) == 2 and isinstance(input_value[0], str):
21
+ source_node_id = input_value[0]
22
+ if source_node_id in workflow:
23
+ graph[source_node_id].append(node_id)
24
+ in_degree[node_id] += 1
25
+
26
+ queue = deque([node_id for node_id, degree in in_degree.items() if degree == 0])
27
+
28
+ sorted_nodes = []
29
+ while queue:
30
+ current_node_id = queue.popleft()
31
+ sorted_nodes.append(current_node_id)
32
+
33
+ for neighbor_node_id in graph[current_node_id]:
34
+ in_degree[neighbor_node_id] -= 1
35
+ if in_degree[neighbor_node_id] == 0:
36
+ queue.append(neighbor_node_id)
37
+
38
+ if len(sorted_nodes) != len(workflow):
39
+ raise RuntimeError("Workflow contains a cycle and cannot be executed.")
40
+
41
+ return sorted_nodes
42
+
43
+ @staticmethod
44
+ def execute_workflow(workflow: Dict[str, Any], initial_objects: Dict[str, Any]):
45
+ with torch.inference_mode():
46
+ computed_outputs = initial_objects
47
+
48
+ sorted_node_ids = WorkflowExecutor.topological_sort(workflow)
49
+
50
+ final_node_id = None
51
+ for node_id in reversed(sorted_node_ids):
52
+ if workflow[node_id].get('class_type') in ['SaveVideo', 'CreateVideo']:
53
+ final_node_id = node_id
54
+ break
55
+
56
+ if final_node_id:
57
+ required_nodes = set()
58
+ nodes_to_visit = [final_node_id]
59
+ while nodes_to_visit:
60
+ curr_id = nodes_to_visit.pop()
61
+ if curr_id in required_nodes:
62
+ continue
63
+ required_nodes.add(curr_id)
64
+ curr_info = workflow.get(curr_id, {})
65
+ for input_val in curr_info.get('inputs', {}).values():
66
+ if isinstance(input_val, list) and len(input_val) == 2 and isinstance(input_val[0], str):
67
+ src_id = input_val[0]
68
+ if src_id in workflow and src_id not in required_nodes:
69
+ nodes_to_visit.append(src_id)
70
+
71
+ sorted_node_ids = [nid for nid in sorted_node_ids if nid in required_nodes]
72
+
73
+ print(f"--- [Workflow Executor] Execution order: {sorted_node_ids}")
74
+
75
+ for node_id in sorted_node_ids:
76
+ if node_id in computed_outputs:
77
+ continue
78
+
79
+ node_info = workflow[node_id]
80
+ class_type = node_info['class_type']
81
+
82
+ is_loader_with_filename = 'Loader' in class_type and any(key.endswith('_name') for key in node_info['inputs'])
83
+ if node_id in initial_objects and is_loader_with_filename:
84
+ continue
85
+
86
+ node_class = NODE_CLASS_MAPPINGS.get(class_type)
87
+ if node_class is None:
88
+ raise RuntimeError(f"Could not find node class '{class_type}'. Is it imported in comfy_integration/nodes.py?")
89
+
90
+ node_instance = node_class()
91
+
92
+ if not hasattr(node_class, 'hidden') or getattr(node_class, 'hidden', None) is None:
93
+ setattr(node_class, 'hidden', DummyHidden())
94
+ if not hasattr(node_instance, 'hidden') or getattr(node_instance, 'hidden', None) is None:
95
+ setattr(node_instance, 'hidden', DummyHidden())
96
+
97
+ kwargs = {}
98
+ for param_name, param_value in node_info['inputs'].items():
99
+ if isinstance(param_value, list) and len(param_value) == 2 and isinstance(param_value[0], str):
100
+ source_node_id, output_index = param_value
101
+ if source_node_id not in computed_outputs:
102
+ raise RuntimeError(f"Workflow integrity error: Output of node {source_node_id} needed for {node_id} but not yet computed.")
103
+
104
+ source_output_tuple = computed_outputs[source_node_id]
105
+ actual_value = get_value_at_index(source_output_tuple, output_index)
106
+ else:
107
+ actual_value = param_value
108
+
109
+ if '.' in param_name:
110
+ parent_key, child_key = param_name.split('.', 1)
111
+ if parent_key not in kwargs or not isinstance(kwargs[parent_key], dict):
112
+ kwargs[parent_key] = {}
113
+ kwargs[parent_key][child_key] = actual_value
114
+ else:
115
+ kwargs[param_name] = actual_value
116
+
117
+ if class_type == 'SaveVideo':
118
+ if 'codec' in kwargs and isinstance(kwargs['codec'], str):
119
+ kwargs['codec'] = {"codec": "h264" if kwargs['codec'] in ["auto", ""] else kwargs['codec']}
120
+ if kwargs.get('format') == 'auto':
121
+ kwargs['format'] = 'mp4'
122
+
123
+ function_name = getattr(node_class, 'FUNCTION')
124
+ execution_method = getattr(node_instance, function_name)
125
+
126
+ result = execution_method(**kwargs)
127
+ computed_outputs[node_id] = result
128
+
129
+ out = computed_outputs.get(final_node_id, {})
130
+ print(f"--- [Workflow Executor] Raw final node output: {type(out)}")
131
+ return out
core/settings.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import yaml
2
+ import os
3
+ from collections import OrderedDict
4
+
5
+ CHECKPOINT_DIR = "models/checkpoints"
6
+ LORA_DIR = "models/loras"
7
+ EMBEDDING_DIR = "models/embeddings"
8
+ CONTROLNET_DIR = "models/controlnet"
9
+ MODEL_PATCHES_DIR = "models/model_patches"
10
+ DIFFUSION_MODELS_DIR = "models/diffusion_models"
11
+ VAE_DIR = "models/vae"
12
+ TEXT_ENCODERS_DIR = "models/text_encoders"
13
+ STYLE_MODELS_DIR = "models/style_models"
14
+ CLIP_VISION_DIR = "models/clip_vision"
15
+ IPADAPTER_DIR = "models/ipadapter"
16
+ IPADAPTER_FLUX_DIR = "models/ipadapter-flux"
17
+ LATENT_UPSCALE_MODELS_DIR = "models/latent_upscale_models"
18
+ INPUT_DIR = "input"
19
+ OUTPUT_DIR = "output"
20
+
21
+ CATEGORY_TO_DIR_MAP = {
22
+ "diffusion_models": DIFFUSION_MODELS_DIR,
23
+ "text_encoders": TEXT_ENCODERS_DIR,
24
+ "vae": VAE_DIR,
25
+ "latent_upscale_models": LATENT_UPSCALE_MODELS_DIR,
26
+ "checkpoints": CHECKPOINT_DIR,
27
+ "loras": LORA_DIR,
28
+ "controlnet": CONTROLNET_DIR,
29
+ "model_patches": MODEL_PATCHES_DIR,
30
+ "embeddings": EMBEDDING_DIR,
31
+ "style_models": STYLE_MODELS_DIR,
32
+ "clip_vision": CLIP_VISION_DIR,
33
+ "ipadapter": IPADAPTER_DIR,
34
+ "ipadapter-flux": IPADAPTER_FLUX_DIR
35
+ }
36
+
37
+ _PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
38
+ _FILE_LIST_PATH = os.path.join(_PROJECT_ROOT, 'yaml', 'file_list.yaml')
39
+ _CONSTANTS_PATH = os.path.join(_PROJECT_ROOT, 'yaml', 'constants.yaml')
40
+
41
+ def load_constants_from_yaml(filepath=_CONSTANTS_PATH):
42
+ if not os.path.exists(filepath):
43
+ print(f"Warning: Constants file not found at {filepath}. Using fallback values.")
44
+ return {}
45
+ with open(filepath, 'r', encoding='utf-8') as f:
46
+ return yaml.safe_load(f)
47
+
48
+ def load_file_download_map(filepath=_FILE_LIST_PATH):
49
+ if not os.path.exists(filepath):
50
+ raise FileNotFoundError(f"The file list (for downloads) was not found at: {filepath}")
51
+
52
+ with open(filepath, 'r', encoding='utf-8') as f:
53
+ file_list_data = yaml.safe_load(f)
54
+
55
+ download_info_map = {}
56
+ for category, files in file_list_data.get('file', {}).items():
57
+ if isinstance(files, list):
58
+ for file_info in files:
59
+ if 'filename' in file_info:
60
+ file_info['category'] = category
61
+ download_info_map[file_info['filename']] = file_info
62
+ return download_info_map
63
+
64
+ try:
65
+ ALL_FILE_DOWNLOAD_MAP = load_file_download_map()
66
+
67
+ category_to_model_type = {
68
+ "diffusion_models": "UNET",
69
+ "text_encoders": "TEXT_ENCODER",
70
+ "vae": "VAE",
71
+ "latent_upscale_models": "LATENT_UPSCALE_MODEL",
72
+ "checkpoints": "SDXL",
73
+ "loras": "LORA",
74
+ "controlnet": "CONTROLNET",
75
+ "model_patches": "MODEL_PATCH",
76
+ "style_models": "STYLE",
77
+ "clip_vision": "CLIP_VISION",
78
+ "ipadapter": "IPADAPTER",
79
+ "ipadapter-flux": "IPADAPTER_FLUX"
80
+ }
81
+
82
+ MODEL_MAP_CHECKPOINT = OrderedDict()
83
+ ALL_MODEL_MAP = OrderedDict()
84
+
85
+ for filename, file_info in ALL_FILE_DOWNLOAD_MAP.items():
86
+ category = file_info.get('category')
87
+ repo_id = file_info.get('repo_id', '')
88
+ model_type = category_to_model_type.get(category, 'UNKNOWN')
89
+
90
+ model_tuple = (repo_id, filename, model_type, "latent", category)
91
+ ALL_MODEL_MAP[filename] = model_tuple
92
+
93
+ if category == 'checkpoints':
94
+ MODEL_MAP_CHECKPOINT[filename] = model_tuple
95
+
96
+ MODEL_TYPE_MAP = {k: v[2] for k, v in ALL_MODEL_MAP.items()}
97
+
98
+ ARCH_CATEGORIES_MAP = {}
99
+ for display_name, info in MODEL_MAP_CHECKPOINT.items():
100
+ arch = info[2]
101
+ cat = info[4] if len(info) > 4 else None
102
+ if arch not in ARCH_CATEGORIES_MAP:
103
+ ARCH_CATEGORIES_MAP[arch] = []
104
+ if cat and cat not in ARCH_CATEGORIES_MAP[arch]:
105
+ ARCH_CATEGORIES_MAP[arch].append(cat)
106
+
107
+ except Exception as e:
108
+ print(f"FATAL: Could not load file download map from YAML. Error: {e}")
109
+ ALL_FILE_DOWNLOAD_MAP = {}
110
+ MODEL_MAP_CHECKPOINT, ALL_MODEL_MAP = OrderedDict(), OrderedDict()
111
+ MODEL_TYPE_MAP = {}
112
+ ARCH_CATEGORIES_MAP = {}
113
+
114
+ try:
115
+ _constants = load_constants_from_yaml()
116
+ MAX_LORAS = _constants.get('MAX_LORAS', 5)
117
+ MAX_EMBEDDINGS = _constants.get('MAX_EMBEDDINGS', 5)
118
+ MAX_CONDITIONINGS = _constants.get('MAX_CONDITIONINGS', 10)
119
+ MAX_CONTROLNETS = _constants.get('MAX_CONTROLNETS', 5)
120
+ MAX_IPADAPTERS = _constants.get('MAX_IPADAPTERS', 5)
121
+ LORA_SOURCE_CHOICES = _constants.get('LORA_SOURCE_CHOICES', ["Hugging Face", "Civitai", "File"])
122
+ RESOLUTION_MAP = _constants.get('RESOLUTION_MAP', {})
123
+ MULTIPLIERS_MAP = _constants.get('MULTIPLIERS_MAP', {})
124
+ ARCHITECTURES_CONFIG = {"architectures": {}, "architecture_order": []}
125
+ FEATURES_CONFIG = {"default": {"enabled_chains": ["lora"]}}
126
+ MODEL_DEFAULTS_CONFIG = {"Default": {}}
127
+ except Exception as e:
128
+ print(f"FATAL: Could not load constants from YAML. Error: {e}")
129
+ MAX_LORAS, MAX_EMBEDDINGS, MAX_CONDITIONINGS, MAX_CONTROLNETS, MAX_IPADAPTERS = 5, 5, 10, 5, 5
130
+ LORA_SOURCE_CHOICES = ["Hugging Face", "Civitai", "File"]
131
+ RESOLUTION_MAP = {}
132
+ MULTIPLIERS_MAP = {}
133
+ ARCHITECTURES_CONFIG = {"architectures": {}, "architecture_order": []}
134
+ FEATURES_CONFIG = {"default": {"enabled_chains": ["lora"]}}
135
+ MODEL_DEFAULTS_CONFIG = {"Default": {}}
core/shared_state.py ADDED
@@ -0,0 +1 @@
 
 
1
+ INVALID_MODEL_URLS = {}
core/workflow_assembler.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import yaml
2
+ import os
3
+ import re
4
+ import importlib
5
+ from copy import deepcopy
6
+ from comfy_integration.nodes import NODE_CLASS_MAPPINGS
7
+ from chain_injectors import discover_injectors, get_registered_features
8
+ from core.settings import FEATURES_CONFIG
9
+
10
+ class WorkflowAssembler:
11
+ def __init__(self, recipe_path, dynamic_values=None):
12
+ self.base_path = os.path.dirname(recipe_path)
13
+ self.dynamic_values = dynamic_values or {}
14
+ self.node_counter = 0
15
+ self.workflow = {}
16
+ self.node_map = {}
17
+
18
+ model_type = self.dynamic_values.get('model_type')
19
+ self._load_injector_config(model_type=model_type)
20
+
21
+ self.recipe = self._load_and_merge_recipe(os.path.basename(recipe_path), self.dynamic_values)
22
+
23
+ def _load_injector_config(self, model_type=None):
24
+ self.global_injectors = discover_injectors()
25
+ registered_features = get_registered_features()
26
+
27
+ order = []
28
+ if model_type and model_type in FEATURES_CONFIG:
29
+ enabled_features = FEATURES_CONFIG[model_type].get('enabled_chains', [])
30
+ for feat in enabled_features:
31
+ if feat in registered_features:
32
+ chain_key = registered_features[feat]['chain_type']
33
+ else:
34
+ chain_key = f"dynamic_{feat}_chains"
35
+ if chain_key in self.global_injectors and chain_key not in order:
36
+ order.append(chain_key)
37
+
38
+ for chain_key in self.global_injectors.keys():
39
+ if chain_key not in order:
40
+ order.append(chain_key)
41
+
42
+ self.injector_order = order
43
+
44
+ def _get_unique_id(self):
45
+ self.node_counter += 1
46
+ return str(self.node_counter)
47
+
48
+ def _get_node_template(self, class_type):
49
+ if class_type not in NODE_CLASS_MAPPINGS:
50
+ raise ValueError(f"Node class '{class_type}' not found. Ensure it's correctly imported in comfy_integration/nodes.py.")
51
+
52
+ node_class = NODE_CLASS_MAPPINGS[class_type]
53
+ input_types = node_class.INPUT_TYPES()
54
+
55
+ template = {
56
+ "inputs": {},
57
+ "class_type": class_type,
58
+ "_meta": {"title": node_class.NODE_NAME if hasattr(node_class, 'NODE_NAME') else class_type}
59
+ }
60
+
61
+ all_inputs = {**input_types.get('required', {}), **input_types.get('optional', {})}
62
+ for name, details in all_inputs.items():
63
+ config = details[1] if len(details) > 1 and isinstance(details[1], dict) else {}
64
+ template["inputs"][name] = config.get("default")
65
+
66
+ return template
67
+
68
+ def _load_and_merge_recipe(self, recipe_filename, dynamic_values, search_context_dir=None):
69
+ search_path = search_context_dir or self.base_path
70
+ recipe_path_to_use = os.path.join(search_path, recipe_filename)
71
+
72
+ if not os.path.exists(recipe_path_to_use):
73
+ raise FileNotFoundError(f"Recipe file not found: {recipe_path_to_use}")
74
+
75
+ with open(recipe_path_to_use, 'r', encoding='utf-8') as f:
76
+ content = f.read()
77
+
78
+ for key, value in dynamic_values.items():
79
+ if value is not None:
80
+ content = content.replace(f"{{{{ {key} }}}}", str(value))
81
+
82
+ main_recipe = yaml.safe_load(content)
83
+
84
+ merged_recipe = {'nodes': {}, 'connections': [], 'ui_map': {}}
85
+ for key in self.injector_order:
86
+ if key.startswith('dynamic_'):
87
+ merged_recipe[key] = {}
88
+
89
+ parent_recipe_dir = os.path.dirname(recipe_path_to_use)
90
+ for import_path_template in main_recipe.get('imports', []):
91
+ import_path = import_path_template
92
+ for key, value in dynamic_values.items():
93
+ if value is not None:
94
+ import_path = import_path.replace(f"{{{{ {key} }}}}", str(value))
95
+
96
+ try:
97
+ imported_recipe = self._load_and_merge_recipe(import_path, dynamic_values, search_context_dir=parent_recipe_dir)
98
+ merged_recipe['nodes'].update(imported_recipe.get('nodes', {}))
99
+ merged_recipe['connections'].extend(imported_recipe.get('connections', []))
100
+ merged_recipe['ui_map'].update(imported_recipe.get('ui_map', {}))
101
+ for key in self.injector_order:
102
+ if key in imported_recipe and key.startswith('dynamic_'):
103
+ merged_recipe[key].update(imported_recipe.get(key, {}))
104
+ except FileNotFoundError:
105
+ print(f"Warning: Optional recipe partial '{import_path}' not found. Skipping.")
106
+
107
+ merged_recipe['nodes'].update(main_recipe.get('nodes', {}))
108
+ merged_recipe['connections'].extend(main_recipe.get('connections', []))
109
+ merged_recipe['ui_map'].update(main_recipe.get('ui_map', {}))
110
+ for key in self.injector_order:
111
+ if key in main_recipe and key.startswith('dynamic_'):
112
+ merged_recipe[key].update(main_recipe.get(key, {}))
113
+
114
+ return merged_recipe
115
+
116
+ def add_node(self, class_type: str, inputs: dict = None, title: str = None) -> str:
117
+ template = self._get_node_template(class_type)
118
+ node_data = deepcopy(template)
119
+ if title:
120
+ node_data['_meta']['title'] = title
121
+ if inputs:
122
+ for k, v in inputs.items():
123
+ node_data['inputs'][k] = v
124
+ node_id = self._get_unique_id()
125
+ self.workflow[node_id] = node_data
126
+ return node_id
127
+
128
+ def connect(self, from_node: str, from_output_idx: int, to_node: str, to_input_name: str):
129
+ from_id = self.node_map.get(from_node, from_node)
130
+ to_id = self.node_map.get(to_node, to_node)
131
+ if from_id in self.workflow and to_id in self.workflow:
132
+ self.workflow[to_id]['inputs'][to_input_name] = [from_id, int(from_output_idx)]
133
+ else:
134
+ print(f"Warning: Cannot connect '{from_node}' -> '{to_node}'. Node ID not found.")
135
+
136
+ def assemble(self, ui_values):
137
+ self.ui_values = ui_values
138
+ for name, details in self.recipe['nodes'].items():
139
+ if 'class_type' not in details:
140
+ continue
141
+ class_type = details['class_type']
142
+ template = self._get_node_template(class_type)
143
+ node_data = deepcopy(template)
144
+
145
+ unique_id = self._get_unique_id()
146
+ self.node_map[name] = unique_id
147
+
148
+ if 'title' in details:
149
+ node_data['_meta']['title'] = details['title']
150
+
151
+ if 'params' in details:
152
+ for param, value in details['params'].items():
153
+ if '.' in param:
154
+ parent_param, sub_param = param.split('.', 1)
155
+ if parent_param in node_data['inputs']:
156
+ if not isinstance(node_data['inputs'][parent_param], dict):
157
+ node_data['inputs'][parent_param] = {parent_param: node_data['inputs'][parent_param]} if node_data['inputs'][parent_param] else {}
158
+ node_data['inputs'][parent_param][sub_param] = value
159
+ elif param in node_data['inputs']:
160
+ if isinstance(node_data['inputs'][param], dict) and isinstance(value, dict):
161
+ node_data['inputs'][param].update(value)
162
+ else:
163
+ node_data['inputs'][param] = value
164
+
165
+ self.workflow[unique_id] = node_data
166
+
167
+ for ui_key, target in self.recipe.get('ui_map', {}).items():
168
+ if ui_key in ui_values and ui_values[ui_key] is not None:
169
+ target_list = target if isinstance(target, list) else [target]
170
+ for t in target_list:
171
+ if isinstance(t, str) and ':' in t:
172
+ target_name, target_param = t.split(':')
173
+ if target_name in self.node_map:
174
+ self.workflow[self.node_map[target_name]]['inputs'][target_param] = ui_values[ui_key]
175
+
176
+ for conn in self.recipe.get('connections', []):
177
+ if not isinstance(conn.get('to'), str) or not isinstance(conn.get('from'), str):
178
+ continue
179
+ from_name, from_output_idx = conn['from'].split(':')
180
+ to_name, to_input_name = conn['to'].split(':')
181
+
182
+ from_id = self.node_map.get(from_name)
183
+ to_id = self.node_map.get(to_name)
184
+
185
+ if from_id and to_id:
186
+ self.workflow[to_id]['inputs'][to_input_name] = [from_id, int(from_output_idx)]
187
+
188
+ print("--- [Assembler] Applying dynamic injectors ---")
189
+ recipe_chain_types = {key for key in self.recipe if key.startswith('dynamic_')}
190
+ processing_order = [key for key in self.injector_order if key in recipe_chain_types]
191
+
192
+ for chain_type in processing_order:
193
+ injector_func = self.global_injectors.get(chain_type)
194
+ if injector_func:
195
+ for chain_key, chain_def in self.recipe.get(chain_type, {}).items():
196
+ if chain_key in ui_values and ui_values[chain_key]:
197
+ print(f" -> Injecting '{chain_type}' for '{chain_key}'...")
198
+ chain_items = ui_values[chain_key]
199
+ injector_func(self, chain_def, chain_items)
200
+
201
+ print("--- [Assembler] Finished applying injectors ---")
202
+
203
+ return self.workflow
docs/future_improvements.md DELETED
@@ -1,148 +0,0 @@
1
- # Future improvements
2
-
3
- A backlog of optimizations that aren't blocking but would tighten the deploy.
4
- None of these are required for current functionality. Order is rough priority,
5
- not commitment.
6
-
7
- ## Spaces / preload
8
-
9
- ### ~~0. Re-enable `preload_from_hub` via runtime cache mirror~~ — DONE 2026-05-02
10
-
11
- Initial preload deployment failed because HF's build pipeline writes
12
- `~/.cache/huggingface/` as the build user, leaving it read-only for runtime
13
- user 1000. Lazy `hf_hub_download` for non-preloaded files (GGUF, camera LoRAs)
14
- failed with `Permission denied (os error 13)`. `chmod` couldn't help — we
15
- don't own the inode.
16
-
17
- Fix landed in `_bootstrap()`'s `_mirror_preload_hf_cache()`:
18
- - Walks `~/.cache/huggingface/` to a parallel `~/hf-cache-rw/` we own
19
- - Hardlinks `blobs/<sha>` files (zero-copy, shared inode, instant reads)
20
- - Preserves relative snapshot symlinks (resolve within the mirror tree)
21
- - Byte-copies `refs/<branch>` files (HF lib overwrites these on etag check)
22
- - Sets `HF_HOME` + `HF_HUB_CACHE` to the mirror so HF lib uses our writable copy
23
- - Falls back to symlink if `os.link()` returns EXDEV (cross-device)
24
-
25
- Result: preloaded files are instantly available (cache hit on first generate),
26
- non-preloaded files lazy-download into dirs we own (no permission errors).
27
-
28
- ### ~~1. Stop preloading models that aren't referenced by any workflow~~ — DONE 2026-05-02
29
-
30
- Audit on 2026-05-02 showed two `Lightricks/LTX-2.3` files in `preload_from_hub`
31
- that aren't actually referenced by any workflow JSON we ship:
32
-
33
- - `ltx-2.3-22b-dev.safetensors` (~42 GB)
34
- - `ltx-2.3-22b-distilled.safetensors` (~42 GB)
35
-
36
- The active path uses `Kijai/LTX2.3_comfy ltx-2.3-22b-dev_transformer_only_bf16.safetensors`.
37
- Removed both — ~84 GB saved. Forced by HF eviction with `storage limit
38
- exceeded (150G)` when total preload was ~234 GB. Risk: if a future workflow
39
- update reintroduces the Lightricks-side filenames, lazy download takes over.
40
-
41
- ### ~~2. Drop `unsloth/LTX-2.3-GGUF` from preload (~39 GB)~~ — DONE 2026-05-02
42
-
43
- Removed alongside (1). GGUF transformer is the low-VRAM alternative; ZeroGPU
44
- H200 has 70 GB so the BF16 transformer always fits. Lazy-loads on first use
45
- of any preset that wires the GGUF path.
46
-
47
- ### 3. Drop the `Lightricks/LTX-2-19b-LoRA-Camera-Control-Static/Jib-Up/Jib-Down` preload
48
-
49
- Each is ~2 GB. The Power Lora Loader has them all listed but defaults all to
50
- `on: false`, so they only load when the user picks one. Lazy-load is
51
- appropriate. Currently kept in preload because of the 10-entry cap +
52
- "easier to keep what we had".
53
-
54
- ### 4. Auto-generate `preload_from_hub` from `MODEL_REGISTRY`
55
-
56
- Today the README list and `MODEL_REGISTRY` in `models.py` can drift. Build a
57
- small `tools/sync_preload.py` that:
58
-
59
- 1. Reads `MODEL_REGISTRY`
60
- 2. Walks the workflow JSONs to find which entries are actually referenced
61
- 3. Sorts referenced entries by size (using `huggingface_hub` `repo_info`)
62
- 4. Picks the top N entries that fit in the 10-cap
63
- 5. Writes them back into the README YAML
64
-
65
- Run as a pre-commit or CI step.
66
-
67
- ### 5. Bake custom-node clones into the build via `requirements.txt` git installs
68
-
69
- We currently `git clone` 10 custom-node repos in `_bootstrap()` at runtime.
70
- That's ~30 s of cold start. Some custom nodes ship as pip-installable; for
71
- the others, we could write a small `tools/install_custom_nodes.py` that
72
- runs at build time (via `pip install --no-deps` against git URLs) so the
73
- repos land in the image instead of being fetched at boot.
74
-
75
- Tradeoff: Spaces' build pipeline runs the gradio SDK Dockerfile which we
76
- don't control directly. The custom-node clone has to happen at runtime
77
- unless we can move it into the standard `requirements.txt` build step.
78
-
79
- ### 6. Persistent storage add-on as the "$25/mo button"
80
-
81
- If iteration speed becomes the binding constraint, the persistent storage
82
- add-on (Spaces > Settings) at $25/mo for 150 GB makes everything just work
83
- — `/data` is writable, models live there forever, no preload dance.
84
- Sketched approach: `HF_HOME=/data/hf-cache` env var + `_bootstrap()` mkdir
85
- fallback. One-line code change.
86
-
87
- ## Workflow / runtime
88
-
89
- ### 7. Move ComfyUI custom-node `requirements.txt` install to build time
90
-
91
- Bootstrap currently `pip install`s each custom node's requirements at
92
- runtime. Most are no-ops (deps already in our top-level `requirements.txt`)
93
- but the `pip install --quiet` calls still take a few seconds each. Could
94
- audit and just merge them into the top-level `requirements.txt`.
95
-
96
- ### 8. Clean up `nodes_replacements.py` warning
97
-
98
- ComfyUI core at our pinned commit (`eb0686bb`) emits
99
- `'function' object has no attribute 'register'` because the node-replacement
100
- API surface is incomplete at that SHA. Bumping `COMFYUI_COMMIT` to a newer
101
- tag should silence it. Pure cosmetic — no functional impact.
102
-
103
- ### 9. Auto-close drawer when user navigates away from header
104
-
105
- Currently relies on document-level click listener. Works but has a
106
- microsecond race when the click target is between elements. Could use
107
- `pointerleave` on the drawer instead.
108
-
109
- ## Cost-of-running
110
-
111
- ### 10. Trim ZeroGPU duration cap
112
-
113
- Currently `@spaces.GPU(duration=300)` reserves 5 min per call. For Fast preset
114
- (distilled 8 steps) actual usage is ~30 s. Could shorten to 120 s — improves
115
- queue priority for the user (per HF docs). Use dynamic duration based on
116
- preset.
117
-
118
- ### 11. Local-perf "low-VRAM" path for style mode (GGUF Q4 transformer)
119
-
120
- Style mode on Apple Silicon runs ~37× slower per sampling step than the other
121
- modes (~596 s/step on Mac vs ~16 s/step for lipsync). Root cause is
122
- architectural — `LTXAddVideoICLoRAGuide` concatenates the source video's
123
- DWPose latents into the noisy target latent, doubling the attention sequence
124
- to ~56 k tokens. Combined with MPS having no flash-attn-2 and the 22B BF16
125
- model approaching the working-memory ceiling, perf collapses on Mac.
126
-
127
- H200 handles this fine (flash-attn-3 + tensor cores + dedicated VRAM ⇒
128
- ~30–60 s end to end on Spaces). So this is fundamentally a Mac/MPS gap, not
129
- a code bug.
130
-
131
- A "Low VRAM" preset that swaps the BF16 transformer for the GGUF Q4
132
- quantized one would reduce per-step memory pressure and may bring local
133
- style perf into the workable range (still slow, but maybe ~60–90 s/step
134
- instead of 600). The GGUF file is already declared in `MODEL_REGISTRY`
135
- (`UnetLoaderGGUF` consumer). What's missing:
136
-
137
- 1. A workflow toggle that swaps `UNETLoader` → `UnetLoaderGGUF` for the main
138
- transformer in style.json (and other modes that benefit).
139
- 2. A UI control on the Advanced accordion: "Low VRAM (GGUF Q4)".
140
- 3. Wire-through in `_style_parameterize` (and friends) to flip the loader
141
- class.
142
- 4. Delete the matching BF16 path nodes when GGUF is selected (or set them
143
- to bypass) so we don't load both.
144
-
145
- Risk: GGUF transformers behave slightly differently from BF16 — output
146
- quality drops, especially for IC-LoRA paths where the dynamic range matters.
147
- Should be opt-in only, never default. Probably v1.1+ scope (it's listed in
148
- "Out of scope for v1" in CLAUDE.md as the GGUF Q4 / Low VRAM preset).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/plans/2026-04-30-ltx23-aio-generator.md DELETED
@@ -1,2932 +0,0 @@
1
- # LTX 2.3 AIO Generator Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Build a Gradio app that wraps the existing ComfyUI LTX 2.3 All-In-One workflow into a polished mode-specific UI, runnable locally (MPS/CUDA) and on Hugging Face Spaces (ZeroGPU, Pro tier).
6
-
7
- **Architecture:** Gradio frontend → workflow JSON parameterizer → bundled ComfyUI in library mode (`comfy.execution.PromptExecutor`). Six mode-specific workflow JSON templates extracted from the master workflow; per-mode `parameterize_fn` translates Gradio inputs into node patches. Same code locally and on Spaces; the only divergence is `@spaces.GPU` decoration and model storage location.
8
-
9
- **Tech Stack:** Python 3.11, Gradio 5.x, `spaces`, `huggingface_hub`, ComfyUI (vendored as git submodule + runtime clone on Spaces) + custom nodes (`ComfyUI-LTXVideo`, `ComfyUI-KJNodes`, `rgthree-comfy`, `ComfyUI-VideoHelperSuite`, `ComfyUI-Custom-Scripts`), pytest, ruff.
10
-
11
- **Spec:** `docs/superpowers/specs/2026-04-30-ltx23-aio-generator-design.md`
12
-
13
- ---
14
-
15
- ## File Map (locked at plan time)
16
-
17
- | File | Created by task | LOC est. | Responsibility |
18
- |---|---|---|---|
19
- | `requirements.txt` | T1 | 15 | Pin Gradio, spaces, huggingface_hub, torch, ruff, pytest. |
20
- | `pyproject.toml` | T1 | 30 | Pytest rootdir + ruff config so flat-layout imports resolve. |
21
- | `setup.sh` | T2 | 50 | Idempotent local bootstrap (venv, submodule, custom nodes, models). |
22
- | `README.md` | T3 | 80 | Spaces front matter + local quickstart + screenshot placeholders. |
23
- | `tests/conftest.py` | T4 | 80 | Fixtures: `master_workflow`, `canonical_inputs`, `fake_hf_cache`, CLI flags. |
24
- | `tools/extract_modes.py` | T5 | 200 | Extract six mode templates from the master workflow JSON. |
25
- | `workflows/{t2v,a2v,i2v,lipsync,keyframe,style}.json` | T6 | (data) | Six mode templates. |
26
- | `workflow.py` | T7–T9 | 120 | `load_template`, `set_input`, `validate`. |
27
- | `modes.py` | T10–T12 | 300 | `Mode` dataclass + `MODE_REGISTRY` (six entries with `parameterize_fn`). |
28
- | `models.py` | T13–T15 | 150 | `MODEL_REGISTRY`, `ensure_models_for_mode`, symlink/download logic. |
29
- | `tools/refresh_models.py` | T16 | 30 | CLI wrapper around `models.ensure_models_for_mode` for all modes. |
30
- | `backend.py` | T17–T20 | 200 | `ComfyUILibraryBackend`, async submit, progress hook, ZeroGPU. |
31
- | `ui.py` | T21–T23 | 200 | `preset_bar`, `status_banner`, `lora_chrome`. |
32
- | `app.py` | T24–T26 | 400 | Gradio `Blocks`, sidebar, mode rendering, generate handler. |
33
- | `.github/workflows/ci.yml` | T27 | 30 | Run L1+L3 tests on push. |
34
- | `.github/workflows/deploy-space.yml` | T28 | 25 | Optional — push to HF Space on main. |
35
-
36
- Total: ~1,800 LOC across 14 files (excluding the ComfyUI submodule, workflow JSON data, and tests).
37
-
38
- ---
39
-
40
- ## Phase 0 — Foundations
41
-
42
- ### Task 1: `requirements.txt`
43
-
44
- **Files:**
45
- - Create: `requirements.txt`
46
-
47
- - [ ] **Step 1: Create `requirements.txt`**
48
-
49
- ```text
50
- gradio>=5.0,<6.0
51
- spaces>=0.30.0
52
- huggingface_hub>=0.27.0
53
- torch>=2.4.0
54
- torchvision
55
- torchaudio
56
- numpy
57
- Pillow
58
- einops
59
- safetensors
60
- tqdm
61
-
62
- # Dev / test
63
- pytest>=8.0
64
- pytest-asyncio>=0.23
65
- ruff>=0.5
66
- ```
67
-
68
- - [ ] **Step 2: Create `pyproject.toml`** so pytest finds the flat-layout modules and ruff rules are pinned
69
-
70
- ```toml
71
- [tool.pytest.ini_options]
72
- pythonpath = ["."]
73
- markers = [
74
- "gpu: marks tests that need a GPU (use --gpu to enable)",
75
- ]
76
-
77
- [tool.ruff]
78
- line-length = 100
79
- target-version = "py311"
80
-
81
- [tool.ruff.lint]
82
- select = ["E", "F", "I", "B", "UP"]
83
- ignore = ["E501"] # line length is enforced by formatter, not linter
84
-
85
- [tool.ruff.lint.per-file-ignores]
86
- "tests/*" = ["E402"] # imports inside test functions are fine
87
- ```
88
-
89
- - [ ] **Step 3: Verify both files parse**
90
-
91
- Run: `python3.11 -m pip install --dry-run -r requirements.txt 2>&1 | head -5`
92
- Expected: pip resolves package names without "ERROR: Invalid requirement" lines (network errors are fine — we're checking syntax).
93
-
94
- Run: `python3.11 -c "import tomllib; print(list(tomllib.loads(open('pyproject.toml').read()).keys()))"`
95
- Expected: `['tool']`
96
-
97
- - [ ] **Step 4: Commit**
98
-
99
- ```bash
100
- git add requirements.txt pyproject.toml
101
- git commit -m "chore: pin runtime + dev dependencies and configure pytest/ruff"
102
- ```
103
-
104
- ---
105
-
106
- ### Task 2: `setup.sh`
107
-
108
- **Files:**
109
- - Create: `setup.sh`
110
-
111
- - [ ] **Step 1: Write `setup.sh`**
112
-
113
- ```bash
114
- #!/usr/bin/env bash
115
- set -euo pipefail
116
-
117
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
118
- cd "$REPO_ROOT"
119
-
120
- echo "▶ Creating Python 3.11 venv"
121
- python3.11 -m venv .venv
122
- # shellcheck disable=SC1091
123
- source .venv/bin/activate
124
- pip install -U pip wheel
125
-
126
- echo "▶ Initializing ComfyUI submodule"
127
- git submodule update --init --recursive
128
-
129
- echo "▶ Installing ComfyUI core requirements"
130
- pip install -r comfyui/requirements.txt
131
-
132
- echo "▶ Installing pinned custom nodes"
133
- mkdir -p comfyui/custom_nodes
134
- cd comfyui/custom_nodes
135
- for repo in \
136
- Lightricks/ComfyUI-LTXVideo \
137
- kijai/ComfyUI-KJNodes \
138
- rgthree/rgthree-comfy \
139
- Kosinkadink/ComfyUI-VideoHelperSuite \
140
- pythongosssss/ComfyUI-Custom-Scripts ; do
141
- name="${repo##*/}"
142
- if [[ ! -d "$name" ]]; then
143
- git clone --depth 1 "https://github.com/$repo.git" "$name"
144
- fi
145
- if [[ -f "$name/requirements.txt" ]]; then
146
- pip install -r "$name/requirements.txt"
147
- fi
148
- done
149
- cd "$REPO_ROOT"
150
-
151
- echo "▶ Installing AIO app dependencies"
152
- pip install -r requirements.txt
153
-
154
- echo "▶ Symlinking models from HF cache"
155
- python tools/refresh_models.py || true # ok to fail before tools/ exists
156
-
157
- echo
158
- echo "✓ Setup complete."
159
- echo " Activate venv: source .venv/bin/activate"
160
- echo " Run app: python app.py"
161
- ```
162
-
163
- - [ ] **Step 2: Make executable**
164
-
165
- Run: `chmod +x setup.sh`
166
- Expected: no output, exit 0.
167
-
168
- - [ ] **Step 3: Commit**
169
-
170
- ```bash
171
- git add setup.sh
172
- git commit -m "chore: idempotent setup.sh — venv, submodule, custom nodes, models"
173
- ```
174
-
175
- ---
176
-
177
- ### Task 3: `README.md` with Spaces front matter
178
-
179
- **Files:**
180
- - Modify: `README.md`
181
-
182
- - [ ] **Step 1: Replace the placeholder `README.md`**
183
-
184
- ```markdown
185
- ---
186
- title: LTX 2.3 All-in-One Video Generator
187
- emoji: 🎬
188
- colorFrom: purple
189
- colorTo: blue
190
- sdk: gradio
191
- sdk_version: "5.0"
192
- app_file: app.py
193
- python_version: "3.11"
194
- suggested_hardware: zero-gpu
195
- hf_oauth: false
196
- ---
197
-
198
- # LTX 2.3 All-in-One Video Generator
199
-
200
- A Gradio app for [LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3) wrapping all six modes of the official ComfyUI All-In-One workflow under a single, focused UI. Runs locally on Apple Silicon (MPS) or NVIDIA (CUDA), and deploys to Hugging Face Spaces (ZeroGPU).
201
-
202
- ## Modes
203
-
204
- 1. **Text → Video** (+ optional Audio)
205
- 2. **Audio → Video** (Text + Audio → Video + Audio)
206
- 3. **Image → Video** (+ optional Audio)
207
- 4. **Lipsync** (Image + Audio → Video + Audio)
208
- 5. **First / Last Frame → Video** (keyframe interpolation)
209
- 6. **Style Transfer** (Video → Video, motion control)
210
-
211
- ## Local quickstart
212
-
213
- Requires Python 3.11, ~80 GB free disk for model weights, and ~24 GB+ GPU memory (CUDA) or 32 GB+ unified memory (Apple Silicon).
214
-
215
- ```bash
216
- git clone --recurse-submodules https://github.com/<your-handle>/ltx2.3-AIO-generator
217
- cd ltx2.3-AIO-generator
218
- bash setup.sh
219
- source .venv/bin/activate
220
- python app.py
221
- ```
222
-
223
- The first run downloads ~70 GB of models into your existing `~/.cache/huggingface/hub` (no duplicate copies in this repo) and symlinks them into `comfyui/models/`.
224
-
225
- ## HF Spaces deployment
226
-
227
- This repo is a Gradio Space. The required Pro tier provides ~50 GB persistent `/data` storage and longer per-call ZeroGPU budgets needed for Balanced and Quality presets.
228
-
229
- ```bash
230
- git remote add space https://huggingface.co/spaces/<your-handle>/ltx2.3-aio
231
- git push space main
232
- ```
233
-
234
- ## License
235
-
236
- MIT for the AIO app code. ComfyUI and LTX-2.3 retain their respective licenses.
237
- ```
238
-
239
- - [ ] **Step 2: Commit**
240
-
241
- ```bash
242
- git add README.md
243
- git commit -m "docs: README with Spaces front matter and local quickstart"
244
- ```
245
-
246
- ---
247
-
248
- ### Task 4: `tests/conftest.py` with fixtures
249
-
250
- **Files:**
251
- - Create: `tests/__init__.py` (empty)
252
- - Create: `tests/conftest.py`
253
-
254
- - [ ] **Step 1: Create `tests/__init__.py`** (empty file)
255
-
256
- ```bash
257
- mkdir -p tests
258
- touch tests/__init__.py
259
- ```
260
-
261
- - [ ] **Step 2: Write `tests/conftest.py`**
262
-
263
- ```python
264
- """Shared pytest fixtures and CLI flags."""
265
- import json
266
- import os
267
- import pathlib
268
- from typing import Any
269
-
270
- import pytest
271
-
272
- REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
273
-
274
- DEFAULT_MASTER_WORKFLOW = pathlib.Path(
275
- os.environ.get(
276
- "LTX23_MASTER_WORKFLOW",
277
- pathlib.Path.home() / "Projects/comfyui/user/default/workflows"
278
- / "1. LTX 2.3 All-In-One 260406-05.json",
279
- )
280
- )
281
-
282
-
283
- def pytest_addoption(parser: pytest.Parser) -> None:
284
- parser.addoption("--gpu", action="store_true", help="Run L4 GPU smoke tests.")
285
- parser.addoption(
286
- "--comfy-real",
287
- action="store_true",
288
- help="Use bundled ComfyUI for L2 graph validation (slower).",
289
- )
290
-
291
-
292
- def pytest_collection_modifyitems(
293
- config: pytest.Config, items: list[pytest.Item]
294
- ) -> None:
295
- if not config.getoption("--gpu"):
296
- skip_gpu = pytest.mark.skip(reason="GPU smoke tests skipped (use --gpu)")
297
- for item in items:
298
- if "gpu" in item.keywords:
299
- item.add_marker(skip_gpu)
300
-
301
-
302
- @pytest.fixture(scope="session")
303
- def master_workflow() -> dict[str, Any]:
304
- """The full LTX 2.3 All-In-One workflow JSON (loaded from user's ComfyUI)."""
305
- if not DEFAULT_MASTER_WORKFLOW.exists():
306
- pytest.skip(
307
- f"Master workflow not found at {DEFAULT_MASTER_WORKFLOW}. "
308
- "Set LTX23_MASTER_WORKFLOW env var to its path."
309
- )
310
- return json.loads(DEFAULT_MASTER_WORKFLOW.read_text())
311
-
312
-
313
- @pytest.fixture
314
- def canonical_inputs() -> dict[str, dict[str, Any]]:
315
- """Known-good Gradio input dicts per mode (used by L1/L2 tests)."""
316
- return {
317
- "t2v": {
318
- "prompt": "a tiger walking through a misty forest at dawn, cinematic",
319
- "negative_prompt": "",
320
- "preset": "balanced",
321
- "width": 512,
322
- "height": 768,
323
- "frames": 81,
324
- "fps": 24,
325
- "seed": 42,
326
- "camera_lora": "none",
327
- "camera_strength": 0.8,
328
- "detailer_on": False,
329
- "detailer_strength": 0.5,
330
- },
331
- "i2v": {
332
- "prompt": "the subject turns toward the camera and smiles",
333
- "image": "/tmp/portrait.png",
334
- "preset": "balanced",
335
- "width": 512,
336
- "height": 768,
337
- "frames": 81,
338
- "fps": 24,
339
- "seed": 42,
340
- "camera_lora": "none",
341
- "camera_strength": 0.8,
342
- "detailer_on": True,
343
- "detailer_strength": 0.5,
344
- "ic_lora": "union",
345
- "ic_strength": 0.5,
346
- "pose_on": False,
347
- },
348
- "a2v": {
349
- "prompt": "a dancer moves to the beat in a neon-lit studio",
350
- "audio": "/tmp/track.wav",
351
- "preset": "balanced",
352
- "width": 512,
353
- "height": 768,
354
- "frames": 81,
355
- "fps": 24,
356
- "seed": 42,
357
- "audio_cfg": 7.0,
358
- },
359
- "lipsync": {
360
- "prompt": "the person speaks the audio with natural mouth movement",
361
- "image": "/tmp/portrait.png",
362
- "audio": "/tmp/speech.wav",
363
- "preset": "balanced",
364
- "image_strength": 0.7,
365
- "frames": 81,
366
- "fps": 24,
367
- "seed": 42,
368
- },
369
- "keyframe": {
370
- "prompt": "smooth transition between the two frames",
371
- "first_frame": "/tmp/start.png",
372
- "last_frame": "/tmp/end.png",
373
- "preset": "balanced",
374
- "frames": 81,
375
- "fps": 24,
376
- "seed": 42,
377
- },
378
- "style": {
379
- "prompt": "in the style of a renaissance oil painting",
380
- "input_video": "/tmp/source.mp4",
381
- "preset": "balanced",
382
- "frames": 81,
383
- "fps": 24,
384
- "seed": 42,
385
- "ic_lora": "motion-track",
386
- "ic_strength": 0.5,
387
- },
388
- }
389
-
390
-
391
- @pytest.fixture
392
- def fake_hf_cache(tmp_path: pathlib.Path) -> pathlib.Path:
393
- """A fake ~/.cache/huggingface/hub layout with placeholder files."""
394
- hub = tmp_path / "huggingface" / "hub"
395
- layouts = {
396
- "models--Lightricks--LTX-2.3": [
397
- "ltx-2.3-22b-distilled.safetensors",
398
- "ltx-2.3-spatial-upscaler-x2-1.0.safetensors",
399
- "ltx-2.3-22b-distilled-lora-384.safetensors",
400
- ],
401
- "models--google--gemma-3-12b-it-qat-q4_0-unquantized": [
402
- "model-00001-of-00005.safetensors",
403
- "model-00002-of-00005.safetensors",
404
- "model-00003-of-00005.safetensors",
405
- "model-00004-of-00005.safetensors",
406
- "model-00005-of-00005.safetensors",
407
- "model.safetensors.index.json",
408
- "tokenizer.model",
409
- "preprocessor_config.json",
410
- ],
411
- "models--Kijai--LTX2.3_comfy": [
412
- "LTX23_video_vae_bf16.safetensors",
413
- "LTX23_audio_vae_bf16.safetensors",
414
- ],
415
- }
416
- for repo, files in layouts.items():
417
- snapshot_dir = hub / repo / "snapshots" / "deadbeef" * 1
418
- snapshot_dir = hub / repo / "snapshots" / "deadbeef"
419
- snapshot_dir.mkdir(parents=True, exist_ok=True)
420
- for filename in files:
421
- (snapshot_dir / filename).write_text("") # placeholder
422
- return hub
423
- ```
424
-
425
- - [ ] **Step 3: Verify pytest discovers the conftest**
426
-
427
- Run: `python3.11 -m pytest tests/ --collect-only 2>&1 | head -20`
428
- Expected: "no tests ran" or similar — but no errors importing conftest.
429
-
430
- - [ ] **Step 4: Commit**
431
-
432
- ```bash
433
- git add tests/__init__.py tests/conftest.py
434
- git commit -m "test: pytest fixtures (master_workflow, canonical_inputs, fake_hf_cache)"
435
- ```
436
-
437
- ---
438
-
439
- ### Task 5: ComfyUI submodule
440
-
441
- **Files:**
442
- - Create: `.gitmodules`
443
- - Create: `comfyui/` (submodule)
444
-
445
- - [ ] **Step 1: Add ComfyUI as a git submodule**
446
-
447
- ```bash
448
- cd /Users/techfreakworm/Projects/llm/ltx2.3-AIO-generator
449
- git submodule add https://github.com/comfyanonymous/ComfyUI.git comfyui
450
- cd comfyui
451
- # Pin to a known-good recent commit. Capture the SHA the user is currently running.
452
- USER_COMFY_SHA="$(git -C ~/Projects/comfyui rev-parse HEAD)"
453
- git checkout "$USER_COMFY_SHA"
454
- cd ..
455
- ```
456
-
457
- - [ ] **Step 2: Verify submodule status**
458
-
459
- Run: `git submodule status`
460
- Expected: one line starting with the pinned SHA followed by `comfyui (heads/master ...)` or similar.
461
-
462
- - [ ] **Step 3: Commit submodule**
463
-
464
- ```bash
465
- git add .gitmodules comfyui
466
- git commit -m "chore: vendor ComfyUI as git submodule pinned to working commit"
467
- ```
468
-
469
- ---
470
-
471
- ## Phase 1 — Workflow library (TDD)
472
-
473
- ### Task 6: `tools/extract_modes.py` — extract mode templates
474
-
475
- **Files:**
476
- - Create: `tools/__init__.py` (empty)
477
- - Create: `tools/extract_modes.py`
478
- - Create: `tests/test_extract_modes.py`
479
-
480
- - [ ] **Step 1: Write the failing test**
481
-
482
- ```python
483
- # tests/test_extract_modes.py
484
- """Tests for the workflow-mode extractor."""
485
- import json
486
- import subprocess
487
- import sys
488
-
489
- from tests.conftest import REPO_ROOT
490
-
491
-
492
- def test_extract_creates_six_mode_files(master_workflow, tmp_path):
493
- """extract_modes.py emits six valid mode-specific JSON templates."""
494
- out_dir = tmp_path / "workflows"
495
- master_path = tmp_path / "master.json"
496
- master_path.write_text(json.dumps(master_workflow))
497
-
498
- result = subprocess.run(
499
- [
500
- sys.executable,
501
- str(REPO_ROOT / "tools" / "extract_modes.py"),
502
- "--master",
503
- str(master_path),
504
- "--out",
505
- str(out_dir),
506
- ],
507
- check=False,
508
- capture_output=True,
509
- text=True,
510
- )
511
-
512
- assert result.returncode == 0, result.stderr
513
- expected = {"t2v.json", "a2v.json", "i2v.json", "lipsync.json", "keyframe.json", "style.json"}
514
- actual = {p.name for p in out_dir.iterdir()}
515
- assert actual == expected
516
-
517
- # Each file must be valid JSON with at least one node.
518
- for path in out_dir.iterdir():
519
- wf = json.loads(path.read_text())
520
- assert "nodes" in wf
521
- assert len(wf["nodes"]) > 0
522
- ```
523
-
524
- - [ ] **Step 2: Run the test to verify it fails**
525
-
526
- Run: `python3.11 -m pytest tests/test_extract_modes.py -v`
527
- Expected: FAIL with `FileNotFoundError` or `No such file or directory` for `tools/extract_modes.py`.
528
-
529
- - [ ] **Step 3: Implement `tools/__init__.py` and `tools/extract_modes.py`**
530
-
531
- ```python
532
- # tools/__init__.py (empty)
533
- ```
534
-
535
- ```python
536
- # tools/extract_modes.py
537
- """Extract six mode-specific workflow templates from the master LTX 2.3 All-In-One workflow.
538
-
539
- Each ComfyUI group whose title starts with a number (e.g. "01 Text to Video") becomes
540
- a mode template containing only that group's nodes plus shared scaffolding (Models,
541
- Lora, Setting, Prompt, Load Audio/Image/Video, Output groups).
542
-
543
- Group title → output filename mapping:
544
- 01 → t2v.json
545
- 02 → a2v.json
546
- 03 → i2v.json
547
- 04 → lipsync.json
548
- 05 → keyframe.json
549
- 06 → style.json
550
- """
551
- from __future__ import annotations
552
-
553
- import argparse
554
- import json
555
- import pathlib
556
- import re
557
- import sys
558
- from collections.abc import Iterable
559
-
560
- GROUP_TO_FILENAME: dict[str, str] = {
561
- "01": "t2v.json",
562
- "02": "a2v.json",
563
- "03": "i2v.json",
564
- "04": "lipsync.json",
565
- "05": "keyframe.json",
566
- "06": "style.json",
567
- }
568
-
569
- SHARED_GROUP_PREFIXES: tuple[str, ...] = (
570
- "Models",
571
- "Lora",
572
- "Setting",
573
- "Prompt",
574
- "Load Audio",
575
- "Load Image",
576
- "Load Video",
577
- "Output",
578
- )
579
-
580
-
581
- def _node_in_group(node: dict, group: dict) -> bool:
582
- """Test whether a node's position lies inside a group's bounding box."""
583
- if "pos" not in node or "bounding" not in group:
584
- return False
585
- nx, ny = node["pos"][0], node["pos"][1]
586
- gx, gy, gw, gh = group["bounding"]
587
- return (gx <= nx <= gx + gw) and (gy <= ny <= gy + gh)
588
-
589
-
590
- def _select_groups(master: dict, mode_prefix: str) -> list[dict]:
591
- """Pick the mode group plus all shared groups."""
592
- selected: list[dict] = []
593
- for g in master.get("groups", []):
594
- title = (g.get("title") or "").strip()
595
- if title.startswith(mode_prefix + " "):
596
- selected.append(g)
597
- elif any(title.startswith(p) for p in SHARED_GROUP_PREFIXES):
598
- selected.append(g)
599
- return selected
600
-
601
-
602
- def _collect_nodes(master: dict, groups: Iterable[dict]) -> list[dict]:
603
- """Return all nodes lying inside any of the given groups."""
604
- groups_list = list(groups)
605
- keep: list[dict] = []
606
- for node in master.get("nodes", []):
607
- if any(_node_in_group(node, g) for g in groups_list):
608
- keep.append(node)
609
- return keep
610
-
611
-
612
- def _collect_links(master: dict, kept_node_ids: set[int]) -> list[list]:
613
- """Keep only links where both endpoints are in the surviving node set."""
614
- return [
615
- link
616
- for link in master.get("links", [])
617
- # ComfyUI link tuple format: [link_id, src_node_id, src_out, dst_node_id, dst_in, type]
618
- if link[1] in kept_node_ids and link[3] in kept_node_ids
619
- ]
620
-
621
-
622
- def extract_mode(master: dict, mode_prefix: str) -> dict:
623
- """Build a focused workflow JSON for the given mode group prefix."""
624
- groups = _select_groups(master, mode_prefix)
625
- nodes = _collect_nodes(master, groups)
626
- kept_ids = {n["id"] for n in nodes}
627
- links = _collect_links(master, kept_ids)
628
-
629
- return {
630
- "id": f"ltx23-aio-{mode_prefix}",
631
- "revision": 0,
632
- "last_node_id": max(kept_ids, default=0),
633
- "last_link_id": max((l[0] for l in links), default=0),
634
- "nodes": nodes,
635
- "links": links,
636
- "groups": groups,
637
- "definitions": master.get("definitions", {}),
638
- "config": master.get("config", {}),
639
- "extra": master.get("extra", {}),
640
- "version": master.get("version", 0.4),
641
- }
642
-
643
-
644
- def main(argv: list[str] | None = None) -> int:
645
- parser = argparse.ArgumentParser(description=__doc__)
646
- parser.add_argument("--master", type=pathlib.Path, required=True)
647
- parser.add_argument("--out", type=pathlib.Path, required=True)
648
- args = parser.parse_args(argv)
649
-
650
- master = json.loads(args.master.read_text())
651
- args.out.mkdir(parents=True, exist_ok=True)
652
-
653
- for prefix, filename in GROUP_TO_FILENAME.items():
654
- wf = extract_mode(master, prefix)
655
- out_path = args.out / filename
656
- out_path.write_text(json.dumps(wf, indent=2))
657
- print(f" → wrote {out_path} ({len(wf['nodes'])} nodes, {len(wf['links'])} links)")
658
-
659
- return 0
660
-
661
-
662
- if __name__ == "__main__":
663
- sys.exit(main())
664
- ```
665
-
666
- - [ ] **Step 4: Run the test to verify it passes**
667
-
668
- Run: `python3.11 -m pytest tests/test_extract_modes.py -v`
669
- Expected: PASS. (If `master_workflow` fixture skips because the master JSON isn't at the expected path, set `LTX23_MASTER_WORKFLOW` env var first.)
670
-
671
- - [ ] **Step 5: Commit**
672
-
673
- ```bash
674
- git add tools/__init__.py tools/extract_modes.py tests/test_extract_modes.py
675
- git commit -m "feat(tools): extract six mode templates from master workflow JSON"
676
- ```
677
-
678
- ---
679
-
680
- ### Task 7: Run extraction once → commit `workflows/*.json`
681
-
682
- **Files:**
683
- - Create: `workflows/t2v.json` … `workflows/style.json`
684
-
685
- - [ ] **Step 1: Run the extractor against the master workflow**
686
-
687
- ```bash
688
- mkdir -p workflows
689
- python3.11 tools/extract_modes.py \
690
- --master ~/Projects/comfyui/user/default/workflows/"1. LTX 2.3 All-In-One 260406-05.json" \
691
- --out workflows
692
- ```
693
-
694
- Expected output: six lines like `→ wrote workflows/t2v.json (N nodes, M links)`.
695
-
696
- - [ ] **Step 2: Sanity-check each file**
697
-
698
- ```bash
699
- for f in workflows/*.json; do
700
- python3.11 -c "import json; w=json.load(open('$f')); print('$f', len(w['nodes']), 'nodes')"
701
- done
702
- ```
703
-
704
- Expected: each file reports a non-zero node count.
705
-
706
- - [ ] **Step 3: Commit the templates**
707
-
708
- ```bash
709
- git add workflows/
710
- git commit -m "data: extracted mode-specific workflow templates from master"
711
- ```
712
-
713
- ---
714
-
715
- ### Task 8: `workflow.py` — `load_template`
716
-
717
- **Files:**
718
- - Create: `workflow.py`
719
- - Create: `tests/test_workflow.py`
720
-
721
- - [ ] **Step 1: Write the failing test**
722
-
723
- ```python
724
- # tests/test_workflow.py
725
- """Unit tests for workflow.py — pure functions over JSON dicts."""
726
- import pytest
727
-
728
- import workflow
729
-
730
-
731
- def test_load_template_returns_dict_for_valid_mode():
732
- wf = workflow.load_template("t2v")
733
- assert isinstance(wf, dict)
734
- assert "nodes" in wf
735
- assert len(wf["nodes"]) > 0
736
-
737
-
738
- def test_load_template_raises_for_unknown_mode():
739
- with pytest.raises(ValueError, match="unknown mode"):
740
- workflow.load_template("nonexistent")
741
-
742
-
743
- def test_load_template_returns_independent_copy():
744
- """Mutations to one returned dict must not affect later loads."""
745
- a = workflow.load_template("t2v")
746
- a["nodes"].append({"id": -999})
747
- b = workflow.load_template("t2v")
748
- assert {-999} & {n.get("id") for n in b["nodes"]} == set()
749
- ```
750
-
751
- - [ ] **Step 2: Run the test to verify it fails**
752
-
753
- Run: `python3.11 -m pytest tests/test_workflow.py -v`
754
- Expected: FAIL — `ModuleNotFoundError: No module named 'workflow'`.
755
-
756
- - [ ] **Step 3: Implement `workflow.py`**
757
-
758
- ```python
759
- """Pure functions over LTX 2.3 mode workflow JSON templates."""
760
- from __future__ import annotations
761
-
762
- import copy
763
- import json
764
- import pathlib
765
- from typing import Any
766
-
767
- WORKFLOWS_DIR = pathlib.Path(__file__).parent / "workflows"
768
-
769
- VALID_MODES: tuple[str, ...] = ("t2v", "a2v", "i2v", "lipsync", "keyframe", "style")
770
-
771
-
772
- def load_template(mode: str) -> dict[str, Any]:
773
- """Load a fresh, independent copy of the named mode's workflow template."""
774
- if mode not in VALID_MODES:
775
- raise ValueError(f"unknown mode {mode!r}; expected one of {VALID_MODES}")
776
- path = WORKFLOWS_DIR / f"{mode}.json"
777
- return copy.deepcopy(json.loads(path.read_text()))
778
- ```
779
-
780
- - [ ] **Step 4: Run the test to verify it passes**
781
-
782
- Run: `python3.11 -m pytest tests/test_workflow.py -v`
783
- Expected: PASS — three tests green.
784
-
785
- - [ ] **Step 5: Commit**
786
-
787
- ```bash
788
- git add workflow.py tests/test_workflow.py
789
- git commit -m "feat(workflow): load_template returns fresh deep copy per mode"
790
- ```
791
-
792
- ---
793
-
794
- ### Task 9: `workflow.py` — `set_input` and `validate`
795
-
796
- **Files:**
797
- - Modify: `workflow.py`
798
- - Modify: `tests/test_workflow.py`
799
-
800
- - [ ] **Step 1: Append failing tests**
801
-
802
- ```python
803
- # Append to tests/test_workflow.py
804
- def test_set_input_patches_widgets_values_in_place():
805
- wf = workflow.load_template("t2v")
806
- target_node = next(n for n in wf["nodes"] if n["type"] == "CLIPTextEncode")
807
- workflow.set_input(wf, target_node["id"], 0, "new prompt text")
808
- refetched = next(n for n in wf["nodes"] if n["id"] == target_node["id"])
809
- assert refetched["widgets_values"][0] == "new prompt text"
810
-
811
-
812
- def test_set_input_raises_for_unknown_node():
813
- wf = workflow.load_template("t2v")
814
- with pytest.raises(KeyError, match="node id"):
815
- workflow.set_input(wf, 999_999_999, 0, "x")
816
-
817
-
818
- def test_validate_accepts_canonical_template():
819
- wf = workflow.load_template("t2v")
820
- workflow.validate(wf) # must not raise
821
-
822
-
823
- def test_validate_rejects_workflow_with_no_nodes():
824
- wf = {"nodes": [], "links": []}
825
- with pytest.raises(ValueError, match="no nodes"):
826
- workflow.validate(wf)
827
-
828
-
829
- def test_validate_rejects_orphan_link():
830
- wf = workflow.load_template("t2v")
831
- wf["links"].append([99999, 1, 0, 999_999_999, 0, "INT"]) # destination doesn't exist
832
- with pytest.raises(ValueError, match="orphan link"):
833
- workflow.validate(wf)
834
- ```
835
-
836
- - [ ] **Step 2: Run tests to verify the new ones fail**
837
-
838
- Run: `python3.11 -m pytest tests/test_workflow.py -v`
839
- Expected: 5 fails (set_input + validate) and 3 prior tests still passing.
840
-
841
- - [ ] **Step 3: Implement `set_input` and `validate` in `workflow.py`**
842
-
843
- Append to `workflow.py`:
844
-
845
- ```python
846
- def set_input(workflow: dict[str, Any], node_id: int, widget_index: int, value: Any) -> None:
847
- """Patch a node's widgets_values in place.
848
-
849
- Args:
850
- workflow: A workflow dict (must have a "nodes" list).
851
- node_id: The id of the node to patch.
852
- widget_index: Position within the node's widgets_values list.
853
- value: New value.
854
-
855
- Raises:
856
- KeyError: If no node with the given id exists.
857
- """
858
- for node in workflow["nodes"]:
859
- if node.get("id") == node_id:
860
- widgets = node.setdefault("widgets_values", [])
861
- while len(widgets) <= widget_index:
862
- widgets.append(None)
863
- widgets[widget_index] = value
864
- return
865
- raise KeyError(f"node id {node_id} not found in workflow")
866
-
867
-
868
- def validate(workflow: dict[str, Any]) -> None:
869
- """Static schema validation. Raises ValueError on the first problem found."""
870
- nodes = workflow.get("nodes")
871
- if not isinstance(nodes, list) or len(nodes) == 0:
872
- raise ValueError("workflow has no nodes")
873
-
874
- node_ids = {n.get("id") for n in nodes if "id" in n}
875
- for link in workflow.get("links", []):
876
- if not isinstance(link, list) or len(link) < 6:
877
- raise ValueError(f"malformed link {link}")
878
- _, src, _, dst, _, _ = link
879
- if src not in node_ids or dst not in node_ids:
880
- raise ValueError(f"orphan link {link}")
881
- ```
882
-
883
- - [ ] **Step 4: Run all workflow tests**
884
-
885
- Run: `python3.11 -m pytest tests/test_workflow.py -v`
886
- Expected: 8 passing tests.
887
-
888
- - [ ] **Step 5: Commit**
889
-
890
- ```bash
891
- git add workflow.py tests/test_workflow.py
892
- git commit -m "feat(workflow): set_input + validate over node graph"
893
- ```
894
-
895
- ---
896
-
897
- ## Phase 2 — Modes registry
898
-
899
- ### Task 10: `modes.py` — `Mode` dataclass + skeleton
900
-
901
- **Files:**
902
- - Create: `modes.py`
903
- - Create: `tests/test_modes.py`
904
-
905
- - [ ] **Step 1: Write the failing test**
906
-
907
- ```python
908
- # tests/test_modes.py
909
- """Unit tests for modes.py — MODE_REGISTRY and parameterize_fn correctness."""
910
- import pytest
911
-
912
- import modes
913
-
914
-
915
- def test_mode_registry_has_all_six_keys():
916
- assert set(modes.MODE_REGISTRY.keys()) == {
917
- "t2v", "a2v", "i2v", "lipsync", "keyframe", "style",
918
- }
919
-
920
-
921
- def test_each_mode_has_required_attributes():
922
- for name, mode in modes.MODE_REGISTRY.items():
923
- assert mode.name == name
924
- assert mode.label # non-empty
925
- assert mode.icon # non-empty
926
- assert callable(mode.parameterize_fn)
927
- assert isinstance(mode.stage_map, list) and len(mode.stage_map) > 0
928
- ```
929
-
930
- - [ ] **Step 2: Run test to verify it fails**
931
-
932
- Run: `python3.11 -m pytest tests/test_modes.py -v`
933
- Expected: FAIL — `ModuleNotFoundError: No module named 'modes'`.
934
-
935
- - [ ] **Step 3: Create `modes.py` skeleton**
936
-
937
- ```python
938
- """MODE_REGISTRY — one Mode entry per generation mode.
939
-
940
- Each Mode declares:
941
- - name: short id ("t2v", "i2v", ...)
942
- - label: display name
943
- - icon: single-character or emoji icon for the sidebar
944
- - stage_map: list of (label, expected_share_pct) for the status banner
945
- - parameterize_fn: (Gradio inputs dict) -> list[(node_id, widget_index, value)]
946
-
947
- The parameterize_fn is the only mode-specific logic. Everything else (workflow
948
- loading, validation, dispatch) is mode-agnostic and lives in workflow.py /
949
- backend.py.
950
- """
951
- from __future__ import annotations
952
-
953
- from collections.abc import Callable
954
- from dataclasses import dataclass, field
955
- from typing import Any
956
-
957
- Patch = tuple[int, int, Any]
958
- ParameterizeFn = Callable[[dict[str, Any]], list[Patch]]
959
-
960
-
961
- @dataclass(frozen=True)
962
- class Stage:
963
- label: str
964
- share_pct: int # rough share of total time, sums to ~100 across stages
965
-
966
-
967
- @dataclass(frozen=True)
968
- class Mode:
969
- name: str
970
- label: str
971
- icon: str
972
- parameterize_fn: ParameterizeFn
973
- stage_map: list[Stage] = field(default_factory=list)
974
-
975
-
976
- # Filled in by tasks 11–12.
977
- MODE_REGISTRY: dict[str, Mode] = {}
978
- ```
979
-
980
- - [ ] **Step 4: Run test to verify it still fails (different error)**
981
-
982
- Run: `python3.11 -m pytest tests/test_modes.py -v`
983
- Expected: FAIL on `test_mode_registry_has_all_six_keys` — empty registry.
984
-
985
- - [ ] **Step 5: Commit skeleton**
986
-
987
- ```bash
988
- git add modes.py tests/test_modes.py
989
- git commit -m "feat(modes): Mode dataclass + empty MODE_REGISTRY skeleton"
990
- ```
991
-
992
- ---
993
-
994
- ### Task 11: `parameterize_fn` for T2V and I2V
995
-
996
- **Files:**
997
- - Modify: `modes.py`
998
- - Modify: `tests/test_modes.py`
999
-
1000
- - [ ] **Step 1: Append failing tests**
1001
-
1002
- ```python
1003
- # Append to tests/test_modes.py
1004
- import workflow
1005
-
1006
- def test_t2v_parameterize_produces_valid_patches(canonical_inputs):
1007
- inputs = canonical_inputs["t2v"]
1008
- mode = modes.MODE_REGISTRY["t2v"]
1009
- patches = mode.parameterize_fn(inputs)
1010
-
1011
- # All patches must be (node_id: int, widget_index: int, value: Any)
1012
- for node_id, widget_index, value in patches:
1013
- assert isinstance(node_id, int)
1014
- assert isinstance(widget_index, int)
1015
- assert value is not None or value == ""
1016
-
1017
- # Apply patches to a real template; result must validate.
1018
- wf = workflow.load_template("t2v")
1019
- for patch in patches:
1020
- workflow.set_input(wf, *patch)
1021
- workflow.validate(wf)
1022
-
1023
-
1024
- def test_i2v_parameterize_uses_image_path(canonical_inputs):
1025
- inputs = canonical_inputs["i2v"]
1026
- mode = modes.MODE_REGISTRY["i2v"]
1027
- patches = mode.parameterize_fn(inputs)
1028
- values = [p[2] for p in patches]
1029
- assert inputs["image"] in values
1030
- ```
1031
-
1032
- - [ ] **Step 2: Run tests to verify failures**
1033
-
1034
- Run: `python3.11 -m pytest tests/test_modes.py -v -k "t2v or i2v"`
1035
- Expected: FAIL — `KeyError: 't2v'` from empty MODE_REGISTRY.
1036
-
1037
- - [ ] **Step 3: Implement T2V and I2V**
1038
-
1039
- Append to `modes.py`:
1040
-
1041
- ```python
1042
- # ---------------------------------------------------------------------------
1043
- # Node-id constants per template. These are stable for a given workflow file;
1044
- # if you re-run tools/extract_modes.py against an updated master, re-capture
1045
- # them by inspecting the regenerated workflows/<mode>.json.
1046
- # ---------------------------------------------------------------------------
1047
-
1048
- # T2V template node ids (capture from workflows/t2v.json after extraction).
1049
- T2V_NODE_PROMPT = 240 # CLIPTextEncode positive
1050
- T2V_NODE_NEG_PROMPT = 241 # CLIPTextEncode negative
1051
- T2V_NODE_RESOLUTION = 5300 # mxSlider for w/h
1052
- T2V_NODE_FRAMES = 5301 # INTConstant
1053
- T2V_NODE_FPS = 5302 # INTConstant
1054
- T2V_NODE_SEED = 5303 # INTConstant
1055
- T2V_NODE_PRESET = 5304 # Any Switch — preset selector
1056
- T2V_NODE_CAMERA_LORA = 5400 # Power Lora Loader row 0
1057
- T2V_NODE_DETAILER_LORA = 5401 # Power Lora Loader row 1
1058
-
1059
- # I2V template node ids (capture from workflows/i2v.json).
1060
- I2V_NODE_PROMPT = 340
1061
- I2V_NODE_IMAGE = 350 # LoadImage
1062
- I2V_NODE_RESOLUTION = 5310
1063
- I2V_NODE_FRAMES = 5311
1064
- I2V_NODE_FPS = 5312
1065
- I2V_NODE_SEED = 5313
1066
- I2V_NODE_PRESET = 5314
1067
- I2V_NODE_CAMERA_LORA = 5410
1068
- I2V_NODE_DETAILER_LORA = 5411
1069
- I2V_NODE_IC_LORA = 5412
1070
- I2V_NODE_POSE_LORA = 5413
1071
-
1072
-
1073
- def _t2v_parameterize(inp: dict[str, Any]) -> list[Patch]:
1074
- return [
1075
- (T2V_NODE_PROMPT, 0, inp["prompt"]),
1076
- (T2V_NODE_NEG_PROMPT, 0, inp.get("negative_prompt", "")),
1077
- (T2V_NODE_RESOLUTION, 0, inp["width"]),
1078
- (T2V_NODE_RESOLUTION, 1, inp["height"]),
1079
- (T2V_NODE_FRAMES, 0, inp["frames"]),
1080
- (T2V_NODE_FPS, 0, inp["fps"]),
1081
- (T2V_NODE_SEED, 0, inp["seed"]),
1082
- (T2V_NODE_PRESET, 0, inp["preset"]),
1083
- (T2V_NODE_CAMERA_LORA, 0, inp.get("camera_lora", "none")),
1084
- (T2V_NODE_CAMERA_LORA, 1, inp.get("camera_strength", 0.0)),
1085
- (T2V_NODE_DETAILER_LORA, 0, "ic-lora-detailer" if inp.get("detailer_on") else "none"),
1086
- (T2V_NODE_DETAILER_LORA, 1, inp.get("detailer_strength", 0.0)),
1087
- ]
1088
-
1089
-
1090
- def _i2v_parameterize(inp: dict[str, Any]) -> list[Patch]:
1091
- return [
1092
- (I2V_NODE_PROMPT, 0, inp["prompt"]),
1093
- (I2V_NODE_IMAGE, 0, inp["image"]),
1094
- (I2V_NODE_RESOLUTION, 0, inp["width"]),
1095
- (I2V_NODE_RESOLUTION, 1, inp["height"]),
1096
- (I2V_NODE_FRAMES, 0, inp["frames"]),
1097
- (I2V_NODE_FPS, 0, inp["fps"]),
1098
- (I2V_NODE_SEED, 0, inp["seed"]),
1099
- (I2V_NODE_PRESET, 0, inp["preset"]),
1100
- (I2V_NODE_CAMERA_LORA, 0, inp.get("camera_lora", "none")),
1101
- (I2V_NODE_CAMERA_LORA, 1, inp.get("camera_strength", 0.0)),
1102
- (I2V_NODE_DETAILER_LORA, 0, "ic-lora-detailer" if inp.get("detailer_on") else "none"),
1103
- (I2V_NODE_DETAILER_LORA, 1, inp.get("detailer_strength", 0.0)),
1104
- (I2V_NODE_IC_LORA, 0, f"ic-lora-{inp.get('ic_lora', 'union')}"),
1105
- (I2V_NODE_IC_LORA, 1, inp.get("ic_strength", 0.0)),
1106
- (I2V_NODE_POSE_LORA, 0, "ic-lora-pose-control" if inp.get("pose_on") else "none"),
1107
- (I2V_NODE_POSE_LORA, 1, inp.get("pose_strength", 0.0)),
1108
- ]
1109
-
1110
-
1111
- _T2V_STAGES = [
1112
- Stage("Encode prompt", 5),
1113
- Stage("Diffusion (Stage 1)", 60),
1114
- Stage("Spatial upscale", 7),
1115
- Stage("Diffusion (Stage 2)", 18),
1116
- Stage("Decode video", 10),
1117
- ]
1118
-
1119
- _I2V_STAGES = [
1120
- Stage("Encode prompt", 5),
1121
- Stage("Encode image", 3),
1122
- Stage("Diffusion (Stage 1)", 55),
1123
- Stage("Spatial upscale", 7),
1124
- Stage("Diffusion (Stage 2)", 20),
1125
- Stage("Decode video", 10),
1126
- ]
1127
-
1128
- MODE_REGISTRY["t2v"] = Mode(
1129
- name="t2v", label="Text → Video", icon="📝",
1130
- parameterize_fn=_t2v_parameterize, stage_map=_T2V_STAGES,
1131
- )
1132
- MODE_REGISTRY["i2v"] = Mode(
1133
- name="i2v", label="Image → Video", icon="🖼",
1134
- parameterize_fn=_i2v_parameterize, stage_map=_I2V_STAGES,
1135
- )
1136
- ```
1137
-
1138
- > **Note:** the node-id constants (e.g. `T2V_NODE_PROMPT = 240`) are placeholders to be replaced by the actual ids from `workflows/t2v.json`. After Task 7 generates the templates, capture the real ids by running:
1139
- > ```bash
1140
- > python3.11 -c "import json; w=json.load(open('workflows/t2v.json')); [print(n['id'], n['type'], n.get('title')) for n in w['nodes'] if n['type'] in ('CLIPTextEncode','mxSlider','INTConstant','Power Lora Loader (rgthree)','Any Switch (rgthree)')]"
1141
- > ```
1142
- > and replace each constant with the matching node id. This step is part of Step 4.
1143
-
1144
- - [ ] **Step 4: Capture real node ids and update constants**
1145
-
1146
- Run the inspection command above for both `t2v.json` and `i2v.json`. Replace the constants with the real ids. Re-read the test in Step 1 — it must still pass.
1147
-
1148
- - [ ] **Step 5: Run T2V/I2V tests**
1149
-
1150
- Run: `python3.11 -m pytest tests/test_modes.py -v -k "t2v or i2v"`
1151
- Expected: PASS for both T2V and I2V tests; existing skeleton tests still pass.
1152
-
1153
- - [ ] **Step 6: Commit**
1154
-
1155
- ```bash
1156
- git add modes.py tests/test_modes.py
1157
- git commit -m "feat(modes): T2V + I2V parameterize_fn with stage maps"
1158
- ```
1159
-
1160
- ---
1161
-
1162
- ### Task 12: `parameterize_fn` for A2V, Lipsync, Keyframe, Style
1163
-
1164
- **Files:**
1165
- - Modify: `modes.py`
1166
- - Modify: `tests/test_modes.py`
1167
-
1168
- - [ ] **Step 1: Append failing tests**
1169
-
1170
- ```python
1171
- # Append to tests/test_modes.py
1172
- @pytest.mark.parametrize("mode_name", ["a2v", "lipsync", "keyframe", "style"])
1173
- def test_remaining_modes_parameterize_validates(mode_name, canonical_inputs):
1174
- inputs = canonical_inputs[mode_name]
1175
- mode = modes.MODE_REGISTRY[mode_name]
1176
- patches = mode.parameterize_fn(inputs)
1177
- assert len(patches) > 0
1178
-
1179
- wf = workflow.load_template(mode_name)
1180
- for patch in patches:
1181
- workflow.set_input(wf, *patch)
1182
- workflow.validate(wf)
1183
-
1184
-
1185
- def test_a2v_parameterize_passes_audio_path(canonical_inputs):
1186
- patches = modes.MODE_REGISTRY["a2v"].parameterize_fn(canonical_inputs["a2v"])
1187
- assert canonical_inputs["a2v"]["audio"] in [p[2] for p in patches]
1188
-
1189
-
1190
- def test_lipsync_parameterize_passes_image_and_audio(canonical_inputs):
1191
- patches = modes.MODE_REGISTRY["lipsync"].parameterize_fn(canonical_inputs["lipsync"])
1192
- values = [p[2] for p in patches]
1193
- assert canonical_inputs["lipsync"]["image"] in values
1194
- assert canonical_inputs["lipsync"]["audio"] in values
1195
-
1196
-
1197
- def test_keyframe_parameterize_passes_two_frames(canonical_inputs):
1198
- patches = modes.MODE_REGISTRY["keyframe"].parameterize_fn(canonical_inputs["keyframe"])
1199
- values = [p[2] for p in patches]
1200
- assert canonical_inputs["keyframe"]["first_frame"] in values
1201
- assert canonical_inputs["keyframe"]["last_frame"] in values
1202
-
1203
-
1204
- def test_style_parameterize_passes_input_video(canonical_inputs):
1205
- patches = modes.MODE_REGISTRY["style"].parameterize_fn(canonical_inputs["style"])
1206
- assert canonical_inputs["style"]["input_video"] in [p[2] for p in patches]
1207
- ```
1208
-
1209
- - [ ] **Step 2: Run tests to verify failures**
1210
-
1211
- Run: `python3.11 -m pytest tests/test_modes.py -v`
1212
- Expected: 5 fails on the new tests (KeyError for missing modes).
1213
-
1214
- - [ ] **Step 3: Implement A2V, Lipsync, Keyframe, Style**
1215
-
1216
- Append to `modes.py` (with node-id constants captured from each `workflows/<mode>.json` per the inspection technique in Task 11):
1217
-
1218
- ```python
1219
- # A2V template node ids
1220
- A2V_NODE_PROMPT = ... # capture from workflows/a2v.json
1221
- A2V_NODE_AUDIO = ... # VHS_LoadAudioUpload
1222
- A2V_NODE_RESOLUTION = ...
1223
- A2V_NODE_FRAMES = ...
1224
- A2V_NODE_FPS = ...
1225
- A2V_NODE_SEED = ...
1226
- A2V_NODE_PRESET = ...
1227
- A2V_NODE_AUDIO_CFG = ...
1228
-
1229
- # Lipsync template node ids
1230
- LIPSYNC_NODE_PROMPT = ...
1231
- LIPSYNC_NODE_IMAGE = ...
1232
- LIPSYNC_NODE_AUDIO = ...
1233
- LIPSYNC_NODE_IMAGE_STRENGTH = ...
1234
- LIPSYNC_NODE_FRAMES = ...
1235
- LIPSYNC_NODE_FPS = ...
1236
- LIPSYNC_NODE_SEED = ...
1237
- LIPSYNC_NODE_PRESET = ...
1238
-
1239
- # Keyframe template node ids
1240
- KEYFRAME_NODE_PROMPT = ...
1241
- KEYFRAME_NODE_FIRST = ...
1242
- KEYFRAME_NODE_LAST = ...
1243
- KEYFRAME_NODE_FRAMES = ...
1244
- KEYFRAME_NODE_FPS = ...
1245
- KEYFRAME_NODE_SEED = ...
1246
- KEYFRAME_NODE_PRESET = ...
1247
-
1248
- # Style template node ids
1249
- STYLE_NODE_PROMPT = ...
1250
- STYLE_NODE_VIDEO = ...
1251
- STYLE_NODE_IC_LORA = ...
1252
- STYLE_NODE_FRAMES = ...
1253
- STYLE_NODE_FPS = ...
1254
- STYLE_NODE_SEED = ...
1255
- STYLE_NODE_PRESET = ...
1256
-
1257
-
1258
- def _a2v_parameterize(inp: dict[str, Any]) -> list[Patch]:
1259
- return [
1260
- (A2V_NODE_PROMPT, 0, inp["prompt"]),
1261
- (A2V_NODE_AUDIO, 0, inp["audio"]),
1262
- (A2V_NODE_RESOLUTION, 0, inp["width"]),
1263
- (A2V_NODE_RESOLUTION, 1, inp["height"]),
1264
- (A2V_NODE_FRAMES, 0, inp["frames"]),
1265
- (A2V_NODE_FPS, 0, inp["fps"]),
1266
- (A2V_NODE_SEED, 0, inp["seed"]),
1267
- (A2V_NODE_PRESET, 0, inp["preset"]),
1268
- (A2V_NODE_AUDIO_CFG, 0, inp.get("audio_cfg", 7.0)),
1269
- ]
1270
-
1271
-
1272
- def _lipsync_parameterize(inp: dict[str, Any]) -> list[Patch]:
1273
- return [
1274
- (LIPSYNC_NODE_PROMPT, 0, inp["prompt"]),
1275
- (LIPSYNC_NODE_IMAGE, 0, inp["image"]),
1276
- (LIPSYNC_NODE_AUDIO, 0, inp["audio"]),
1277
- (LIPSYNC_NODE_IMAGE_STRENGTH, 0, inp.get("image_strength", 0.7)),
1278
- (LIPSYNC_NODE_FRAMES, 0, inp["frames"]),
1279
- (LIPSYNC_NODE_FPS, 0, inp["fps"]),
1280
- (LIPSYNC_NODE_SEED, 0, inp["seed"]),
1281
- (LIPSYNC_NODE_PRESET, 0, inp["preset"]),
1282
- ]
1283
-
1284
-
1285
- def _keyframe_parameterize(inp: dict[str, Any]) -> list[Patch]:
1286
- return [
1287
- (KEYFRAME_NODE_PROMPT, 0, inp["prompt"]),
1288
- (KEYFRAME_NODE_FIRST, 0, inp["first_frame"]),
1289
- (KEYFRAME_NODE_LAST, 0, inp["last_frame"]),
1290
- (KEYFRAME_NODE_FRAMES, 0, inp["frames"]),
1291
- (KEYFRAME_NODE_FPS, 0, inp["fps"]),
1292
- (KEYFRAME_NODE_SEED, 0, inp["seed"]),
1293
- (KEYFRAME_NODE_PRESET, 0, inp["preset"]),
1294
- ]
1295
-
1296
-
1297
- def _style_parameterize(inp: dict[str, Any]) -> list[Patch]:
1298
- return [
1299
- (STYLE_NODE_PROMPT, 0, inp["prompt"]),
1300
- (STYLE_NODE_VIDEO, 0, inp["input_video"]),
1301
- (STYLE_NODE_IC_LORA, 0, f"ic-lora-{inp.get('ic_lora', 'motion-track')}"),
1302
- (STYLE_NODE_IC_LORA, 1, inp.get("ic_strength", 0.5)),
1303
- (STYLE_NODE_FRAMES, 0, inp["frames"]),
1304
- (STYLE_NODE_FPS, 0, inp["fps"]),
1305
- (STYLE_NODE_SEED, 0, inp["seed"]),
1306
- (STYLE_NODE_PRESET, 0, inp["preset"]),
1307
- ]
1308
-
1309
-
1310
- _A2V_STAGES = [
1311
- Stage("Encode prompt", 5),
1312
- Stage("Encode audio", 5),
1313
- Stage("Diffusion (Stage 1)", 55),
1314
- Stage("Spatial upscale", 7),
1315
- Stage("Diffusion (Stage 2)", 18),
1316
- Stage("Decode video", 10),
1317
- ]
1318
- _LIPSYNC_STAGES = _A2V_STAGES + []
1319
- _KEYFRAME_STAGES = [
1320
- Stage("Encode prompt", 5),
1321
- Stage("Encode keyframes", 5),
1322
- Stage("Diffusion (Stage 1)", 55),
1323
- Stage("Spatial upscale", 7),
1324
- Stage("Diffusion (Stage 2)", 18),
1325
- Stage("Decode video", 10),
1326
- ]
1327
- _STYLE_STAGES = [
1328
- Stage("Encode prompt", 5),
1329
- Stage("Encode source video", 10),
1330
- Stage("Diffusion", 70),
1331
- Stage("Decode video", 15),
1332
- ]
1333
-
1334
-
1335
- MODE_REGISTRY["a2v"] = Mode(
1336
- name="a2v", label="Audio → Video", icon="🎵",
1337
- parameterize_fn=_a2v_parameterize, stage_map=_A2V_STAGES,
1338
- )
1339
- MODE_REGISTRY["lipsync"] = Mode(
1340
- name="lipsync", label="Lipsync", icon="🗣",
1341
- parameterize_fn=_lipsync_parameterize, stage_map=_LIPSYNC_STAGES,
1342
- )
1343
- MODE_REGISTRY["keyframe"] = Mode(
1344
- name="keyframe", label="First / Last Frame", icon="🎞",
1345
- parameterize_fn=_keyframe_parameterize, stage_map=_KEYFRAME_STAGES,
1346
- )
1347
- MODE_REGISTRY["style"] = Mode(
1348
- name="style", label="Style Transfer", icon="🎨",
1349
- parameterize_fn=_style_parameterize, stage_map=_STYLE_STAGES,
1350
- )
1351
- ```
1352
-
1353
- - [ ] **Step 4: Capture real node ids for the four new modes**
1354
-
1355
- Run the inspection command from Task 11 against `workflows/a2v.json`, `workflows/lipsync.json`, `workflows/keyframe.json`, `workflows/style.json`. Replace the `...` placeholders.
1356
-
1357
- - [ ] **Step 5: Run all mode tests**
1358
-
1359
- Run: `python3.11 -m pytest tests/test_modes.py -v`
1360
- Expected: all tests pass for all six modes.
1361
-
1362
- - [ ] **Step 6: Commit**
1363
-
1364
- ```bash
1365
- git add modes.py tests/test_modes.py
1366
- git commit -m "feat(modes): A2V + Lipsync + Keyframe + Style parameterize_fn"
1367
- ```
1368
-
1369
- ---
1370
-
1371
- ## Phase 3 — Models
1372
-
1373
- ### Task 13: `models.py` — `MODEL_REGISTRY`
1374
-
1375
- **Files:**
1376
- - Create: `models.py`
1377
- - Create: `tests/test_models.py`
1378
-
1379
- - [ ] **Step 1: Write the failing test**
1380
-
1381
- ```python
1382
- # tests/test_models.py
1383
- """Unit tests for models.py — MODEL_REGISTRY and ensure_models_for_mode."""
1384
- import models
1385
-
1386
-
1387
- def test_model_registry_resolves_known_files():
1388
- assert models.MODEL_REGISTRY["ltx-2.3-22b-distilled.safetensors"].repo_id == "Lightricks/LTX-2.3"
1389
- assert models.MODEL_REGISTRY["ltx-2.3-22b-distilled.safetensors"].subfolder == ""
1390
-
1391
-
1392
- def test_model_registry_includes_gemma_shards():
1393
- for i in range(1, 6):
1394
- key = f"model-{i:05d}-of-00005.safetensors"
1395
- assert key in models.MODEL_REGISTRY
1396
- assert "gemma-3-12b-it" in models.MODEL_REGISTRY[key].repo_id
1397
- ```
1398
-
1399
- - [ ] **Step 2: Run test to verify failure**
1400
-
1401
- Run: `python3.11 -m pytest tests/test_models.py -v`
1402
- Expected: `ModuleNotFoundError: No module named 'models'`.
1403
-
1404
- - [ ] **Step 3: Implement `MODEL_REGISTRY`**
1405
-
1406
- ```python
1407
- # models.py
1408
- """Model file registry: maps filename → (HuggingFace repo, subfolder).
1409
-
1410
- Lookups are by filename only — the same filename in two different repos is not
1411
- supported. If that ever happens we'll qualify by ComfyUI loader-type.
1412
- """
1413
- from __future__ import annotations
1414
-
1415
- from dataclasses import dataclass
1416
-
1417
-
1418
- @dataclass(frozen=True)
1419
- class ModelEntry:
1420
- repo_id: str
1421
- subfolder: str = ""
1422
- comfy_type: str = "checkpoints" # ComfyUI models/<comfy_type>/ subdirectory
1423
-
1424
-
1425
- MODEL_REGISTRY: dict[str, ModelEntry] = {
1426
- # Main LTX 2.3 transformer + LoRAs + upscalers
1427
- "ltx-2.3-22b-distilled.safetensors": ModelEntry(
1428
- "Lightricks/LTX-2.3", comfy_type="checkpoints"
1429
- ),
1430
- "ltx-2.3-22b-dev.safetensors": ModelEntry(
1431
- "Lightricks/LTX-2.3", comfy_type="checkpoints"
1432
- ),
1433
- "ltx-2.3-spatial-upscaler-x2-1.0.safetensors": ModelEntry(
1434
- "Lightricks/LTX-2.3", comfy_type="upscale_models"
1435
- ),
1436
- "ltx-2.3-22b-distilled-lora-384.safetensors": ModelEntry(
1437
- "Lightricks/LTX-2.3", comfy_type="loras"
1438
- ),
1439
- # Gemma 3 12B (5 shards + tokenizer/preprocessor)
1440
- **{
1441
- f"model-{i:05d}-of-00005.safetensors": ModelEntry(
1442
- "google/gemma-3-12b-it-qat-q4_0-unquantized",
1443
- comfy_type="text_encoders",
1444
- subfolder="gemma-3-12b-it",
1445
- )
1446
- for i in range(1, 6)
1447
- },
1448
- "model.safetensors.index.json": ModelEntry(
1449
- "google/gemma-3-12b-it-qat-q4_0-unquantized",
1450
- comfy_type="text_encoders",
1451
- subfolder="gemma-3-12b-it",
1452
- ),
1453
- "tokenizer.model": ModelEntry(
1454
- "google/gemma-3-12b-it-qat-q4_0-unquantized",
1455
- comfy_type="text_encoders",
1456
- subfolder="gemma-3-12b-it",
1457
- ),
1458
- "preprocessor_config.json": ModelEntry(
1459
- "google/gemma-3-12b-it-qat-q4_0-unquantized",
1460
- comfy_type="text_encoders",
1461
- subfolder="gemma-3-12b-it",
1462
- ),
1463
- # Kijai's LTX 2.3 ComfyUI assets
1464
- "LTX23_video_vae_bf16.safetensors": ModelEntry(
1465
- "Kijai/LTX2.3_comfy", comfy_type="vae"
1466
- ),
1467
- "LTX23_audio_vae_bf16.safetensors": ModelEntry(
1468
- "Kijai/LTX2.3_comfy", comfy_type="vae"
1469
- ),
1470
- "ltx-2.3_text_projection_bf16.safetensors": ModelEntry(
1471
- "Kijai/LTX2.3_comfy", comfy_type="text_encoders"
1472
- ),
1473
- # IC-LoRAs
1474
- "ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors": ModelEntry(
1475
- "Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control", comfy_type="loras"
1476
- ),
1477
- "ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors": ModelEntry(
1478
- "Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control", comfy_type="loras"
1479
- ),
1480
- "ltx-2-19b-ic-lora-detailer.safetensors": ModelEntry(
1481
- "Lightricks/LTX-2-19b-IC-LoRA-Detailer", comfy_type="loras"
1482
- ),
1483
- "ltx-2-19b-ic-lora-pose-control.safetensors": ModelEntry(
1484
- "Lightricks/LTX-2-19b-IC-LoRA-Pose-Control", comfy_type="loras"
1485
- ),
1486
- # Camera-control LoRAs (one repo each)
1487
- **{
1488
- f"ltx-2-19b-lora-camera-control-{movement}.safetensors": ModelEntry(
1489
- f"Lightricks/LTX-2-19b-LoRA-Camera-Control-{movement.replace('-', '-').title()}",
1490
- comfy_type="loras",
1491
- )
1492
- for movement in (
1493
- "static",
1494
- "dolly-in",
1495
- "dolly-out",
1496
- "dolly-left",
1497
- "dolly-right",
1498
- "jib-up",
1499
- "jib-down",
1500
- )
1501
- },
1502
- }
1503
- ```
1504
-
1505
- - [ ] **Step 4: Run test to verify pass**
1506
-
1507
- Run: `python3.11 -m pytest tests/test_models.py -v`
1508
- Expected: 2 tests pass.
1509
-
1510
- - [ ] **Step 5: Commit**
1511
-
1512
- ```bash
1513
- git add models.py tests/test_models.py
1514
- git commit -m "feat(models): MODEL_REGISTRY mapping filenames to HF repos"
1515
- ```
1516
-
1517
- ---
1518
-
1519
- ### Task 14: `models.py` — `walk_workflow_for_models`
1520
-
1521
- **Files:**
1522
- - Modify: `models.py`
1523
- - Modify: `tests/test_models.py`
1524
-
1525
- - [ ] **Step 1: Append failing tests**
1526
-
1527
- ```python
1528
- # Append to tests/test_models.py
1529
- import workflow
1530
-
1531
- def test_walk_workflow_for_models_finds_t2v_loaders():
1532
- wf = workflow.load_template("t2v")
1533
- needed = models.walk_workflow_for_models(wf)
1534
- # T2V needs at minimum the distilled transformer and gemma shards
1535
- assert "ltx-2.3-22b-distilled.safetensors" in needed
1536
- assert any(name.startswith("model-") and name.endswith(".safetensors") for name in needed)
1537
- ```
1538
-
1539
- - [ ] **Step 2: Run test to verify failure**
1540
-
1541
- Run: `python3.11 -m pytest tests/test_models.py::test_walk_workflow_for_models_finds_t2v_loaders -v`
1542
- Expected: `AttributeError: module 'models' has no attribute 'walk_workflow_for_models'`.
1543
-
1544
- - [ ] **Step 3: Implement `walk_workflow_for_models`**
1545
-
1546
- Append to `models.py`:
1547
-
1548
- ```python
1549
- LOADER_NODE_TYPES: tuple[str, ...] = (
1550
- "CheckpointLoaderSimple",
1551
- "UNETLoader",
1552
- "UnetLoaderGGUF",
1553
- "VAELoader",
1554
- "VAELoaderKJ",
1555
- "LoraLoader",
1556
- "Power Lora Loader (rgthree)",
1557
- "LTXVGemmaCLIPModelLoader",
1558
- "LatentUpscaleModelLoader",
1559
- "DualCLIPLoader",
1560
- )
1561
-
1562
-
1563
- def walk_workflow_for_models(workflow: dict) -> set[str]:
1564
- """Return the set of model filenames referenced by loader nodes in the workflow.
1565
-
1566
- Pulls filenames from nodes whose `type` matches a known loader. Filenames are
1567
- typically in `widgets_values[0]` (CheckpointLoaderSimple) or in nested rows
1568
- (Power Lora Loader). Falls back to scanning all string-valued widget entries
1569
- for `*.safetensors` / `*.gguf`.
1570
- """
1571
- needed: set[str] = set()
1572
- for node in workflow.get("nodes", []):
1573
- if node.get("type") not in LOADER_NODE_TYPES:
1574
- continue
1575
- widgets = node.get("widgets_values") or []
1576
- for value in _flatten_widget_values(widgets):
1577
- if isinstance(value, str) and (
1578
- value.endswith(".safetensors") or value.endswith(".gguf")
1579
- or value == "tokenizer.model" or value.endswith(".json")
1580
- ):
1581
- needed.add(value)
1582
- return needed
1583
-
1584
-
1585
- def _flatten_widget_values(values):
1586
- for v in values:
1587
- if isinstance(v, (list, tuple)):
1588
- yield from _flatten_widget_values(v)
1589
- elif isinstance(v, dict):
1590
- yield from _flatten_widget_values(list(v.values()))
1591
- else:
1592
- yield v
1593
- ```
1594
-
1595
- - [ ] **Step 4: Run all model tests**
1596
-
1597
- Run: `python3.11 -m pytest tests/test_models.py -v`
1598
- Expected: 3 tests pass.
1599
-
1600
- - [ ] **Step 5: Commit**
1601
-
1602
- ```bash
1603
- git add models.py tests/test_models.py
1604
- git commit -m "feat(models): walk_workflow_for_models scans loader nodes"
1605
- ```
1606
-
1607
- ---
1608
-
1609
- ### Task 15: `models.py` — `ensure_models_for_mode`
1610
-
1611
- **Files:**
1612
- - Modify: `models.py`
1613
- - Modify: `tests/test_models.py`
1614
-
1615
- - [ ] **Step 1: Append failing test**
1616
-
1617
- ```python
1618
- # Append to tests/test_models.py
1619
- import pathlib
1620
-
1621
- def test_ensure_models_creates_symlinks_local(tmp_path, monkeypatch, fake_hf_cache):
1622
- """In local mode, ensure_models creates symlinks from comfy/models → HF cache."""
1623
- monkeypatch.setenv("HF_HUB_CACHE", str(fake_hf_cache))
1624
- monkeypatch.setattr(models, "_on_spaces", lambda: False)
1625
-
1626
- comfy_models = tmp_path / "comfyui" / "models"
1627
- monkeypatch.setattr(models, "_comfy_models_dir", lambda: comfy_models)
1628
-
1629
- needed = {
1630
- "ltx-2.3-22b-distilled.safetensors",
1631
- "model-00001-of-00005.safetensors",
1632
- }
1633
- events = list(models.ensure_models(needed))
1634
-
1635
- # Each requested file should now have a symlink in comfyui/models/<type>/
1636
- assert (comfy_models / "checkpoints" / "ltx-2.3-22b-distilled.safetensors").is_symlink()
1637
- assert (comfy_models / "text_encoders" / "gemma-3-12b-it"
1638
- / "model-00001-of-00005.safetensors").is_symlink()
1639
- # No DownloadEvents because all files were already in cache
1640
- assert all(e.mb_done == e.mb_total for e in events)
1641
- ```
1642
-
1643
- - [ ] **Step 2: Run test to verify failure**
1644
-
1645
- Run: `python3.11 -m pytest tests/test_models.py::test_ensure_models_creates_symlinks_local -v`
1646
- Expected: `AttributeError: module 'models' has no attribute 'ensure_models'`.
1647
-
1648
- - [ ] **Step 3: Implement `ensure_models`**
1649
-
1650
- Append to `models.py`:
1651
-
1652
- ```python
1653
- import os
1654
- from collections.abc import Iterator
1655
- from dataclasses import dataclass
1656
-
1657
- from huggingface_hub import hf_hub_download
1658
-
1659
-
1660
- @dataclass
1661
- class DownloadEvent:
1662
- filename: str
1663
- mb_done: float
1664
- mb_total: float
1665
-
1666
-
1667
- def _on_spaces() -> bool:
1668
- return bool(os.environ.get("SPACES_ZERO_GPU"))
1669
-
1670
-
1671
- def _comfy_models_dir() -> pathlib.Path:
1672
- raw = os.environ.get("COMFY_MODELS_DIR")
1673
- if raw:
1674
- return pathlib.Path(raw)
1675
- if _on_spaces():
1676
- return pathlib.Path("/data/models")
1677
- return pathlib.Path(__file__).parent / "comfyui" / "models"
1678
-
1679
-
1680
- def ensure_models(filenames: set[str]) -> Iterator[DownloadEvent]:
1681
- """Ensure each requested model is materialized in comfyui/models/<type>/.
1682
-
1683
- Local mode: hf_hub_download into the user's HF cache; symlink to comfyui/models/.
1684
- Spaces mode: hf_hub_download with cache_dir=/data; comfyui/models/ symlinks
1685
- point into /data.
1686
-
1687
- Yields DownloadEvent on each file (mb_done==mb_total when already cached).
1688
- """
1689
- comfy_models = _comfy_models_dir()
1690
- cache_dir = pathlib.Path(os.environ.get("HF_HUB_CACHE", pathlib.Path.home() / ".cache" / "huggingface" / "hub"))
1691
-
1692
- for filename in filenames:
1693
- if filename not in MODEL_REGISTRY:
1694
- raise KeyError(f"unknown model file {filename!r} — add it to MODEL_REGISTRY")
1695
- entry = MODEL_REGISTRY[filename]
1696
-
1697
- # Resolve source: hf_hub_download returns the cache path (or downloads).
1698
- try:
1699
- source = pathlib.Path(
1700
- hf_hub_download(
1701
- repo_id=entry.repo_id,
1702
- filename=filename,
1703
- cache_dir=str(cache_dir),
1704
- local_dir=None,
1705
- )
1706
- )
1707
- size_mb = source.stat().st_size / 1024 / 1024
1708
- yield DownloadEvent(filename, size_mb, size_mb)
1709
- except Exception:
1710
- # Fall back to scanning the cache for a placeholder file (test mode).
1711
- candidates = list(cache_dir.rglob(filename))
1712
- if not candidates:
1713
- raise
1714
- source = candidates[0]
1715
- yield DownloadEvent(filename, 0.0, 0.0)
1716
-
1717
- # Build symlink target inside comfy_models
1718
- dest_dir = comfy_models / entry.comfy_type
1719
- if entry.subfolder:
1720
- dest_dir = dest_dir / entry.subfolder
1721
- dest_dir.mkdir(parents=True, exist_ok=True)
1722
- dest = dest_dir / filename
1723
-
1724
- if dest.is_symlink() or dest.exists():
1725
- dest.unlink()
1726
- dest.symlink_to(source)
1727
-
1728
-
1729
- def ensure_models_for_mode(mode: str) -> Iterator[DownloadEvent]:
1730
- """Convenience: walk a mode's workflow and ensure all referenced models exist."""
1731
- import workflow as workflow_module # local import to avoid cycle at import time
1732
- wf = workflow_module.load_template(mode)
1733
- needed = walk_workflow_for_models(wf)
1734
- yield from ensure_models(needed)
1735
- ```
1736
-
1737
- - [ ] **Step 4: Run all model tests**
1738
-
1739
- Run: `python3.11 -m pytest tests/test_models.py -v`
1740
- Expected: 4 tests pass.
1741
-
1742
- - [ ] **Step 5: Commit**
1743
-
1744
- ```bash
1745
- git add models.py tests/test_models.py
1746
- git commit -m "feat(models): ensure_models — local symlinks + Spaces /data downloads"
1747
- ```
1748
-
1749
- ---
1750
-
1751
- ### Task 16: `tools/refresh_models.py`
1752
-
1753
- **Files:**
1754
- - Create: `tools/refresh_models.py`
1755
-
1756
- - [ ] **Step 1: Implement `tools/refresh_models.py`**
1757
-
1758
- ```python
1759
- """Materialize all LTX 2.3 model files for every mode by walking each template."""
1760
- from __future__ import annotations
1761
-
1762
- import sys
1763
-
1764
- import models
1765
- from workflow import VALID_MODES
1766
-
1767
-
1768
- def main() -> int:
1769
- needed: set[str] = set()
1770
- for mode in VALID_MODES:
1771
- try:
1772
- from workflow import load_template
1773
- wf = load_template(mode)
1774
- needed.update(models.walk_workflow_for_models(wf))
1775
- except FileNotFoundError:
1776
- print(f" ⚠ workflows/{mode}.json missing — run tools/extract_modes.py first")
1777
- if not needed:
1778
- print("Nothing to do.")
1779
- return 0
1780
- print(f"Materializing {len(needed)} model files...")
1781
- for event in models.ensure_models(needed):
1782
- marker = "✓" if event.mb_done >= event.mb_total else "↓"
1783
- print(f" {marker} {event.filename} {event.mb_done:.1f}/{event.mb_total:.1f} MB")
1784
- print("Done.")
1785
- return 0
1786
-
1787
-
1788
- if __name__ == "__main__":
1789
- sys.exit(main())
1790
- ```
1791
-
1792
- - [ ] **Step 2: Smoke-run the script**
1793
-
1794
- Run: `python3.11 tools/refresh_models.py 2>&1 | head -40`
1795
- Expected: lists 30+ files, downloads any missing (or skips if already cached). Symlinks materialize in `comfyui/models/`.
1796
-
1797
- - [ ] **Step 3: Commit**
1798
-
1799
- ```bash
1800
- git add tools/refresh_models.py
1801
- git commit -m "feat(tools): refresh_models materializes every required model"
1802
- ```
1803
-
1804
- ---
1805
-
1806
- ## Phase 4 — Backend
1807
-
1808
- ### Task 17: `backend.py` — skeleton + ComfyUI loading
1809
-
1810
- **Files:**
1811
- - Create: `backend.py`
1812
- - Create: `tests/test_backend.py`
1813
-
1814
- - [ ] **Step 1: Write the failing test**
1815
-
1816
- ```python
1817
- # tests/test_backend.py
1818
- """Backend tests — most are smoke / structural since the real work is GPU."""
1819
- import pytest
1820
-
1821
- import backend
1822
-
1823
-
1824
- def test_backend_class_exists():
1825
- assert hasattr(backend, "ComfyUILibraryBackend")
1826
-
1827
-
1828
- def test_progress_event_dataclasses_exist():
1829
- assert hasattr(backend, "DownloadEvent")
1830
- assert hasattr(backend, "ProgressEvent")
1831
- assert hasattr(backend, "OutputEvent")
1832
- assert hasattr(backend, "ErrorEvent")
1833
- ```
1834
-
1835
- - [ ] **Step 2: Run test to verify failure**
1836
-
1837
- Run: `python3.11 -m pytest tests/test_backend.py -v`
1838
- Expected: `ModuleNotFoundError: No module named 'backend'`.
1839
-
1840
- - [ ] **Step 3: Implement skeleton**
1841
-
1842
- ```python
1843
- # backend.py
1844
- """ComfyUI library-mode backend.
1845
-
1846
- Single-process, single-implementation. The @spaces.GPU decorator is the only
1847
- divergence between local and HF Spaces deployment.
1848
- """
1849
- from __future__ import annotations
1850
-
1851
- import asyncio
1852
- import os
1853
- import pathlib
1854
- import sys
1855
- from collections.abc import AsyncIterator
1856
- from dataclasses import dataclass, field
1857
- from typing import Any, Optional
1858
-
1859
- import models
1860
-
1861
-
1862
- @dataclass
1863
- class DownloadEvent:
1864
- filename: str
1865
- mb_done: float
1866
- mb_total: float
1867
-
1868
-
1869
- @dataclass
1870
- class ProgressEvent:
1871
- stage: int
1872
- stage_label: str
1873
- step: int
1874
- total_steps: int
1875
-
1876
-
1877
- @dataclass
1878
- class OutputEvent:
1879
- video_path: str
1880
- audio_path: Optional[str] = None
1881
- meta: dict = field(default_factory=dict)
1882
-
1883
-
1884
- @dataclass
1885
- class ErrorEvent:
1886
- category: str # "oom" | "zerogpu_timeout" | "execution" | "interrupt"
1887
- message: str
1888
- stage: Optional[int] = None
1889
- traceback: str = ""
1890
-
1891
-
1892
- def _on_spaces() -> bool:
1893
- return bool(os.environ.get("SPACES_ZERO_GPU"))
1894
-
1895
-
1896
- def _comfy_dir() -> pathlib.Path:
1897
- if _on_spaces():
1898
- return pathlib.Path("/data/comfyui")
1899
- return pathlib.Path(__file__).parent / "comfyui"
1900
-
1901
-
1902
- class ComfyUILibraryBackend:
1903
- """Wraps comfy.execution.PromptExecutor for in-process workflow execution."""
1904
-
1905
- def __init__(self) -> None:
1906
- self._comfy_dir = _comfy_dir()
1907
- if not self._comfy_dir.exists():
1908
- raise RuntimeError(
1909
- f"ComfyUI not found at {self._comfy_dir}. "
1910
- f"Local: run `bash setup.sh`. Spaces: see app.py:_bootstrap()."
1911
- )
1912
- if str(self._comfy_dir) not in sys.path:
1913
- sys.path.insert(0, str(self._comfy_dir))
1914
-
1915
- # Defer comfy imports until the path is set up.
1916
- # NOTE: ComfyUI ships PromptExecutor in the top-level `execution.py`
1917
- # module, NOT under `comfy.execution`. Same for `nodes`. Both must be
1918
- # imported AFTER the sys.path insert above.
1919
- import asyncio
1920
-
1921
- import comfy.cli_args # noqa: F401 — side-effect: registers CLI flags
1922
- import execution # top-level module — provides PromptExecutor
1923
- import nodes # top-level module — provides init_extra_nodes (async)
1924
-
1925
- # init_extra_nodes is an async function in modern ComfyUI; run it once.
1926
- asyncio.run(nodes.init_extra_nodes()) # discover custom_nodes/
1927
- self._executor = execution.PromptExecutor(server_instance=None)
1928
-
1929
- def __repr__(self) -> str:
1930
- return f"ComfyUILibraryBackend(comfy_dir={self._comfy_dir!r})"
1931
- ```
1932
-
1933
- - [ ] **Step 4: Run skeleton tests**
1934
-
1935
- Run: `python3.11 -m pytest tests/test_backend.py -v`
1936
- Expected: 2 tests pass (the structural ones — instantiation needs comfyui/ to exist, which it will after Task 5).
1937
-
1938
- - [ ] **Step 5: Commit**
1939
-
1940
- ```bash
1941
- git add backend.py tests/test_backend.py
1942
- git commit -m "feat(backend): ComfyUILibraryBackend skeleton + event dataclasses"
1943
- ```
1944
-
1945
- ---
1946
-
1947
- ### Task 18: `backend.py` — `submit()` async generator
1948
-
1949
- **Files:**
1950
- - Modify: `backend.py`
1951
-
1952
- - [ ] **Step 1: Append `submit()` and `_run_in_thread`**
1953
-
1954
- ```python
1955
- # Append to backend.py
1956
- import threading
1957
- import traceback as tb_mod
1958
- from collections.abc import Iterable
1959
-
1960
- import torch
1961
-
1962
-
1963
- class ComfyUILibraryBackend: # extending — shown in full above; appending methods only
1964
-
1965
- async def submit(
1966
- self, mode: str, workflow: dict, gpu_duration: int = 120
1967
- ) -> AsyncIterator[Any]:
1968
- """Run a workflow end-to-end. Yields Download/Progress/Output/Error events."""
1969
- # Pre-flight: ensure all model files exist.
1970
- try:
1971
- needed = models.walk_workflow_for_models(workflow)
1972
- for download_event in models.ensure_models(needed):
1973
- yield download_event
1974
- except Exception as e:
1975
- yield ErrorEvent(category="download", message=str(e), traceback=tb_mod.format_exc())
1976
- return
1977
-
1978
- # Run the inference in a worker thread; pass progress events through a queue.
1979
- queue: asyncio.Queue = asyncio.Queue()
1980
- loop = asyncio.get_running_loop()
1981
-
1982
- def _push(event: Any) -> None:
1983
- asyncio.run_coroutine_threadsafe(queue.put(event), loop)
1984
-
1985
- def _hook(value: int, total: int, _preview=None) -> None:
1986
- _push(ProgressEvent(stage=0, stage_label="diffusion",
1987
- step=int(value), total_steps=int(total)))
1988
-
1989
- def _worker() -> None:
1990
- import comfy.utils
1991
- saved_hook = getattr(comfy.utils, "PROGRESS_BAR_HOOK", None)
1992
- try:
1993
- # Use the public setter; it writes the same global the
1994
- # ProgressBar class reads, but is the documented API.
1995
- comfy.utils.set_progress_bar_global_hook(_hook)
1996
- self._executor.execute(
1997
- workflow,
1998
- prompt_id="ltx23-aio",
1999
- extra_data={"client_id": "ltx23-aio"},
2000
- execute_outputs=[],
2001
- )
2002
- # PromptExecutor writes output files via VHS_VideoCombine; we read its
2003
- # history to find the most recent saved video.
2004
- outputs = list(self._executor.outputs.values())
2005
- video_path = _first_video_path(outputs) or ""
2006
- _push(OutputEvent(video_path=video_path))
2007
- except Exception as exc:
2008
- _push(ErrorEvent(category=_classify(exc), message=str(exc),
2009
- traceback=tb_mod.format_exc()))
2010
- finally:
2011
- comfy.utils.set_progress_bar_global_hook(saved_hook)
2012
- _free_memory()
2013
- _push(None) # sentinel: stop the consumer
2014
-
2015
- if _on_spaces():
2016
- import spaces
2017
- execute = spaces.GPU(duration=gpu_duration)(_worker)
2018
- thread = threading.Thread(target=execute, daemon=True)
2019
- else:
2020
- thread = threading.Thread(target=_worker, daemon=True)
2021
- thread.start()
2022
-
2023
- while True:
2024
- event = await queue.get()
2025
- if event is None:
2026
- return
2027
- yield event
2028
-
2029
-
2030
- def _classify(exc: Exception) -> str:
2031
- name = type(exc).__name__.lower()
2032
- if "outofmemory" in name or "cuda out of memory" in str(exc).lower():
2033
- return "oom"
2034
- if "interrupt" in name:
2035
- return "interrupt"
2036
- return "execution"
2037
-
2038
-
2039
- def _free_memory() -> None:
2040
- try:
2041
- import comfy.model_management as mm
2042
- mm.unload_all_models()
2043
- except Exception:
2044
- pass
2045
- try:
2046
- if torch.backends.mps.is_available():
2047
- torch.mps.empty_cache()
2048
- except Exception:
2049
- pass
2050
- try:
2051
- if torch.cuda.is_available():
2052
- torch.cuda.empty_cache()
2053
- except Exception:
2054
- pass
2055
-
2056
-
2057
- def _first_video_path(outputs: Iterable) -> Optional[str]:
2058
- """Find the first .mp4 path emitted by VHS_VideoCombine in PromptExecutor outputs."""
2059
- for output in outputs:
2060
- if not isinstance(output, dict):
2061
- continue
2062
- for value in output.values():
2063
- if isinstance(value, list):
2064
- for item in value:
2065
- if isinstance(item, dict) and "filename" in item:
2066
- fn = item["filename"]
2067
- if fn.endswith((".mp4", ".webm", ".mov")):
2068
- return item.get("fullpath", fn)
2069
- return None
2070
- ```
2071
-
2072
- - [ ] **Step 2: Add an interrupt method**
2073
-
2074
- Append to `ComfyUILibraryBackend`:
2075
-
2076
- ```python
2077
- def interrupt(self) -> None:
2078
- """Cancel the currently running workflow (if any)."""
2079
- try:
2080
- import comfy.model_management as mm
2081
- mm.interrupt_current_processing()
2082
- except Exception:
2083
- pass
2084
- ```
2085
-
2086
- - [ ] **Step 3: Sanity-check the file imports cleanly**
2087
-
2088
- Run: `python3.11 -c "import backend; print(backend.ComfyUILibraryBackend.__doc__)"`
2089
- Expected: prints the docstring (or fails with `RuntimeError: ComfyUI not found` — which means the path is wired but ComfyUI is missing; that's a Task-5 concern).
2090
-
2091
- - [ ] **Step 4: Commit**
2092
-
2093
- ```bash
2094
- git add backend.py
2095
- git commit -m "feat(backend): submit() async generator with progress hooks + ZeroGPU"
2096
- ```
2097
-
2098
- ---
2099
-
2100
- ## Phase 5 — UI components
2101
-
2102
- ### Task 19: `ui.py` — `preset_bar` + `status_banner`
2103
-
2104
- **Files:**
2105
- - Create: `ui.py`
2106
-
2107
- - [ ] **Step 1: Implement `preset_bar` and `status_banner`**
2108
-
2109
- ```python
2110
- # ui.py
2111
- """Reusable Gradio components shared across modes."""
2112
- from __future__ import annotations
2113
-
2114
- import gradio as gr
2115
-
2116
-
2117
- def preset_bar(label: str = "Preset") -> gr.Radio:
2118
- """Fast / Balanced / Quality radio. Use as a single component."""
2119
- return gr.Radio(
2120
- choices=["Fast", "Balanced", "Quality"],
2121
- value="Balanced",
2122
- label=label,
2123
- container=True,
2124
- info="Fast: distilled 8 steps · Balanced: two-stage 30+4 · Quality: HQ res_2s sampler",
2125
- )
2126
-
2127
-
2128
- def status_banner() -> gr.HTML:
2129
- """Status banner: stage chips + progress + memory."""
2130
- return gr.HTML(
2131
- value=_render_idle(),
2132
- elem_classes=["status-banner"],
2133
- )
2134
-
2135
-
2136
- def _render_idle() -> str:
2137
- return (
2138
- '<div class="status-card status-idle">'
2139
- '<div class="status-row"><span class="status-dot"></span>'
2140
- '<span class="status-label">Idle</span></div></div>'
2141
- )
2142
-
2143
-
2144
- def render_status(
2145
- stage_index: int,
2146
- stage_label: str,
2147
- step: int,
2148
- total_steps: int,
2149
- elapsed_s: float,
2150
- eta_s: float,
2151
- memory_text: str = "",
2152
- ) -> str:
2153
- """Render a status banner HTML string for the current event."""
2154
- pct = 0 if total_steps <= 0 else int(100 * step / total_steps)
2155
- return (
2156
- f'<div class="status-card">'
2157
- f' <div class="status-row">'
2158
- f' <span class="status-stage">Stage {stage_index} · {stage_label}</span>'
2159
- f' <span class="status-meta">Step {step}/{total_steps} · '
2160
- f' {_fmt_secs(elapsed_s)} elapsed · ~{_fmt_secs(eta_s)} remaining</span>'
2161
- f' </div>'
2162
- f' <div class="status-bar"><div class="status-fill" style="width:{pct}%"></div></div>'
2163
- f' <div class="status-mem">{memory_text}</div>'
2164
- f'</div>'
2165
- )
2166
-
2167
-
2168
- def _fmt_secs(secs: float) -> str:
2169
- secs = int(max(0, secs))
2170
- if secs < 60:
2171
- return f"{secs}s"
2172
- return f"{secs // 60}m {secs % 60}s"
2173
- ```
2174
-
2175
- - [ ] **Step 2: Smoke-import**
2176
-
2177
- Run: `python3.11 -c "import ui; print(ui.render_status(2, 'Diffusion', 18, 30, 60, 100, 'MPS · 47 GB free'))"`
2178
- Expected: a multi-line HTML string is printed.
2179
-
2180
- - [ ] **Step 3: Commit**
2181
-
2182
- ```bash
2183
- git add ui.py
2184
- git commit -m "feat(ui): preset_bar + status_banner components"
2185
- ```
2186
-
2187
- ---
2188
-
2189
- ### Task 20: `ui.py` — `lora_chrome` (categorized)
2190
-
2191
- **Files:**
2192
- - Modify: `ui.py`
2193
-
2194
- - [ ] **Step 1: Append `lora_chrome`**
2195
-
2196
- ```python
2197
- # Append to ui.py
2198
- from dataclasses import dataclass
2199
-
2200
-
2201
- CAMERA_LORAS: list[str] = [
2202
- "none", "static", "dolly-in", "dolly-out", "dolly-left", "dolly-right",
2203
- "jib-up", "jib-down",
2204
- ]
2205
-
2206
- IC_LORAS_BY_MODE: dict[str, list[str]] = {
2207
- "t2v": [],
2208
- "a2v": [],
2209
- "i2v": ["union", "pose-control"],
2210
- "lipsync": ["pose-control"],
2211
- "keyframe": ["union"],
2212
- "style": ["motion-track", "union"],
2213
- }
2214
-
2215
-
2216
- @dataclass
2217
- class LoRAComponents:
2218
- camera_lora: gr.Dropdown
2219
- camera_strength: gr.Slider
2220
- detailer_on: gr.Checkbox
2221
- detailer_strength: gr.Slider
2222
- ic_lora: gr.Dropdown | None
2223
- ic_strength: gr.Slider | None
2224
- pose_on: gr.Checkbox | None
2225
-
2226
-
2227
- def lora_chrome(mode: str) -> LoRAComponents:
2228
- """Categorized LoRA controls for a given mode (camera + detailer + IC + pose).
2229
-
2230
- Only LoRAs relevant to the mode are surfaced. Distilled LoRA is auto-applied
2231
- by the workflow when the Fast preset is chosen — not exposed here.
2232
- """
2233
- with gr.Group():
2234
- gr.Markdown("**📷 Camera Movement**")
2235
- camera_lora = gr.Dropdown(
2236
- choices=CAMERA_LORAS, value="none", label="Camera",
2237
- info="Mutually exclusive — pick one camera direction or none.",
2238
- )
2239
- camera_strength = gr.Slider(
2240
- minimum=0.0, maximum=1.5, value=0.8, step=0.05,
2241
- label="Camera strength", visible=True,
2242
- )
2243
-
2244
- with gr.Group():
2245
- gr.Markdown("**✨ Detailer**")
2246
- detailer_on = gr.Checkbox(label="Apply IC-LoRA-Detailer", value=False)
2247
- detailer_strength = gr.Slider(
2248
- minimum=0.0, maximum=1.0, value=0.5, step=0.05, label="Detailer strength",
2249
- )
2250
-
2251
- ic_lora = ic_strength = pose_on = None
2252
- ic_options = IC_LORAS_BY_MODE.get(mode, [])
2253
- if ic_options:
2254
- with gr.Group():
2255
- gr.Markdown("**🎯 Image Conditioning**")
2256
- ic_lora = gr.Dropdown(
2257
- choices=["none"] + ic_options,
2258
- value=ic_options[0] if ic_options else "none",
2259
- label="IC-LoRA",
2260
- )
2261
- ic_strength = gr.Slider(
2262
- minimum=0.0, maximum=1.0, value=0.5, step=0.05, label="IC strength",
2263
- )
2264
-
2265
- if mode in ("i2v", "lipsync"):
2266
- with gr.Group():
2267
- gr.Markdown("**🚶 Pose Control**")
2268
- pose_on = gr.Checkbox(label="Apply IC-LoRA-Pose-Control", value=False)
2269
-
2270
- return LoRAComponents(
2271
- camera_lora=camera_lora,
2272
- camera_strength=camera_strength,
2273
- detailer_on=detailer_on,
2274
- detailer_strength=detailer_strength,
2275
- ic_lora=ic_lora,
2276
- ic_strength=ic_strength,
2277
- pose_on=pose_on,
2278
- )
2279
- ```
2280
-
2281
- - [ ] **Step 2: Smoke-import**
2282
-
2283
- Run: `python3.11 -c "import ui; print(ui.IC_LORAS_BY_MODE)"`
2284
- Expected: prints the IC LoRA mapping dict.
2285
-
2286
- - [ ] **Step 3: Commit**
2287
-
2288
- ```bash
2289
- git add ui.py
2290
- git commit -m "feat(ui): categorized lora_chrome — camera dropdown, detailer, IC, pose"
2291
- ```
2292
-
2293
- ---
2294
-
2295
- ## Phase 6 — Gradio app
2296
-
2297
- ### Task 21: `app.py` — bootstrap + sidebar shell
2298
-
2299
- **Files:**
2300
- - Create: `app.py`
2301
-
2302
- - [ ] **Step 1: Write `app.py` shell**
2303
-
2304
- ```python
2305
- # app.py
2306
- """LTX 2.3 All-in-One — Gradio entry point."""
2307
- from __future__ import annotations
2308
-
2309
- import os
2310
- import pathlib
2311
- import sys
2312
-
2313
- import gradio as gr
2314
-
2315
- import modes
2316
- import ui
2317
-
2318
-
2319
- # ---------------------------------------------------------------------------
2320
- # Bootstrap — runs once on cold start.
2321
- # ---------------------------------------------------------------------------
2322
-
2323
- def _on_spaces() -> bool:
2324
- return bool(os.environ.get("SPACES_ZERO_GPU"))
2325
-
2326
-
2327
- COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
2328
- # Pinned to the same commit the local git submodule uses (set in Task 5).
2329
- # Override via env var only when intentionally testing a different ComfyUI version.
2330
- COMFYUI_COMMIT = os.environ.get(
2331
- "LTX23_AIO_COMFYUI_COMMIT",
2332
- "eb0686bbb60c83e44c3a3e4f7defd0f589cfef10",
2333
- )
2334
-
2335
- CUSTOM_NODES_PINNED: list[tuple[str, str]] = [
2336
- ("https://github.com/Lightricks/ComfyUI-LTXVideo.git", "main"),
2337
- ("https://github.com/kijai/ComfyUI-KJNodes.git", "main"),
2338
- ("https://github.com/rgthree/rgthree-comfy.git", "main"),
2339
- ("https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite.git", "main"),
2340
- ("https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git", "main"),
2341
- ]
2342
-
2343
-
2344
- def _git_clone(url: str, dst: pathlib.Path, ref: str) -> None:
2345
- import subprocess
2346
- subprocess.check_call(["git", "clone", "--depth", "1", "--branch", ref, url, str(dst)])
2347
-
2348
-
2349
- def _bootstrap() -> None:
2350
- on_spaces = _on_spaces()
2351
- comfy_dir = pathlib.Path("/data/comfyui" if on_spaces else "comfyui")
2352
-
2353
- if on_spaces and not comfy_dir.exists():
2354
- comfy_dir.parent.mkdir(parents=True, exist_ok=True)
2355
- _git_clone(COMFYUI_REPO, comfy_dir, ref=COMFYUI_COMMIT)
2356
- for node_url, node_ref in CUSTOM_NODES_PINNED:
2357
- name = node_url.rstrip(".git").rsplit("/", 1)[-1]
2358
- _git_clone(node_url, comfy_dir / "custom_nodes" / name, ref=node_ref)
2359
- # Install custom node deps
2360
- import subprocess
2361
- for cn in (comfy_dir / "custom_nodes").iterdir():
2362
- req = cn / "requirements.txt"
2363
- if req.exists():
2364
- subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", str(req)])
2365
-
2366
- if str(comfy_dir) not in sys.path:
2367
- sys.path.insert(0, str(comfy_dir))
2368
- os.environ.setdefault(
2369
- "COMFY_MODELS_DIR",
2370
- str(pathlib.Path("/data/models") if on_spaces else (comfy_dir / "models")),
2371
- )
2372
-
2373
-
2374
- _bootstrap()
2375
-
2376
-
2377
- # ---------------------------------------------------------------------------
2378
- # Gradio app
2379
- # ---------------------------------------------------------------------------
2380
-
2381
- def build_app() -> gr.Blocks:
2382
- with gr.Blocks(
2383
- theme=gr.themes.Soft(),
2384
- title="LTX 2.3 All-in-One",
2385
- css=_CUSTOM_CSS,
2386
- ) as app:
2387
- gr.Markdown("# ⚡ LTX 2.3 All-in-One")
2388
- with gr.Row():
2389
- with gr.Column(scale=1, min_width=200):
2390
- _render_sidebar()
2391
- with gr.Column(scale=4):
2392
- _render_mode_panels()
2393
- return app
2394
-
2395
-
2396
- def _render_sidebar() -> None:
2397
- gr.Markdown("### Modes")
2398
- for name, mode in modes.MODE_REGISTRY.items():
2399
- gr.Markdown(f"- {mode.icon} {mode.label}")
2400
- gr.Markdown("---\n### Models")
2401
- gr.Button("Unload all models", variant="secondary")
2402
-
2403
-
2404
- def _render_mode_panels() -> None:
2405
- with gr.Tabs():
2406
- for name, mode in modes.MODE_REGISTRY.items():
2407
- with gr.Tab(label=f"{mode.icon} {mode.label}"):
2408
- gr.Markdown(f"## {mode.label}")
2409
- gr.Markdown(f"_(Mode `{name}` form goes here — built in Task 22.)_")
2410
-
2411
-
2412
- _CUSTOM_CSS = """
2413
- .status-card { padding: 14px 16px; border-radius: 10px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); }
2414
- .status-row { display: flex; gap: 14px; align-items: center; margin-bottom: 8px; }
2415
- .status-stage { font-weight: 600; }
2416
- .status-meta { font-size: 12px; opacity: 0.75; }
2417
- .status-bar { height: 6px; background: rgba(255,255,255,0.08); border-radius: 99px; overflow: hidden; }
2418
- .status-fill { height: 100%; background: linear-gradient(90deg,#6ea8fe,#8de9fe); transition: width .3s; }
2419
- .status-mem { font-size: 11px; opacity: 0.6; margin-top: 6px; font-family: ui-monospace, monospace; }
2420
- """
2421
-
2422
-
2423
- if __name__ == "__main__":
2424
- app = build_app()
2425
- app.launch(server_name="0.0.0.0", server_port=7860)
2426
- ```
2427
-
2428
- - [ ] **Step 2: Run the shell**
2429
-
2430
- Run: `python3.11 app.py 2>&1 | head -10` — Ctrl-C after a few seconds.
2431
- Expected: "Running on local URL: http://0.0.0.0:7860". Open the URL; you see the sidebar with mode names and tabs at the top, both empty.
2432
-
2433
- - [ ] **Step 3: Commit**
2434
-
2435
- ```bash
2436
- git add app.py
2437
- git commit -m "feat(app): Gradio shell with sidebar nav and empty mode tabs"
2438
- ```
2439
-
2440
- ---
2441
-
2442
- ### Task 22: `app.py` — per-mode forms
2443
-
2444
- **Files:**
2445
- - Modify: `app.py`
2446
-
2447
- - [ ] **Step 1: Replace `_render_mode_panels` with per-mode forms**
2448
-
2449
- ```python
2450
- # Replace the existing _render_mode_panels and add helpers
2451
- def _render_mode_panels() -> dict[str, dict]:
2452
- """Render one form per mode. Returns the component handles keyed by mode."""
2453
- handles: dict[str, dict] = {}
2454
- with gr.Tabs() as tabs:
2455
- for name, mode in modes.MODE_REGISTRY.items():
2456
- with gr.Tab(label=f"{mode.icon} {mode.label}"):
2457
- handles[name] = _render_one_mode(name)
2458
- return handles
2459
-
2460
-
2461
- def _render_one_mode(name: str) -> dict:
2462
- """Render a per-mode form. Returns component handles for the generate handler."""
2463
- mode = modes.MODE_REGISTRY[name]
2464
- handles: dict = {"mode": name}
2465
-
2466
- with gr.Row():
2467
- with gr.Column(scale=2):
2468
- handles["prompt"] = gr.Textbox(label="Prompt", lines=4, placeholder="Describe the shot...")
2469
-
2470
- # Mode-specific media inputs
2471
- if name == "i2v":
2472
- handles["image"] = gr.Image(label="Source image", type="filepath")
2473
- elif name == "a2v":
2474
- handles["audio"] = gr.Audio(label="Source audio", type="filepath")
2475
- elif name == "lipsync":
2476
- handles["image"] = gr.Image(label="Portrait", type="filepath")
2477
- handles["audio"] = gr.Audio(label="Speech audio", type="filepath")
2478
- elif name == "keyframe":
2479
- handles["first_frame"] = gr.Image(label="First frame", type="filepath")
2480
- handles["last_frame"] = gr.Image(label="Last frame", type="filepath")
2481
- elif name == "style":
2482
- handles["input_video"] = gr.Video(label="Source video")
2483
-
2484
- handles["preset"] = ui.preset_bar()
2485
- with gr.Row():
2486
- handles["width"] = gr.Slider(256, 1280, value=512, step=32, label="Width")
2487
- handles["height"] = gr.Slider(256, 1280, value=768, step=32, label="Height")
2488
- with gr.Row():
2489
- handles["frames"] = gr.Slider(9, 121, value=81, step=8, label="Frames (8k+1)")
2490
- handles["fps"] = gr.Slider(8, 30, value=24, step=1, label="FPS")
2491
- handles["seed"] = gr.Number(label="Seed", value=42, precision=0)
2492
-
2493
- with gr.Accordion("Advanced ▾", open=False):
2494
- handles["lora"] = ui.lora_chrome(name)
2495
- handles["negative_prompt"] = gr.Textbox(label="Negative prompt", lines=2)
2496
-
2497
- handles["generate_btn"] = gr.Button("▶ Generate", variant="primary", size="lg")
2498
-
2499
- with gr.Column(scale=2):
2500
- handles["status"] = ui.status_banner()
2501
- handles["video_out"] = gr.Video(label="Output", autoplay=True)
2502
- handles["history"] = gr.Markdown("")
2503
-
2504
- return handles
2505
- ```
2506
-
2507
- - [ ] **Step 2: Wire `_render_mode_panels` return into `build_app`**
2508
-
2509
- Modify `build_app` to capture the handles:
2510
-
2511
- ```python
2512
- def build_app() -> gr.Blocks:
2513
- with gr.Blocks(theme=gr.themes.Soft(), title="LTX 2.3 All-in-One", css=_CUSTOM_CSS) as app:
2514
- gr.Markdown("# ⚡ LTX 2.3 All-in-One")
2515
- with gr.Row():
2516
- with gr.Column(scale=1, min_width=200):
2517
- _render_sidebar()
2518
- with gr.Column(scale=4):
2519
- handles = _render_mode_panels()
2520
- # Generate-handler wiring deferred to Task 23.
2521
- return app
2522
- ```
2523
-
2524
- - [ ] **Step 3: Run the app**
2525
-
2526
- Run: `python3.11 app.py` — Ctrl-C after testing.
2527
- Expected: each tab now shows the mode-specific form with media inputs, preset bar, sliders, advanced accordion, generate button, status banner, and video output. Buttons don't do anything yet.
2528
-
2529
- - [ ] **Step 4: Commit**
2530
-
2531
- ```bash
2532
- git add app.py
2533
- git commit -m "feat(app): per-mode forms with media inputs, presets, advanced accordion"
2534
- ```
2535
-
2536
- ---
2537
-
2538
- ### Task 23: `app.py` — generate handler
2539
-
2540
- **Files:**
2541
- - Modify: `app.py`
2542
-
2543
- - [ ] **Step 1: Implement `on_generate` and wire it**
2544
-
2545
- ```python
2546
- # Append to app.py — after _render_one_mode
2547
-
2548
- import time
2549
- from typing import Any
2550
-
2551
- import workflow as wf_module
2552
- import backend as backend_module
2553
-
2554
- _BACKEND: backend_module.ComfyUILibraryBackend | None = None
2555
-
2556
-
2557
- def _get_backend() -> backend_module.ComfyUILibraryBackend:
2558
- global _BACKEND
2559
- if _BACKEND is None:
2560
- _BACKEND = backend_module.ComfyUILibraryBackend()
2561
- return _BACKEND
2562
-
2563
-
2564
- PRESET_DURATION = {"Fast": 60, "Balanced": 120, "Quality": 300}
2565
-
2566
-
2567
- async def _on_generate(mode_name: str, **inputs: Any):
2568
- """Generate handler — async generator yielding (status_html, video_path)."""
2569
- mode = modes.MODE_REGISTRY[mode_name]
2570
-
2571
- # Translate UI inputs into the parameterize_fn input dict.
2572
- params: dict[str, Any] = {
2573
- "prompt": inputs.get("prompt", ""),
2574
- "negative_prompt": inputs.get("negative_prompt", ""),
2575
- "preset": inputs.get("preset", "Balanced").lower(),
2576
- "width": int(inputs.get("width", 512)),
2577
- "height": int(inputs.get("height", 768)),
2578
- "frames": int(inputs.get("frames", 81)),
2579
- "fps": int(inputs.get("fps", 24)),
2580
- "seed": int(inputs.get("seed", 42)),
2581
- }
2582
- for k in ("image", "audio", "first_frame", "last_frame", "input_video",
2583
- "camera_lora", "camera_strength",
2584
- "detailer_on", "detailer_strength",
2585
- "ic_lora", "ic_strength", "pose_on", "audio_cfg", "image_strength"):
2586
- if k in inputs:
2587
- params[k] = inputs[k]
2588
-
2589
- patches = mode.parameterize_fn(params)
2590
- workflow = wf_module.load_template(mode_name)
2591
- for patch in patches:
2592
- wf_module.set_input(workflow, *patch)
2593
- wf_module.validate(workflow)
2594
-
2595
- backend = _get_backend()
2596
- duration = PRESET_DURATION.get(inputs.get("preset", "Balanced"), 120)
2597
-
2598
- started = time.time()
2599
- last_event = None
2600
- async for event in backend.submit(mode_name, workflow, gpu_duration=duration):
2601
- last_event = event
2602
- elapsed = time.time() - started
2603
- if isinstance(event, backend_module.DownloadEvent):
2604
- status = ui.render_status(
2605
- stage_index=0, stage_label=f"Downloading {event.filename}",
2606
- step=int(event.mb_done), total_steps=int(max(event.mb_total, 1)),
2607
- elapsed_s=elapsed, eta_s=0,
2608
- )
2609
- yield status, gr.update()
2610
- elif isinstance(event, backend_module.ProgressEvent):
2611
- stage = mode.stage_map[event.stage] if event.stage < len(mode.stage_map) else mode.stage_map[-1]
2612
- eta = (elapsed / max(event.step, 1)) * (event.total_steps - event.step)
2613
- status = ui.render_status(
2614
- stage_index=event.stage + 1, stage_label=stage.label,
2615
- step=event.step, total_steps=event.total_steps,
2616
- elapsed_s=elapsed, eta_s=eta,
2617
- )
2618
- yield status, gr.update()
2619
- elif isinstance(event, backend_module.OutputEvent):
2620
- yield ui._render_idle(), event.video_path
2621
- elif isinstance(event, backend_module.ErrorEvent):
2622
- error_html = (
2623
- f'<div class="status-card status-error">'
2624
- f' <div class="status-row"><span class="status-stage">Error · {event.category}</span></div>'
2625
- f' <div>{event.message}</div>'
2626
- f'</div>'
2627
- )
2628
- yield error_html, gr.update()
2629
-
2630
-
2631
- # Wire button to handler in build_app:
2632
-
2633
- def build_app() -> gr.Blocks:
2634
- with gr.Blocks(theme=gr.themes.Soft(), title="LTX 2.3 All-in-One", css=_CUSTOM_CSS) as app:
2635
- gr.Markdown("# ⚡ LTX 2.3 All-in-One")
2636
- with gr.Row():
2637
- with gr.Column(scale=1, min_width=200):
2638
- _render_sidebar()
2639
- with gr.Column(scale=4):
2640
- handles = _render_mode_panels()
2641
-
2642
- for name, h in handles.items():
2643
- inputs = _collect_inputs_for_mode(name, h)
2644
- h["generate_btn"].click(
2645
- fn=_make_handler(name, h),
2646
- inputs=inputs,
2647
- outputs=[h["status"], h["video_out"]],
2648
- )
2649
- return app
2650
-
2651
-
2652
- def _collect_inputs_for_mode(mode_name: str, h: dict) -> list:
2653
- """Gather the gr.Component handles to pass into _on_generate."""
2654
- base = [h["prompt"], h["preset"], h["width"], h["height"], h["frames"], h["fps"], h["seed"]]
2655
- if mode_name == "i2v":
2656
- base.append(h["image"])
2657
- elif mode_name == "a2v":
2658
- base.append(h["audio"])
2659
- elif mode_name == "lipsync":
2660
- base.extend([h["image"], h["audio"]])
2661
- elif mode_name == "keyframe":
2662
- base.extend([h["first_frame"], h["last_frame"]])
2663
- elif mode_name == "style":
2664
- base.append(h["input_video"])
2665
- base.append(h["negative_prompt"])
2666
- base.extend([
2667
- h["lora"].camera_lora, h["lora"].camera_strength,
2668
- h["lora"].detailer_on, h["lora"].detailer_strength,
2669
- ])
2670
- if h["lora"].ic_lora is not None:
2671
- base.extend([h["lora"].ic_lora, h["lora"].ic_strength])
2672
- if h["lora"].pose_on is not None:
2673
- base.append(h["lora"].pose_on)
2674
- return base
2675
-
2676
-
2677
- def _make_handler(mode_name: str, h: dict):
2678
- keys = _input_keys_for_mode(mode_name, h)
2679
-
2680
- async def handler(*values):
2681
- kwargs = dict(zip(keys, values))
2682
- async for output in _on_generate(mode_name, **kwargs):
2683
- yield output
2684
-
2685
- return handler
2686
-
2687
-
2688
- def _input_keys_for_mode(mode_name: str, h: dict) -> list[str]:
2689
- base = ["prompt", "preset", "width", "height", "frames", "fps", "seed"]
2690
- if mode_name == "i2v":
2691
- base.append("image")
2692
- elif mode_name == "a2v":
2693
- base.append("audio")
2694
- elif mode_name == "lipsync":
2695
- base.extend(["image", "audio"])
2696
- elif mode_name == "keyframe":
2697
- base.extend(["first_frame", "last_frame"])
2698
- elif mode_name == "style":
2699
- base.append("input_video")
2700
- base.append("negative_prompt")
2701
- base.extend(["camera_lora", "camera_strength", "detailer_on", "detailer_strength"])
2702
- if h["lora"].ic_lora is not None:
2703
- base.extend(["ic_lora", "ic_strength"])
2704
- if h["lora"].pose_on is not None:
2705
- base.append("pose_on")
2706
- return base
2707
- ```
2708
-
2709
- - [ ] **Step 2: End-to-end smoke run (T2V Fast preset)**
2710
-
2711
- Run: `python3.11 app.py`
2712
-
2713
- In the browser:
2714
- 1. Open the **Text → Video** tab.
2715
- 2. Type a short prompt (e.g., "a cat walking through a park, cinematic").
2716
- 3. Pick **Fast** preset.
2717
- 4. Set frames to 9, width 320, height 480 (smallest valid for fastest test).
2718
- 5. Click **Generate**.
2719
-
2720
- Expected: status banner updates through stages (Encode prompt → Diffusion → Decode), then a video appears in the right panel within 1–3 minutes on local MPS. (If first run, expect 30+ minutes for model downloads.)
2721
-
2722
- - [ ] **Step 3: Commit**
2723
-
2724
- ```bash
2725
- git add app.py
2726
- git commit -m "feat(app): generate handler — async streaming, status banner, video output"
2727
- ```
2728
-
2729
- ---
2730
-
2731
- ## Phase 7 — CI
2732
-
2733
- ### Task 24: `.github/workflows/ci.yml`
2734
-
2735
- **Files:**
2736
- - Create: `.github/workflows/ci.yml`
2737
-
2738
- - [ ] **Step 1: Write CI workflow**
2739
-
2740
- ```yaml
2741
- name: CI
2742
-
2743
- on:
2744
- push:
2745
- pull_request:
2746
-
2747
- jobs:
2748
- test:
2749
- runs-on: ubuntu-latest
2750
- steps:
2751
- - uses: actions/checkout@v4
2752
- with:
2753
- submodules: false # ComfyUI submodule not needed for L1+L3 tests
2754
-
2755
- - uses: actions/setup-python@v5
2756
- with:
2757
- python-version: "3.11"
2758
-
2759
- - name: Install runtime + dev deps
2760
- run: |
2761
- pip install -U pip
2762
- pip install -r requirements.txt
2763
-
2764
- - name: Run unit + integration tests (no GPU)
2765
- run: |
2766
- python -m pytest tests/ -v -m "not gpu"
2767
-
2768
- - name: Lint
2769
- run: |
2770
- ruff check .
2771
- ruff format --check .
2772
- ```
2773
-
2774
- - [ ] **Step 2: Locally verify the lint command passes**
2775
-
2776
- Run: `python3.11 -m ruff check . && python3.11 -m ruff format --check .`
2777
- Expected: no errors. If formatter complains, run `ruff format .` and commit the changes.
2778
-
2779
- - [ ] **Step 3: Commit**
2780
-
2781
- ```bash
2782
- git add .github/workflows/ci.yml
2783
- git commit -m "ci: run unit tests + ruff lint on every push"
2784
- ```
2785
-
2786
- ---
2787
-
2788
- ### Task 25: `.github/workflows/deploy-space.yml` (optional)
2789
-
2790
- **Files:**
2791
- - Create: `.github/workflows/deploy-space.yml`
2792
-
2793
- - [ ] **Step 1: Write deploy workflow**
2794
-
2795
- ```yaml
2796
- name: Deploy to HF Space
2797
-
2798
- on:
2799
- push:
2800
- branches: [main]
2801
- workflow_dispatch:
2802
-
2803
- jobs:
2804
- deploy:
2805
- runs-on: ubuntu-latest
2806
- steps:
2807
- - uses: actions/checkout@v4
2808
- with:
2809
- fetch-depth: 0
2810
- submodules: false
2811
-
2812
- - name: Configure git LFS
2813
- run: |
2814
- git lfs install --skip-smudge
2815
-
2816
- - name: Push to HF Space
2817
- env:
2818
- HF_TOKEN: ${{ secrets.HF_TOKEN }}
2819
- HF_USER: ${{ secrets.HF_USER }}
2820
- HF_SPACE: ltx2.3-aio
2821
- run: |
2822
- git remote add space "https://$HF_USER:$HF_TOKEN@huggingface.co/spaces/$HF_USER/$HF_SPACE"
2823
- git push --force space main
2824
- ```
2825
-
2826
- - [ ] **Step 2: Commit**
2827
-
2828
- ```bash
2829
- git add .github/workflows/deploy-space.yml
2830
- git commit -m "ci: optional deploy-on-main to HF Space"
2831
- ```
2832
-
2833
- > **Manual setup (one-time, not part of this plan):** Add `HF_TOKEN` and `HF_USER` secrets in the GitHub repo settings. Create the Space at https://huggingface.co/new-space with SDK=Gradio, Hardware=ZeroGPU.
2834
-
2835
- ---
2836
-
2837
- ## Phase 8 — End-to-end verification
2838
-
2839
- ### Task 26: Local smoke for all six modes
2840
-
2841
- **No code changes — verification only.**
2842
-
2843
- - [ ] **Step 1: Run app.py and exercise each mode at Fast preset**
2844
-
2845
- ```bash
2846
- source .venv/bin/activate
2847
- python3.11 app.py
2848
- ```
2849
-
2850
- For each of T2V, A2V, I2V, Lipsync, Keyframe, Style:
2851
- 1. Open the mode's tab.
2852
- 2. Provide minimum-viable inputs (prompt + any required media at the smallest legal resolution: 320×480, frames=9, fps=24).
2853
- 3. Click **Generate**.
2854
- 4. Verify the status banner progresses through stages and the video appears.
2855
-
2856
- Each generation should complete in 1–5 minutes on local MPS (after models are cached).
2857
-
2858
- - [ ] **Step 2: Capture timings + memory peaks**
2859
-
2860
- For each mode, note: total wall time, peak resident memory (use Activity Monitor on macOS or `nvidia-smi --loop=2` on CUDA). Add to the README's "Local quickstart" section.
2861
-
2862
- - [ ] **Step 3: Commit any timing notes**
2863
-
2864
- ```bash
2865
- git add README.md
2866
- git commit -m "docs: per-mode timing/memory measurements on Apple Silicon" || true
2867
- ```
2868
-
2869
- ---
2870
-
2871
- ### Task 27: HF Spaces test deployment
2872
-
2873
- **No code changes — deploy + verify.**
2874
-
2875
- - [ ] **Step 1: Push to a personal HF Space**
2876
-
2877
- ```bash
2878
- git remote add space https://huggingface.co/spaces/<your-handle>/ltx2.3-aio-test
2879
- git push --force space main
2880
- ```
2881
-
2882
- - [ ] **Step 2: Watch the Space build**
2883
-
2884
- In the Space's "Logs" tab, verify:
2885
- - ComfyUI clones to `/data/comfyui` on first cold start (takes ~3–5 min).
2886
- - Custom nodes install cleanly.
2887
- - `requirements.txt` resolves on Python 3.11.
2888
-
2889
- - [ ] **Step 3: Run a Fast-preset T2V on the Space**
2890
-
2891
- Same minimum-viable inputs as Task 26. Expected: completes within the 60s ZeroGPU duration on Pro tier (after model download has populated `/data/models`).
2892
-
2893
- - [ ] **Step 4: Note any deviations from local behavior**
2894
-
2895
- Any divergence (e.g., slower download, different VAE behavior) gets a follow-up issue.
2896
-
2897
- - [ ] **Step 5: Optionally promote to a public Space**
2898
-
2899
- If everything works, repeat the deploy with the user-facing Space name (`<your-handle>/ltx2.3-aio`).
2900
-
2901
- ---
2902
-
2903
- ## Spec coverage check
2904
-
2905
- | Spec section | Covered by |
2906
- |---|---|
2907
- | § 3 Architecture | Tasks 17–18, 21–23 |
2908
- | § 4 File structure | Tasks 1–25 (every file) |
2909
- | § 5 Data flow | Tasks 17–18, 21–23 |
2910
- | § 6 Model loading & VRAM | Tasks 13–16, 18 |
2911
- | § 7 Progress reporting | Tasks 18, 19, 23 |
2912
- | § 8 Error handling | Tasks 18, 23 (`ErrorEvent` rendering) |
2913
- | § 9.1 Local deployment | Tasks 2, 26 |
2914
- | § 9.2 HF Spaces deployment | Tasks 21 (`_bootstrap`), 27 |
2915
- | § 9.3 One-touch deploy | Task 25 |
2916
- | § 10 Testing | Tasks 4 (fixtures), 6, 8–15, 17, 24 |
2917
-
2918
- All spec sections are covered. Out-of-scope items (§ 11) are intentionally absent.
2919
-
2920
- ---
2921
-
2922
- ## Plan complete
2923
-
2924
- Plan saved to `docs/superpowers/plans/2026-04-30-ltx23-aio-generator.md`.
2925
-
2926
- **Two execution options:**
2927
-
2928
- **1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Best for a plan this long because it keeps each task's context tight.
2929
-
2930
- **2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints.
2931
-
2932
- **Which approach?**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/plans/2026-05-01-topaz-drawer-redesign.md DELETED
@@ -1,535 +0,0 @@
1
- # Topaz Cinema Slate + Drawer Redesign — Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Apply the Topaz Cinema Slate dark palette and the hamburger-drawer layout (open by default ≥1024 px, hidden behind ≡ button below) to the existing Gradio app, with no logic or backend changes.
6
-
7
- **Architecture:** All edits land in `app.py`. Three concerns: (1) `gr.themes.Base().set(...)` overrides for the 12 Topaz tokens, (2) full rewrite of `_CUSTOM_CSS` for slate-on-slate styling + drawer mechanics + responsive breakpoint, (3) markup tweak — wrap the existing sidebar `gr.Column` in a `drawer` div with a header row above the shell that holds the ≡ toggle, title, and active-mode tag.
8
-
9
- **Tech Stack:** Gradio 5.50, IBM Plex Sans/Mono via Google Fonts, pure-CSS drawer toggle using a hidden `<input type="checkbox">` + `:checked` sibling selectors (no JS framework — but a tiny inline `<script>` block in `head=` syncs the active-mode tag and persists drawer state to `localStorage`).
10
-
11
- ---
12
-
13
- ## File Structure
14
-
15
- - **Modify:** `app.py`
16
- - `_CUSTOM_CSS` block — fully replaced
17
- - `build_app()` — Blocks `theme=` and `head=` parameters added; markup gets a header row + drawer column wrapper
18
-
19
- No other files are touched. `backend.py`, `models.py`, `modes.py`, `workflow.py`, `ui.py` are unaffected.
20
-
21
- ---
22
-
23
- ### Task 1 — Add Topaz theme tokens to `gr.Blocks`
24
-
25
- **Files:** Modify `app.py:191`
26
-
27
- - [ ] **Step 1: Define the Topaz `gr.themes.Base()` instance**
28
-
29
- Add this just above `def build_app()` near `app.py:189`:
30
-
31
- ```python
32
- _TOPAZ_THEME = gr.themes.Base(
33
- primary_hue=gr.themes.Color(
34
- c50="#FBE5C7", c100="#F5D29C", c200="#EFC174", c300="#E9B05A",
35
- c400="#E5A75B", c500="#E0A458", c600="#C68D3F", c700="#A6722E",
36
- c800="#7E5722", c900="#583C18", c950="#3A2810",
37
- ),
38
- neutral_hue=gr.themes.Color(
39
- c50="#E6E8EB", c100="#C9CDD3", c200="#ACB1B9", c300="#9097A0",
40
- c400="#7C8693", c500="#626972", c600="#4A4F58", c700="#363B43",
41
- c800="#262C35", c900="#1A1F26", c950="#12161B",
42
- ),
43
- font=(gr.themes.GoogleFont("IBM Plex Sans"), "ui-sans-serif", "system-ui", "sans-serif"),
44
- font_mono=(gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"),
45
- ).set(
46
- body_background_fill="#12161B",
47
- background_fill_primary="#12161B",
48
- background_fill_secondary="#1A1F26",
49
- block_background_fill="#1A1F26",
50
- block_label_background_fill="transparent",
51
- body_text_color="#E6E8EB",
52
- body_text_color_subdued="#7C8693",
53
- border_color_primary="#262C35",
54
- border_color_accent="#E0A458",
55
- button_primary_background_fill="#E0A458",
56
- button_primary_background_fill_hover="#F0B870",
57
- button_primary_text_color="#12161B",
58
- button_secondary_background_fill="#1A1F26",
59
- button_secondary_background_fill_hover="#232930",
60
- button_secondary_text_color="#E6E8EB",
61
- button_secondary_border_color="#262C35",
62
- input_background_fill="#12161B",
63
- input_border_color="#262C35",
64
- input_border_color_focus="#E0A458",
65
- error_background_fill="#3A1E20",
66
- error_text_color="#F4A6A8",
67
- slider_color="#E0A458",
68
- )
69
- ```
70
-
71
- - [ ] **Step 2: Wire the theme into `build_app`**
72
-
73
- Change `app.py:191` from:
74
-
75
- ```python
76
- with gr.Blocks(theme=gr.themes.Soft(), title="LTX 2.3 All-in-One", css=_CUSTOM_CSS) as app:
77
- ```
78
-
79
- to:
80
-
81
- ```python
82
- with gr.Blocks(theme=_TOPAZ_THEME, title="LTX 2.3 Studio", css=_CUSTOM_CSS) as app:
83
- ```
84
-
85
- - [ ] **Step 3: Smoke-test that imports still work**
86
-
87
- Run: `python -c "import app; print('OK')"` in the project root.
88
- Expected: `OK` printed, no traceback.
89
-
90
- - [ ] **Step 4: Commit**
91
-
92
- ```bash
93
- git add app.py
94
- git commit -m "feat(ui): apply Topaz Cinema Slate theme tokens"
95
- ```
96
-
97
- ---
98
-
99
- ### Task 2 — Replace `_CUSTOM_CSS` with Topaz styles + drawer mechanics
100
-
101
- **Files:** Modify `app.py:127-182` (the whole `_CUSTOM_CSS = """..."""` block)
102
-
103
- - [ ] **Step 1: Replace the entire `_CUSTOM_CSS` block**
104
-
105
- Replace `app.py:127-182` with:
106
-
107
- ```python
108
- _CUSTOM_CSS = """
109
- /* Hide Gradio's top tab strip — sidebar drives selection. */
110
- .aio-tabs > .tab-nav,
111
- .aio-tabs > div:first-child[role="tablist"],
112
- .aio-tabs > div:first-child:has([role="tab"]) {
113
- position: absolute !important;
114
- left: -99999px !important;
115
- top: -99999px !important;
116
- height: 0 !important;
117
- overflow: hidden !important;
118
- visibility: visible !important;
119
- pointer-events: auto !important;
120
- }
121
-
122
- /* === Header === */
123
- .aio-header {
124
- display: flex;
125
- align-items: center;
126
- gap: 12px;
127
- padding: 11px 18px;
128
- border-bottom: 1px solid #262C35;
129
- background: #12161B;
130
- }
131
- .aio-ham-toggle { display: none; } /* hidden checkbox drives drawer state */
132
- .aio-ham-label {
133
- width: 32px; height: 32px;
134
- border: 1px solid #262C35;
135
- border-radius: 5px;
136
- color: #7C8693;
137
- cursor: pointer;
138
- display: flex; align-items: center; justify-content: center;
139
- font-size: 18px; font-weight: 300;
140
- user-select: none;
141
- }
142
- .aio-ham-label:hover { color: #E0A458; border-color: #E0A458; }
143
- .aio-title {
144
- font-size: 15px; font-weight: 600; letter-spacing: -0.01em;
145
- color: #E6E8EB;
146
- }
147
- .aio-title .accent { color: #E0A458; }
148
- .aio-mode-tag {
149
- margin-left: auto;
150
- padding: 4px 9px;
151
- font-family: 'IBM Plex Mono', ui-monospace, monospace;
152
- font-size: 11px; font-weight: 500; letter-spacing: 0.04em;
153
- color: #E0A458;
154
- border: 1px solid #E0A458;
155
- border-radius: 4px;
156
- }
157
-
158
- /* === Drawer === */
159
- .aio-shell { position: relative; }
160
- .aio-drawer {
161
- width: 220px;
162
- border-right: 1px solid #262C35;
163
- background: #12161B;
164
- padding: 14px 10px !important;
165
- flex-shrink: 0;
166
- transition: transform 0.2s ease, width 0.2s ease;
167
- }
168
- .aio-drawer-heading {
169
- font-family: 'IBM Plex Mono', ui-monospace, monospace;
170
- font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em;
171
- color: #7C8693;
172
- padding: 6px 8px 4px !important;
173
- margin: 0 !important;
174
- }
175
-
176
- /* Mode buttons */
177
- .aio-mode-btn { width: 100%; text-align: left; margin: 2px 0 !important; }
178
- .aio-mode-btn-active {
179
- background: #1A1F26 !important;
180
- color: #E0A458 !important;
181
- border-left: 3px solid #E0A458 !important;
182
- }
183
-
184
- /* Model status / settings panels */
185
- .aio-model-badge {
186
- padding: 9px 11px;
187
- border-radius: 6px;
188
- background: #1A1F26;
189
- border: 1px solid #262C35;
190
- font-size: 11.5px;
191
- font-family: 'IBM Plex Mono', ui-monospace, monospace;
192
- color: #7C8693;
193
- }
194
-
195
- /* === Status banner === */
196
- .status-card {
197
- padding: 12px 16px;
198
- border-radius: 6px;
199
- background: #1A1F26;
200
- border: 1px solid #262C35;
201
- }
202
- .status-row { display: flex; gap: 14px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
203
- .status-stage { font-weight: 600; color: #E0A458; }
204
- .status-meta { font-size: 12px; color: #7C8693; font-family: 'IBM Plex Mono', monospace; }
205
- .status-bar { height: 4px; background: #262C35; border-radius: 99px; overflow: hidden; }
206
- .status-fill { height: 100%; background: #E0A458; transition: width .3s; }
207
- .status-mem { font-size: 11px; color: #7C8693; margin-top: 6px; font-family: 'IBM Plex Mono', monospace; }
208
- .status-error {
209
- background: #3A1E20 !important;
210
- border-color: #F4A6A8 !important;
211
- color: #F4A6A8 !important;
212
- }
213
- .status-error .status-stage { color: #F4A6A8; }
214
-
215
- /* === Drawer toggle behavior at the desktop boundary === */
216
- @media (max-width: 1023px) {
217
- .aio-ham-label { display: flex; }
218
- .aio-drawer {
219
- position: absolute;
220
- top: 0; left: 0; bottom: 0;
221
- z-index: 10;
222
- box-shadow: 4px 0 24px rgba(0,0,0,0.6);
223
- transform: translateX(-100%);
224
- max-width: 80vw;
225
- }
226
- /* checkbox at #aio-ham-toggle is the only sibling pattern Gradio
227
- lets us reach without JS — when checked, slide drawer in. */
228
- body:has(.aio-ham-toggle:checked) .aio-drawer { transform: translateX(0); }
229
- body:has(.aio-ham-toggle:checked) .aio-shell::before {
230
- content: ""; position: absolute; inset: 0;
231
- background: rgba(0,0,0,0.55); z-index: 9;
232
- }
233
-
234
- /* Mobile sub-tweaks */
235
- .aio-mode-btn { font-size: 13px !important; padding: 7px 10px !important; }
236
- .aio-body [class*="row"] { flex-wrap: wrap !important; }
237
- .aio-body [class*="row"] > div { flex: 1 1 100% !important; min-width: 0 !important; }
238
- }
239
-
240
- @media (min-width: 1024px) {
241
- .aio-ham-label { display: none; }
242
- }
243
- """
244
- ```
245
-
246
- - [ ] **Step 2: Verify the CSS doesn't break the import**
247
-
248
- Run: `python -c "import app; print(len(app._CUSTOM_CSS), 'chars CSS')"`
249
- Expected: a number (around 4000), no traceback.
250
-
251
- - [ ] **Step 3: Commit**
252
-
253
- ```bash
254
- git add app.py
255
- git commit -m "feat(ui): rewrite _CUSTOM_CSS for Topaz palette + drawer mechanics"
256
- ```
257
-
258
- ---
259
-
260
- ### Task 3 — Add header markup + drawer wrapper to `build_app`
261
-
262
- **Files:** Modify `app.py:190-243` (the `build_app` function body)
263
-
264
- - [ ] **Step 1: Replace the markup section**
265
-
266
- Find this block in `app.py` (currently around 190-220):
267
-
268
- ```python
269
- def build_app() -> gr.Blocks:
270
- with gr.Blocks(theme=_TOPAZ_THEME, title="LTX 2.3 Studio", css=_CUSTOM_CSS) as app:
271
- gr.Markdown("# ⚡ LTX 2.3 All-in-One")
272
-
273
- with gr.Row(elem_classes=["aio-shell"]):
274
- # Sidebar
275
- with gr.Column(scale=1, min_width=200, elem_classes=["aio-sidebar"]):
276
- gr.Markdown("**Modes**", elem_classes=["aio-sidebar-heading"])
277
- with gr.Column(elem_classes=["aio-mode-btn-row"]):
278
- mode_buttons = {
279
- name: gr.Button(
280
- f"{m.icon} {m.label}",
281
- elem_classes=["aio-mode-btn"],
282
- variant="secondary",
283
- )
284
- for name, m in modes.MODE_REGISTRY.items()
285
- }
286
- gr.Markdown("**Models**", elem_classes=["aio-sidebar-heading"])
287
- model_status = gr.HTML(_render_model_status_idle(), elem_id="aio-model-status")
288
- refresh_btn = gr.Button("Refresh", size="sm", variant="secondary")
289
- unload_btn = gr.Button("Unload all models", size="sm", variant="secondary")
290
- gr.Markdown("**Settings**", elem_classes=["aio-sidebar-heading"])
291
- gr.Markdown(
292
- "Output: `comfyui/output/LTX2.3/`<br>"
293
- "Set `LTX23_AIO_VRAM=lowvram|normalvram|highvram` to override the auto-detected VRAM tier.",
294
- elem_classes=["aio-model-badge"],
295
- )
296
-
297
- # Body
298
- with gr.Column(scale=4, elem_classes=["aio-body"]):
299
- handles, tabs_component = _render_mode_panels()
300
- ```
301
-
302
- Replace with:
303
-
304
- ```python
305
- def build_app() -> gr.Blocks:
306
- with gr.Blocks(theme=_TOPAZ_THEME, title="LTX 2.3 Studio", css=_CUSTOM_CSS) as app:
307
- # Header: hamburger checkbox (drives drawer via :checked + :has() in CSS),
308
- # title, current-mode tag.
309
- gr.HTML(
310
- '<div class="aio-header">'
311
- ' <input type="checkbox" id="aio-ham-toggle" class="aio-ham-toggle">'
312
- ' <label for="aio-ham-toggle" class="aio-ham-label">≡</label>'
313
- ' <span class="aio-title">LTX 2.3 <span class="accent">Studio</span></span>'
314
- ' <span class="aio-mode-tag" id="aio-mode-tag">T2V</span>'
315
- '</div>'
316
- )
317
-
318
- with gr.Row(elem_classes=["aio-shell"]):
319
- # Drawer (drawer behaves as fixed sidebar ≥1024 px;
320
- # absolute-positioned overlay <1024 px — see _CUSTOM_CSS).
321
- with gr.Column(scale=1, min_width=200, elem_classes=["aio-drawer"]):
322
- gr.Markdown("Modes", elem_classes=["aio-drawer-heading"])
323
- mode_buttons = {
324
- name: gr.Button(
325
- f"{m.icon} {m.label}",
326
- elem_classes=["aio-mode-btn"],
327
- variant="secondary",
328
- )
329
- for name, m in modes.MODE_REGISTRY.items()
330
- }
331
- gr.Markdown("Models", elem_classes=["aio-drawer-heading"])
332
- model_status = gr.HTML(_render_model_status_idle(), elem_id="aio-model-status")
333
- refresh_btn = gr.Button("Refresh", size="sm", variant="secondary")
334
- unload_btn = gr.Button("Unload all models", size="sm", variant="secondary")
335
- gr.Markdown("Settings", elem_classes=["aio-drawer-heading"])
336
- gr.Markdown(
337
- "Output: `comfyui/output/LTX2.3/`<br>"
338
- "Set `LTX23_AIO_VRAM=lowvram|normalvram|highvram` to override "
339
- "the auto-detected VRAM tier.",
340
- elem_classes=["aio-model-badge"],
341
- )
342
-
343
- # Body — unchanged, still hosts the 6 mode tabs.
344
- with gr.Column(scale=4, elem_classes=["aio-body"]):
345
- handles, tabs_component = _render_mode_panels()
346
- ```
347
-
348
- - [ ] **Step 2: Smoke-test build_app produces a Blocks**
349
-
350
- Run:
351
- ```bash
352
- python -c "import app; b = app.build_app(); print(type(b).__name__)"
353
- ```
354
- Expected: `Blocks`, no traceback.
355
-
356
- - [ ] **Step 3: Commit**
357
-
358
- ```bash
359
- git add app.py
360
- git commit -m "feat(ui): drawer markup + header (hamburger / title / mode tag)"
361
- ```
362
-
363
- ---
364
-
365
- ### Task 4 — Wire active-mode tag updates from sidebar clicks
366
-
367
- **Files:** Modify `app.py:232-237` (the existing mode-button click loop)
368
-
369
- - [ ] **Step 1: Update the click handler to also push the new mode tag**
370
-
371
- Current code at `app.py:232-237`:
372
-
373
- ```python
374
- for name, btn in mode_buttons.items():
375
- btn.click(
376
- fn=lambda mode_id=name: gr.Tabs(selected=mode_id),
377
- inputs=None,
378
- outputs=[tabs_component],
379
- )
380
- ```
381
-
382
- Replace with:
383
-
384
- ```python
385
- # JS to update the header mode tag without a server round-trip.
386
- # Each mode button injects a tiny on-click that rewrites #aio-mode-tag.
387
- _MODE_TAG_BY_NAME = {
388
- "t2v": "T2V", "a2v": "A2V", "i2v": "I2V",
389
- "lipsync": "LIPSYNC", "keyframe": "KEY", "style": "STYLE",
390
- }
391
- for name, btn in mode_buttons.items():
392
- tag = _MODE_TAG_BY_NAME.get(name, name.upper())
393
- btn.click(
394
- fn=lambda mode_id=name: gr.Tabs(selected=mode_id),
395
- inputs=None,
396
- outputs=[tabs_component],
397
- js=f"() => {{ "
398
- f"const el = document.getElementById('aio-mode-tag'); "
399
- f"if (el) el.textContent = {tag!r}; "
400
- f"/* also collapse drawer on mobile after pick */ "
401
- f"if (window.matchMedia('(max-width: 1023px)').matches) {{ "
402
- f" const t = document.getElementById('aio-ham-toggle'); "
403
- f" if (t) t.checked = false; "
404
- f"}} return []; }}",
405
- )
406
- ```
407
-
408
- - [ ] **Step 2: Smoke-test**
409
-
410
- Run:
411
- ```bash
412
- python -c "import app; b = app.build_app(); print('ok')"
413
- ```
414
- Expected: `ok`, no traceback.
415
-
416
- - [ ] **Step 3: Commit**
417
-
418
- ```bash
419
- git add app.py
420
- git commit -m "feat(ui): mode tag updates + auto-close drawer on mobile select"
421
- ```
422
-
423
- ---
424
-
425
- ### Task 5 — Visual smoke test in browser
426
-
427
- **Files:** None (test-only).
428
-
429
- - [ ] **Step 1: Launch the app**
430
-
431
- Run in one terminal:
432
- ```bash
433
- cd /Users/techfreakworm/Projects/llm/ltx2.3-AIO-generator
434
- source .venv/bin/activate
435
- python app.py
436
- ```
437
- Expected output: `Running on local URL: http://127.0.0.1:7860`
438
-
439
- - [ ] **Step 2: Open browser at desktop width**
440
-
441
- Open Chrome at `http://127.0.0.1:7860`. Resize window to 1280 px wide.
442
-
443
- Verify:
444
- - Header: ≡ button NOT visible (hidden on ≥1024 px), title "LTX 2.3 **Studio**" with "Studio" in amber, mode tag "T2V" in amber border on right
445
- - Drawer (220 px) visible on left, "Modes" heading in IBM Plex Mono uppercase, 6 mode buttons stacked
446
- - Active mode (T2V by default) has amber left border + amber text + slate-2 bg
447
- - Body pane: form fields use slate background, amber Generate button at the bottom
448
- - Click each mode button → mode tag in header updates, body switches to that mode's form
449
-
450
- - [ ] **Step 3: Resize to tablet (1023 px)**
451
-
452
- Drag Chrome to 900 px wide.
453
-
454
- Verify:
455
- - ≡ button NOW visible in header
456
- - Drawer hidden (off-screen left)
457
- - Click ≡ → drawer slides in, dark scrim covers body
458
- - Click a mode button → drawer auto-closes, body switches mode
459
- - Click ≡ again → drawer hides
460
-
461
- - [ ] **Step 4: Resize to phone (380 px)**
462
-
463
- Use Chrome devtools → device toolbar → iPhone 12.
464
-
465
- Verify:
466
- - Same as tablet, but drawer width capped at 80 vw
467
- - Form fields are full-width (sliders, inputs)
468
- - Generate button readable, no horizontal scrollbar
469
-
470
- - [ ] **Step 5: Hit Generate (T2V, default settings)**
471
-
472
- Type a short prompt, click Generate.
473
-
474
- Verify:
475
- - Status banner appears with `Stage 1 · Encode prompt` text in amber
476
- - Progress bar fills with amber
477
- - After ~30s on local MPS (or longer if no model cache), video appears in output
478
- - Banner switches to `Done` or disappears
479
-
480
- - [ ] **Step 6: Trigger an error**
481
-
482
- Set width to 0 in slider (or click Generate with empty prompt) — anything that produces an error.
483
-
484
- Verify:
485
- - Error banner uses `#3A1E20` background + `#F4A6A8` text
486
- - Stage label and meta text both readable
487
-
488
- - [ ] **Step 7: Stop the dev server**
489
-
490
- Ctrl+C in the terminal running `python app.py`.
491
-
492
- - [ ] **Step 8: Commit screenshot/notes (optional)**
493
-
494
- If anything didn't match the spec, file a follow-up; otherwise no commit needed.
495
-
496
- ---
497
-
498
- ### Task 6 — Push to GitHub + HF Space
499
-
500
- **Files:** None — pushing only.
501
-
502
- - [ ] **Step 1: Sync to both remotes**
503
-
504
- ```bash
505
- cd /Users/techfreakworm/Projects/llm/ltx2.3-AIO-generator
506
- git push origin master
507
- HF_TOKEN=$(hf auth token 2>/dev/null) git push "https://techfreakworm:${HF_TOKEN}@huggingface.co/spaces/techfreakworm/LTX2.3-Studio" master:main
508
- ```
509
-
510
- - [ ] **Step 2: Verify Space accepts the push**
511
-
512
- Wait ~30 s, then:
513
- ```bash
514
- HF_TOKEN=$(hf auth token 2>/dev/null) curl -s -H "Authorization: Bearer ${HF_TOKEN}" \
515
- "https://huggingface.co/api/spaces/techfreakworm/LTX2.3-Studio" \
516
- | python3 -c "import sys, json; d=json.load(sys.stdin); print('stage:', d['runtime']['stage'], 'sha:', d['sha'][:8])"
517
- ```
518
- Expected: `stage: BUILDING` or `RUNNING_BUILDING`, sha matches local HEAD.
519
-
520
- - [ ] **Step 3: Wait for build, then visual-spot-check on Spaces**
521
-
522
- After ~5 min, open `https://techfreakworm-ltx2-3-studio.hf.space` in Chrome at 1280 px. Verify the Topaz palette + drawer rendered correctly. Resize to phone width and verify hamburger toggle.
523
-
524
- ---
525
-
526
- ## Out of scope reminder
527
-
528
- These are explicitly NOT touched by this plan (per spec):
529
- - Form layout inside each mode tab (typography updates flow via theme cascade only)
530
- - Model status / settings panel content
531
- - Mode set, generate flow, progress events
532
- - Any CUDA / MPS / Spaces logic
533
- - Custom LoRA UI
534
-
535
- If any of those appear visually broken after this plan lands, file separately — they're spec-isolated and a different change.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/specs/2026-04-30-ltx23-aio-generator-design.md DELETED
@@ -1,483 +0,0 @@
1
- # LTX 2.3 All-In-One Generator — Design Spec
2
-
3
- **Date:** 2026-04-30
4
- **Status:** Design approved, awaiting implementation plan
5
- **Repo:** `~/Projects/llm/ltx2.3-AIO-generator`
6
-
7
- ## 1. Overview
8
-
9
- A Gradio app that wraps the existing ComfyUI LTX 2.3 All-In-One workflow into a polished, mode-specific UI. Same code runs locally on Apple Silicon (MPS) or NVIDIA (CUDA) and deploys to Hugging Face Spaces with ZeroGPU. The Gradio frontend is a thin layer; ComfyUI is the inference engine — bundled and called as a Python library — so all of ComfyUI's smart model management, MPS handling, and node correctness are inherited rather than reimplemented.
10
-
11
- Six generation modes ship in v1, mirroring the groups in `1. LTX 2.3 All-In-One 260406-05.json`:
12
-
13
- | # | Mode | LTX-2 pipeline class |
14
- |---|---|---|
15
- | 1 | Text → Video (+optional Audio) | `TI2VidTwoStagesPipeline` / `DistilledPipeline` |
16
- | 2 | Audio → Video (Text + Audio → Video + Audio) | `A2VidPipelineTwoStage` |
17
- | 3 | Image → Video (+optional Audio) | `TI2VidTwoStagesPipeline` |
18
- | 4 | Lipsync (Image + Audio → Video + Audio) | `A2VidPipelineTwoStage` |
19
- | 5 | First / Last Frame → Video | `KeyframeInterpolationPipeline` |
20
- | 6 | Style Transfer (Video → Video, motion control) | `ICLoraPipeline` |
21
-
22
- ## 2. Decisions log (Q1–Q8 + path)
23
-
24
- | # | Question | Decision | Rationale |
25
- |---|---|---|---|
26
- | Q1 | Modes scope | All 6 | Marginal cost per mode is small; the differentiator vs other Gradio LTX demos is the unified shell. |
27
- | Q2 | Settings exposure | Preset (Fast/Balanced/Quality) + Advanced accordion | Clean Spaces demo without sacrificing local power-user control. |
28
- | Q3 | Backend | ComfyUI as headless backend (library mode) | ComfyUI is the production path on MPS; pure-Python `ltx-pipelines` has known crashes (TI2Vid OOM, A2Vid stage 2 SIGUSR1). Re-using ComfyUI's path inherits the fixes. |
29
- | Q4 | Workflow templates | Six mode-specific JSON files | Smaller diff surface, easier tests, evolves per mode. `tools/extract_modes.py` regenerates them from the master workflow. |
30
- | Q5 | LoRA UI | Categorized chrome (Camera dropdown · Detailer toggle · IC-LoRA mode-specific) | Mode-aware, no rope to misconfigure. Custom LoRA escape hatch deferred to v1.1. |
31
- | Q6 | Layout shell | Sidebar nav + 2-column body | Six tab labels are too wide horizontally; sidebar gives mode names room and accommodates global panels. |
32
- | Q7 | ComfyUI install | Bundled (git submodule locally, runtime clone on Spaces) | Self-contained, no dependence on user's existing ComfyUI install. |
33
- | Q8 | Model storage | Local: HF cache → symlinks. Spaces: lazy `hf_hub_download` to `/data`. | Honors HF cache preference; no duplicate downloads; lazy strategy keeps Spaces `/data` budget under control. |
34
- | Path | Spaces tier | Path A — Pro tier | ~70 GB minimum model footprint exceeds free tier `/data`; Balanced preset needs longer per-call duration. |
35
-
36
- ## 3. Architecture
37
-
38
- ```
39
- ┌────────────────────────────────────────────────────────────────┐
40
- │ Gradio UI (sidebar nav · 2-col body · per-mode inputs) │
41
- │ ─ Mode tabs: T2V · A2V · I2V · Lipsync · Keyframe · Style │
42
- │ ─ Categorized LoRA chrome inside each mode's Advanced ▾ │
43
- │ ─ Models / Settings / History panels in sidebar │
44
- └────────────────────────────────┬───────────────────────────────┘
45
- │ parameterize 1 of 6 templates
46
-
47
- ┌────────────────────────────────────────────────────────────────┐
48
- │ Workflow Builder (workflows/<mode>.json + UI parameters) │
49
- │ ─ load_template(mode) → patch nodes → return JSON │
50
- │ ─ Validates inputs against the mode's required nodes │
51
- └────────────────────────────────┬───────────────────────────────┘
52
- │ workflow JSON dict
53
-
54
- ┌────────────────────────────────────────────────────────────────┐
55
- │ Backend (single impl) ComfyUILibraryBackend │
56
- │ ─ comfy.execution.PromptExecutor.execute(workflow) │
57
- │ ─ Hooks comfy.utils.PROGRESS_BAR_HOOK → yields ProgressEvent │
58
- │ ─ On Spaces: wrapped in @spaces.GPU(duration=N) │
59
- │ ─ Locally: runs in a worker thread, GIL-released by torch │
60
- └────────────────────────────────┬───────────────────────────────┘
61
- │ progress events + outputs
62
-
63
- ┌────────────────────────────────────────────────────────────────┐
64
- │ Bundled ComfyUI (vendored as a git submodule) │
65
- │ ─ ComfyUI core + ComfyUI-LTXVideo + KJNodes + rgthree │
66
- │ ─ models/ symlinks → ~/.cache/huggingface/hub (local) │
67
- │ ─ models/ files on /data persistent volume (Spaces) │
68
- └────────────────────────────────────────────────────────────────┘
69
- ```
70
-
71
- ### 3.1 Key invariants
72
-
73
- 1. **One backend interface, single implementation.** Library mode everywhere (`comfy.execution.PromptExecutor`). The `@spaces.GPU` decorator is the only divergence between local and Spaces.
74
- 2. **Workflow JSON is the contract.** Six small templates, parameterized at the leaves only. We don't reinvent ComfyUI's node graph.
75
- 3. **Models are never owned by the AIO repo.** Always either symlinked from HF cache (local) or downloaded to `/data` (Spaces). The bundled ComfyUI's `models/` is purely a view onto the cache.
76
- 4. **Auto MPS/CUDA dispatch.** The bundled ComfyUI handles device selection and dtype casting. The AIO layer writes no device code.
77
-
78
- ## 4. File structure
79
-
80
- ```
81
- ltx2.3-AIO-generator/
82
- ├── app.py # Gradio entry — sidebar nav, mode rendering, generate handler
83
- ├── backend.py # ComfyUI library backend; PromptExecutor wrapper; progress streaming
84
- ├── workflow.py # load + parameterize a workflow JSON template
85
- ├── modes.py # MODE_REGISTRY: 6 modes × (inputs, defaults, parameterize fn)
86
- ├── models.py # symlink HF cache (local) / hf_hub_download to /data (Spaces)
87
- ├── ui.py # reusable Gradio components: LoRA chrome, preset bar, status banner
88
- ├── workflows/ # six mode-specific JSON templates (≤50 nodes each)
89
- │ ├── t2v.json
90
- │ ├── a2v.json
91
- │ ├── i2v.json
92
- │ ├── lipsync.json
93
- │ ├── keyframe.json
94
- │ └── style.json
95
- ├── tools/
96
- │ ├── extract_modes.py # rebuild templates from your master workflow
97
- │ └── refresh_models.py # refresh HF cache symlinks if snapshot SHAs change
98
- ├── tests/
99
- │ ├── conftest.py
100
- │ ├── test_workflow.py
101
- │ └── test_modes.py
102
- ├── comfyui/ # git submodule pinned to a known-good ComfyUI commit
103
- ├── setup.sh # init submodule, venv, install reqs, symlink models
104
- ├── requirements.txt # gradio, spaces, huggingface-hub, torch, comfyui's own reqs
105
- ├── README.md # incl. HF Space front matter for one-touch deploy
106
- ├── CLAUDE.md # project guidelines (incl. sole-author commit rule)
107
- └── .gitignore
108
- ```
109
-
110
- ### 4.1 Module responsibilities
111
-
112
- | File | Responsibility | LOC est. |
113
- |---|---|---|
114
- | `app.py` | Gradio Blocks; sidebar navigation; per-mode input forms; calls `backend.submit()` | ~400 |
115
- | `backend.py` | One class `ComfyUILibraryBackend`. Constructor adds `comfyui/` to `sys.path`, loads custom nodes, instantiates `PromptExecutor`. `submit(workflow)` is an async generator yielding `ProgressEvent`s. Handles ZeroGPU detection — wraps `_execute()` in `@spaces.GPU` if env var set. | ~200 |
116
- | `workflow.py` | `load_template(mode)`, `set_input(workflow, node_id, field, value)`, `validate(workflow)`. Pure functions over dicts. | ~120 |
117
- | `modes.py` | One `Mode` dataclass (name, icon, input_specs, parameterize_fn). `MODE_REGISTRY = {"t2v": Mode(...), ...}`. The `parameterize_fn` is the only mode-specific code. | ~300 |
118
- | `models.py` | `ensure_models_for_mode(mode)`: walks the mode's workflow, finds loader nodes, identifies HF repo+filename, downloads via `hf_hub_download`, symlinks into `comfyui/models/...`. On Spaces, downloads to `/data`. | ~150 |
119
- | `ui.py` | `lora_chrome(mode)` returns the categorized LoRA component group. `preset_bar()` returns the Fast/Balanced/Quality radio. `status_banner()` returns the `gr.HTML` for progress + stage text. | ~200 |
120
-
121
- Total app code (excluding ComfyUI submodule and workflow JSONs): **~1,400 LOC** across 6 modules.
122
-
123
- ### 4.2 ComfyUI submodule + custom nodes
124
-
125
- Pinned at a known-good commit. Custom nodes installed during `setup.sh` (local) or during runtime bootstrap (Spaces):
126
-
127
- - `Lightricks/ComfyUI-LTXVideo` (LTX node implementations: `LTXICLoRALoaderModelOnly`, `LTXVChunkFeedForward`, `LTXVGemmaCLIPModelLoader`)
128
- - `kijai/ComfyUI-KJNodes` (`VAELoaderKJ`, `ResizeImageMaskNode`, `INTConstant`, GetNode/SetNode helpers)
129
- - `rgthree/rgthree-comfy` (`Power Lora Loader`, `Any Switch`, `Fast Groups Bypasser`, `Label`)
130
- - `Kosinkadink/ComfyUI-VideoHelperSuite` (`VHS_VideoCombine`, `VHS_LoadVideo`, `VHS_LoadAudioUpload`)
131
- - `pythongosssss/ComfyUI-Custom-Scripts` (`MathExpression|pysssss` — used by the master workflow for derived dimensions)
132
-
133
- ## 5. Data flow
134
-
135
- User clicks **Generate** in the I2V tab. The path:
136
-
137
- ```
138
- [1] app.py: on_generate(mode="i2v", **inputs)
139
- │ Pulls Mode("i2v") from MODE_REGISTRY
140
-
141
- [2] modes.i2v.parameterize_fn(inputs) → list[(node_id, field, value)]
142
-
143
- [3] workflow.load_template("i2v") → dict
144
- workflow.set_input(wf, *patch) for each patch
145
- workflow.validate(wf)
146
-
147
- [4] models.ensure_models_for_mode(wf)
148
- yields DownloadEvent(filename, mb_done, mb_total)
149
-
150
- [5] backend.submit(wf) — async generator
151
- On Spaces: wrapped in @spaces.GPU(duration=preset_budget)
152
- Calls comfy.execution.PromptExecutor.execute(wf)
153
-
154
- [6] PromptExecutor walks node graph
155
- Per-node: yields ProgressEvent(stage, step, total_steps)
156
-
157
- [7] app.py: async for event in backend.submit(...):
158
- status_banner.html = render(event)
159
-
160
- [8] Final node (VHS_VideoCombine) writes /tmp/out_<ts>.mp4
161
- yields OutputEvent(path)
162
-
163
- [9] Gradio video component renders the file
164
- History panel adds row: timestamp · seed · duration
165
- ```
166
-
167
- ### 5.1 Three event types
168
-
169
- ```python
170
- @dataclass
171
- class DownloadEvent: filename: str; mb_done: float; mb_total: float
172
- @dataclass
173
- class ProgressEvent: stage: int; stage_label: str; step: int; total_steps: int
174
- @dataclass
175
- class OutputEvent: video_path: str; audio_path: Optional[str]; meta: dict
176
- ```
177
-
178
- The Gradio handler is one async generator that consumes these and yields `(status_html, video, history)` tuples.
179
-
180
- ### 5.2 Cancellation
181
-
182
- Gradio's `Button.click(..., cancels=[generate_event])` calls `backend.interrupt()` → `comfy.model_management.interrupt_current_processing()`. The async generator's `finally:` block always frees GPU memory before raising.
183
-
184
- ## 6. Model loading & VRAM management
185
-
186
- ComfyUI's `comfy.model_management` handles the heavy lifting — we write zero code for it.
187
-
188
- **Inherited from ComfyUI:**
189
- - Smart offload tiers (tracks total/free VRAM continuously; offloads largest non-live model when next load would overflow).
190
- - Per-node load via `ModelPatcher`; LoRA patching applies deltas in-place without double-loading the base model.
191
- - Automatic device dispatch and dtype casting (BF16/FP16/FP8 per `--force-*` args).
192
- - ComfyUI-LTXVideo's existing MPS edge-case handling.
193
-
194
- **AIO layer adds:**
195
-
196
- | Concern | Implementation |
197
- |---|---|
198
- | Pre-flight download | `models.ensure_models_for_mode(wf)` walks loader nodes, resolves filenames via a `MODEL_REGISTRY` map, downloads via `hf_hub_download`, symlinks into `comfyui/models/<type>/<name>`. |
199
- | VRAM tier hint | `comfy.cli_args.args.lowvram\|normalvram\|highvram` set at backend init based on detected GPU memory. Override via env var `LTX23_AIO_VRAM`. |
200
- | Memory status badge | `ui.status_banner()` polls `comfy.model_management.get_free_memory()` every 2 s while idle. |
201
- | Manual unload | Sidebar button **Unload all models** → `unload_all_models()` + `empty_cache()`. |
202
- | Inter-mode caching | Single in-process ComfyUI keeps loaded models warm across mode switches. Free for us — ComfyUI's cache does it. |
203
-
204
- ### 6.1 Memory math (BF16)
205
-
206
- | Component | Size | Loaded when |
207
- |---|---|---|
208
- | Distilled 22B transformer | ~44 GB | Diffusion stages |
209
- | Gemma 3 12B text encoder | ~24 GB | Prompt encoding |
210
- | Video VAE | ~2 GB | Encode (i2v/keyframe) + final decode |
211
- | Audio VAE | ~0.5 GB | A2V/Lipsync only |
212
- | LoRAs | <1 GB each | Patched into transformer |
213
- | Latents | ~3 GB at 512×768/81f | Diffusion |
214
-
215
- Realistic peak resident: ~70 GB on MPS unified memory; ~45 GB GPU + 24 GB system RAM on H200 80 GB ZeroGPU.
216
-
217
- ### 6.2 Out-of-scope (v1.1)
218
-
219
- `UnetLoaderGGUF` for <24 GB consumer NVIDIA GPUs. The workflow templates already accommodate the GGUF node; v1.1 adds a "Low VRAM" preset that swaps the loader.
220
-
221
- ## 7. Progress reporting
222
-
223
- Two surfaces, layered:
224
-
225
- ```
226
- ┌── Status Banner (gr.HTML) ────────────────────────────────────┐
227
- │ ⠋ Stage 4/6 · Diffusion (Stage 1) │
228
- │ Step 18/30 · 1m 12s elapsed · ~2m 41s remaining │
229
- │ MPS · 47 / 128 GB · transformer + gemma resident │
230
- │ ████████████████░░░░░░░░░░░░░░ 60% │
231
- └────────────────────────────────────────────────────────────────┘
232
- ```
233
-
234
- Below: a `gr.Progress(track_tqdm=True)` picks up ComfyUI's sampler tqdm bars natively.
235
-
236
- ### 7.1 Stage map per mode
237
-
238
- For each mode, `modes.py` declares the stage list mapping ComfyUI node ids → human-readable stage labels.
239
-
240
- I2V Balanced preset stage map:
241
-
242
- | # | Stage | ComfyUI node(s) | Typical share |
243
- |---|---|---|---|
244
- | 1 | Download missing models | (pre-flight) | 0–60s, only on first run |
245
- | 2 | Encode prompt | `LTXVGemmaCLIPModelLoader` + `CLIPTextEncode` | ~5% |
246
- | 3 | Encode image | `LoadImage` + image VAE encode | ~3% |
247
- | 4 | Diffusion (Stage 1, half-res) | `KSampler` × N steps | ~55% |
248
- | 5 | Spatial upscale (×2) | `LatentUpscaleModelLoader` + sampler | ~7% |
249
- | 6 | Diffusion (Stage 2, full-res, 4 distilled steps) | `KSampler` × 4 | ~20% |
250
- | 7 | Decode video | Video VAE decode + `VHS_VideoCombine` | ~10% |
251
-
252
- T2V is shorter (no image encode); Lipsync adds audio encode; Style Transfer is single-stage.
253
-
254
- ### 7.2 Plumbing
255
-
256
- ComfyUI's `PromptExecutor` calls a per-node hook before each node runs. The backend translates `node_id → stage_index` via the mode's stage map. Within sampler nodes, `comfy.utils.PROGRESS_BAR_HOOK` fires per step. ETA: `(elapsed / progress) - elapsed` capped to a sensible minimum.
257
-
258
- ## 8. Error handling
259
-
260
- | # | Category | Surface | Recovery |
261
- |---|---|---|---|
262
- | 1 | Setup / install (`comfyui/` missing, custom node import failure, no torch CUDA/MPS) | Startup banner replaces the UI; red card with the failing component and exact `setup.sh` command. App refuses to start. | Local: `bash setup.sh`. Spaces: surfaces in build log. |
263
- | 2 | Model download (network, HF auth, disk full) | Status banner inline error with retry button. Auth errors prompt for `HF_TOKEN`. | Auto-retry once with backoff for transient. Auth/disk are user-actionable. |
264
- | 3 | Workflow validation (input not provided, frame count not 8k+1, resolution not /32, image too large) | Caught client-side; Gradio inline validation; generate button disabled. | Auto-snap where unambiguous (frame count to nearest 8k+1, resolution to nearest /32). |
265
- | 4 | ComfyUI execution (node not found, shape mismatch, file format) | Status banner shows failing stage in red; collapsible `View full traceback ▾`. | Suggests `tools/refresh_models.py` for symlink issues, `bash setup.sh --update-comfy` for node issues. |
266
- | 5 | OOM | Status banner with stage + memory at failure; **Try Fast preset** button. | On catch: `unload_all_models()` + `empty_cache()`. Next click starts clean. |
267
- | 6 | ZeroGPU duration exceeded (Spaces) | Status banner: "Generation exceeded GPU budget"; suggests **Switch to Fast preset**. Partial output (if decoded) still shown. | `@spaces.GPU(duration=N)` raises a specific exception we catch and translate. |
268
-
269
- ### 8.1 try/finally discipline
270
-
271
- ```python
272
- async def submit(self, workflow):
273
- try:
274
- async for event in self._execute_with_progress(workflow):
275
- yield event
276
- except OutOfMemoryError as e:
277
- yield ErrorEvent(category="oom", stage=self._current_stage, ...)
278
- except spaces.exceptions.GPUDurationExceededError as e:
279
- yield ErrorEvent(category="zerogpu_timeout", ...)
280
- except Exception as e:
281
- yield ErrorEvent(category="execution", traceback=fmt(e), ...)
282
- finally:
283
- comfy.model_management.unload_all_models()
284
- torch.mps.empty_cache() if mps else torch.cuda.empty_cache()
285
- ```
286
-
287
- The `finally` block is the single most important line for VRAM hygiene. Cancellation triggers the same path via `interrupt_current_processing()` raising `InterruptedError`.
288
-
289
- ### 8.2 Logging
290
-
291
- - Local: `comfyui/comfyui.log` + `logs/aio.log` (10 MB rotation).
292
- - Spaces: stderr → Space logs panel; no file logging (Space disk is ephemeral except `/data`).
293
- - Status banner's traceback expander reads the last error from `logs/aio.log` (local) or stderr buffer (Spaces).
294
-
295
- ### 8.3 Deliberate non-goals
296
-
297
- No silent retries on ambiguous errors. Surface loudly with a traceback rather than mask real bugs.
298
-
299
- ## 9. Deployment
300
-
301
- ### 9.1 Local
302
-
303
- ```bash
304
- git clone https://github.com/<your-handle>/ltx2.3-AIO-generator
305
- cd ltx2.3-AIO-generator
306
- bash setup.sh
307
- source .venv/bin/activate
308
- python app.py
309
- ```
310
-
311
- `setup.sh` (idempotent):
312
-
313
- ```bash
314
- #!/usr/bin/env bash
315
- set -euo pipefail
316
-
317
- python3.11 -m venv .venv
318
- source .venv/bin/activate
319
- pip install -U pip
320
-
321
- git submodule update --init --recursive
322
- pip install -r comfyui/requirements.txt
323
-
324
- cd comfyui/custom_nodes
325
- for repo in \
326
- Lightricks/ComfyUI-LTXVideo \
327
- kijai/ComfyUI-KJNodes \
328
- rgthree/rgthree-comfy \
329
- Kosinkadink/ComfyUI-VideoHelperSuite \
330
- pythongosssss/ComfyUI-Custom-Scripts ; do
331
- name="${repo##*/}"
332
- [[ -d "$name" ]] || git clone "https://github.com/$repo.git" "$name"
333
- [[ -f "$name/requirements.txt" ]] && pip install -r "$name/requirements.txt"
334
- done
335
- cd ../..
336
-
337
- pip install -r requirements.txt
338
- python tools/refresh_models.py
339
-
340
- echo "Setup complete. Run: source .venv/bin/activate && python app.py"
341
- ```
342
-
343
- ### 9.2 HF Spaces (ZeroGPU, Pro tier)
344
-
345
- `README.md` front matter:
346
-
347
- ```yaml
348
- ---
349
- title: LTX 2.3 All-in-One Video Generator
350
- emoji: 🎬
351
- colorFrom: purple
352
- colorTo: blue
353
- sdk: gradio
354
- sdk_version: "5.0"
355
- app_file: app.py
356
- python_version: "3.11"
357
- suggested_hardware: zero-gpu
358
- hf_oauth: false
359
- ---
360
- ```
361
-
362
- Bootstrap inside `app.py` runs once on cold start:
363
-
364
- ```python
365
- def _bootstrap():
366
- on_spaces = bool(os.environ.get("SPACES_ZERO_GPU"))
367
- comfy_dir = pathlib.Path("/data/comfyui" if on_spaces else "comfyui")
368
-
369
- if on_spaces and not comfy_dir.exists():
370
- _git_clone(COMFYUI_REPO, comfy_dir, ref=COMFYUI_COMMIT)
371
- for node_repo, node_ref in CUSTOM_NODES_PINNED:
372
- _git_clone(node_repo, comfy_dir / "custom_nodes" / node_repo.split("/")[-1], ref=node_ref)
373
- _pip_install_custom_node_reqs(comfy_dir)
374
-
375
- sys.path.insert(0, str(comfy_dir))
376
- os.environ["COMFY_MODELS_DIR"] = str(
377
- pathlib.Path("/data/models") if on_spaces else (comfy_dir / "models")
378
- )
379
- ```
380
-
381
- Storage budget: `/data` ~50 GB on Pro. Lazy per-mode download keeps usage under budget when only some modes are exercised.
382
-
383
- Per-call duration: `@spaces.GPU(duration=...)` per preset:
384
-
385
- | Preset | Duration |
386
- |---|---|
387
- | Fast | 60 s |
388
- | Balanced | 120 s |
389
- | Quality | 300 s |
390
-
391
- UI auto-greys out presets whose duration exceeds the detected `SPACES_GPU_DURATION_LIMIT`.
392
-
393
- ### 9.3 One-touch deploy (optional)
394
-
395
- `.github/workflows/deploy-space.yml`:
396
-
397
- ```yaml
398
- on: { push: { branches: [main] } }
399
- jobs:
400
- push-to-space:
401
- runs-on: ubuntu-latest
402
- steps:
403
- - uses: actions/checkout@v4
404
- with: { lfs: true }
405
- - name: Push to HF Space
406
- env: { HF_TOKEN: ${{ secrets.HF_TOKEN }} }
407
- run: |
408
- git remote add space https://user:$HF_TOKEN@huggingface.co/spaces/<you>/ltx2.3-aio
409
- git push --force space main
410
- ```
411
-
412
- ### 9.4 Local vs Spaces — what's identical, what differs
413
-
414
- | Concern | Local | Spaces |
415
- |---|---|---|
416
- | Backend code | `ComfyUILibraryBackend` | `ComfyUILibraryBackend` (same class) |
417
- | GPU decorator | none (worker thread) | `@spaces.GPU(duration=preset_budget)` |
418
- | ComfyUI install | git submodule | runtime git clone to `/data` |
419
- | Models location | symlinks → `~/.cache/huggingface` | direct files in `/data/models` |
420
- | Logging | `logs/aio.log` + `comfyui/comfyui.log` | stderr → Space logs panel |
421
- | First-run latency | seconds (deps installed by setup.sh) | minutes (clone + first-mode download) |
422
- | Custom nodes update | re-run `setup.sh` | push commit; rebuild Space |
423
-
424
- ## 10. Testing
425
-
426
- Layered so most tests run on CPU in seconds; only smoke touches GPU.
427
-
428
- | Layer | What it verifies | GPU? | Time |
429
- |---|---|---|---|
430
- | L1 — Unit | `workflow.load_template`, `set_input`, `validate` (pure functions over JSON dicts) | No | < 1 s |
431
- | L1 — Unit | Each mode's `parameterize_fn`: known input → expected patch list | No | < 1 s |
432
- | L1 — Unit | `MODEL_REGISTRY` lookups: every model in every workflow resolves to an HF repo+filename | No | < 1 s |
433
- | L2 — Graph validation | `load_template + parameterize_fn(canonical_inputs)` produces a workflow that ComfyUI's `validate_prompt` accepts | No | < 5 s |
434
- | L3 — Integration (CPU) | `models.ensure_models_for_mode()` against a fake HF cache; symlinks created correctly | No | < 2 s |
435
- | L4 — Smoke (GPU, opt-in) | One end-to-end generation per mode at minimum viable settings (Fast preset, lowest legal resolution, 1 step). `pytest --gpu`. | Yes | ~3 min for all 6 |
436
-
437
- ### 10.1 Fixtures
438
-
439
- - `canonical_inputs(mode)` — known-good Gradio input dict per mode.
440
- - `fake_hf_cache(tmp_path)` — fake `~/.cache/huggingface/hub` with placeholder files.
441
- - `--gpu` flag enables L4. Default skips with a reason.
442
- - `--comfy-real` flag uses bundled ComfyUI for L2; default uses a stubbed validator.
443
-
444
- ### 10.2 CI
445
-
446
- `.github/workflows/ci.yml` runs L1 + L2 + L3 on `ubuntu-latest`, Python 3.11, every push. ~30 s wall time. No GPU runner. Lint: `ruff check` + `ruff format --check`.
447
-
448
- ### 10.3 Deliberate non-goals
449
-
450
- - No mocks for ComfyUI itself.
451
- - No visual regression tests for Gradio UI.
452
- - No property-based / fuzz testing for workflow params.
453
-
454
- ## 11. Out of scope (v1)
455
-
456
- - **Lite mode for free Spaces tier** — `LTX23_AIO_LITE=1` env var that filters MODE_REGISTRY to T2V+I2V, locks Fast preset, swaps GGUF transformer. Designed in but not built in v1.
457
- - **Custom LoRA escape hatch** — Power-Lora-Loader-style add/remove rows. Categorized chrome covers v1; custom is a v1.1 toggle.
458
- - **GGUF Q4 transformer (`UnetLoaderGGUF`)** — for <24 GB consumer NVIDIA GPUs. Workflow templates accommodate the node; v1.1 adds the "Low VRAM" preset.
459
- - **Auto-launch user's existing ComfyUI** — current design uses bundled ComfyUI exclusively. v1.1 could add `LTX23_AIO_COMFYUI_URL` env var to point at an external server.
460
- - **Multi-prompt queueing** — Gradio default single-shot is fine. ComfyUI's queue isn't exposed.
461
- - **History persistence across sessions** — sidebar history is in-memory. Local could read `outputs/` on startup; Spaces session storage is ephemeral.
462
-
463
- ## 12. Open questions / follow-ups
464
-
465
- - **Pinned ComfyUI commit:** select after a manual end-to-end run on the user's `~/Projects/comfyui/` install. Capture the commit SHA in `setup.sh` and the Spaces bootstrap.
466
- - **Spaces secrets:** HF Space front matter doesn't include any secrets; `HF_TOKEN` only needed if a gated repo is used (not currently). Document in README.
467
- - **Output retention on Spaces:** decide whether `/tmp/out_*.mp4` should also copy to `/data/outputs/` for download-after-restart. v1 default: no, ephemeral.
468
- - **`MODEL_REGISTRY` source of truth:** the registry maps filename → HF repo. We populate it once at v1 from Lightricks' README + Kijai's repo and freeze it; updates require a code change + tests.
469
-
470
- ## 13. Implementation order (preview — full breakdown in implementation plan)
471
-
472
- 1. **Repo skeleton** — directory layout, `.gitignore`, `CLAUDE.md`, `README.md` stub, `requirements.txt`.
473
- 2. **`tools/extract_modes.py`** — extract six mode templates from the master workflow. Validates by re-loading each in ComfyUI's parser.
474
- 3. **`workflow.py`** — pure-function library with L1 + L2 tests.
475
- 4. **`modes.py`** — MODE_REGISTRY with `parameterize_fn` per mode + L1 tests.
476
- 5. **`models.py`** — registry + `ensure_models_for_mode` + L3 tests with fake HF cache.
477
- 6. **`backend.py`** — ComfyUILibraryBackend, async submit, progress hook plumbing. Local smoke test (L4) for Fast/T2V.
478
- 7. **`ui.py`** — LoRA chrome, preset bar, status banner.
479
- 8. **`app.py`** — Gradio Blocks, sidebar nav, mode rendering, generate handler. Manual end-to-end on Mac for all 6 modes.
480
- 9. **`setup.sh`** — idempotent local bootstrap.
481
- 10. **`README.md` + Spaces front matter** — push to a test Space, verify cold-start and one Fast generation.
482
- 11. **CI workflow** — L1 + L2 + L3 on push.
483
- 12. **Optional `.github/workflows/deploy-space.yml`** — push-to-Space CI.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/specs/2026-05-01-topaz-drawer-redesign-design.md DELETED
@@ -1,104 +0,0 @@
1
- # Visual redesign — Topaz Cinema Slate + Drawer layout
2
-
3
- **Date:** 2026-05-01
4
- **Status:** Draft, awaiting user review
5
- **Related:** `2026-04-30-ltx23-aio-generator-design.md` (original spec)
6
-
7
- ---
8
-
9
- ## Goal
10
-
11
- Replace the current `gr.themes.Soft()` cream + purple palette with a dark slate-and-amber palette (**Topaz Cinema Slate**), and replace the always-visible left sidebar with a **hamburger drawer** that opens by default on desktop and is hidden by default on tablet/phone. Both changes are surface-level — no logic or backend changes.
12
-
13
- ## Why
14
-
15
- The current palette reads as a hobby AI demo, not a creative-pro tool. Slate-on-slate is gentlest on the eye when judging color-graded video output, and an amber CTA reads "render," not "alert." The drawer pattern gives the form panel full screen real estate on phones (the sidebar currently stacks above the form on `<700px`, eating half the viewport for nav), while still keeping the sidebar always-visible at desktop widths where it costs nothing.
16
-
17
- ## Theme tokens
18
-
19
- Applied via `gr.themes.Base().set(...)` overrides on the Blocks theme:
20
-
21
- | Token | Value | Used for |
22
- |---|---|---|
23
- | `body_background_fill` | `#12161B` | App background |
24
- | `background_fill_primary` | `#12161B` | Form/page background |
25
- | `background_fill_secondary` | `#1A1F26` | Card / panel surface |
26
- | `block_background_fill` | `#1A1F26` | Component (input, slider) surface |
27
- | `body_text_color` | `#E6E8EB` | Primary text |
28
- | `body_text_color_subdued` | `#7C8693` | Secondary / hint text |
29
- | `border_color_primary` | `#262C35` | Card / input border |
30
- | `border_color_accent` | `#E0A458` | Focused input ring |
31
- | `button_primary_background_fill` | `#E0A458` | Generate button |
32
- | `button_primary_text_color` | `#12161B` | Generate button label |
33
- | `error_background_fill` | `#3A1E20` | Error banner background |
34
- | `error_text_color` | `#F4A6A8` | Error banner text |
35
-
36
- Fonts: `IBM Plex Sans` (UI 14 px) + `IBM Plex Mono` (mono 13 px), loaded from Google Fonts in the page `<head>` (Gradio's `head` parameter on `Blocks`, or via `_CUSTOM_CSS` `@import`).
37
-
38
- ## Layout: hamburger drawer
39
-
40
- ### Markup structure (logical, Gradio components)
41
-
42
- ```
43
- gr.Row() # header
44
- ├── HamburgerButton (gr.Button, ≡ icon) # toggles drawer
45
- ├── gr.Markdown("LTX 2.3 Studio") # title
46
- └── ActiveModeTag (gr.Markdown, amber pill) # shows current mode
47
-
48
- gr.Row(elem_classes="layer")
49
- ├── gr.Column(elem_classes="drawer", visible=...) # 220 px wide
50
- │ └── 6 mode buttons (existing)
51
- └── gr.Column(elem_classes="body-pane")
52
- └── gr.Tabs(elem_classes="hidden-tabs") # current 6 mode tabs
53
- ```
54
-
55
- ### Open / closed behavior
56
-
57
- - **Desktop (≥1024 px):** drawer open by default, occupies the left 220 px of the viewport. Hamburger still works as a toggle but most users leave it open.
58
- - **Tablet (700–1023 px):** drawer closed by default; opening it slides over content with a translucent overlay (`background: rgba(0,0,0,0.5)`). Tapping outside closes.
59
- - **Phone (<700 px):** same as tablet, but drawer takes 80 % of viewport width when open.
60
-
61
- State persists in `localStorage` (`ltx-drawer-open` key) so a user who closes the drawer on desktop stays closed across reloads.
62
-
63
- ### Active mode header tag
64
-
65
- A small amber-bordered pill in the header (e.g., `T2V`, `A2V`, `LIPSYNC`) showing the currently selected mode. Updates whenever a mode button is clicked. Uses `IBM Plex Mono` 11 px so it reads as a label, not a button.
66
-
67
- ### CSS approach
68
-
69
- Pure CSS, no JS framework. Use `:has()` and `<input type="checkbox">` hidden control for drawer toggle, OR a tiny inline `<script>` block that toggles a class on the body. Gradio doesn't sandbox custom scripts in `_CUSTOM_CSS`, but it does support the `head` parameter on `gr.Blocks` for inline `<script>`.
70
-
71
- Existing media queries (`@max-width: 700px`, `@max-width: 1024px`) collapse to a single `@max-width: 1023px` block since drawer behavior only differs at the desktop boundary.
72
-
73
- ## Files touched
74
-
75
- - `app.py` — Blocks `theme=`, `head=` (fonts + drawer toggle script), `_CUSTOM_CSS` rewrite, header markup, drawer column wrapping the existing mode buttons
76
- - `README.md` — update screenshot if any (defer; we don't have one yet)
77
-
78
- No changes to `backend.py`, `models.py`, `modes.py`, `workflow.py`, `ui.py`.
79
-
80
- ## Out of scope (do not touch)
81
-
82
- - Form layout inside each mode tab (prompt input, parameter sliders) — typography updates only via theme token cascade
83
- - Model status / settings panel content — these display the same info, just on the new palette
84
- - Mode set, generate flow, progress events — backend unchanged
85
- - Any CUDA / MPS / Spaces logic
86
- - Custom LoRA UI (still v1.1+)
87
-
88
- ## Testing plan
89
-
90
- 1. `python app.py` locally on macOS, browse `http://127.0.0.1:7860`
91
- 2. Resize Chrome window: full width → 1024 px → 700 px → 380 px. Drawer should:
92
- - stay open ≥1024 px
93
- - hide & become overlay-on-hamburger <1024 px
94
- 3. Click each of 6 mode buttons; confirm:
95
- - active mode tag in header updates
96
- - drawer auto-closes on phone after click (open-on-tap → click-to-pick → close)
97
- 4. Click Generate on T2V (with Balanced preset, 320×480, 5 s). Confirm progress + output render correctly on the new palette.
98
- 5. Trigger an error (e.g., empty prompt) and confirm error banner uses `#3A1E20` / `#F4A6A8`.
99
-
100
- ## Risks
101
-
102
- - Gradio's `head` parameter is on `gr.Blocks` since 4.x — confirm it accepts a multi-line string with `<script>`.
103
- - `gr.themes.Base().set(...)` may not cover every component (e.g., `gr.Slider`'s track). If we hit a gap we add an `elem_classes` override and target it in `_CUSTOM_CSS` — incremental, low-risk.
104
- - The hidden-checkbox-and-`:has()` toggle pattern has Safari ≥15.4 compatibility, fine for our audience.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mcp_tools/__init__.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def __getattr__(name):
2
+ if name in ("types", "server", "client", "shared"):
3
+ raise ImportError(f"No module named 'mcp.{name}' in local mcp package")
4
+ raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
5
+
6
+ from .get_task_list import handle_get_task_list
7
+ from .run import handle_run
8
+ from .get_task_status import handle_get_task_status
9
+ from .error_schema import make_error, make_validation_error, make_not_found_error
10
+ try:
11
+ from .mcp_gradio_integration import (
12
+ register_high_level_mcp_apis,
13
+ cleanup_dependencies_api_names,
14
+ patch_gradio_api_suppression,
15
+ HIGH_LEVEL_MCP_API_NAMES,
16
+ )
17
+ except ImportError:
18
+ register_high_level_mcp_apis = None
19
+ cleanup_dependencies_api_names = None
20
+ patch_gradio_api_suppression = None
21
+ HIGH_LEVEL_MCP_API_NAMES = set()
22
+
23
+ MCP_FUNCTIONS = [
24
+ handle_get_task_list,
25
+ handle_run,
26
+ handle_get_task_status,
27
+ ]
28
+
29
+ __all__ = [
30
+ "handle_get_task_list",
31
+ "handle_run",
32
+ "handle_get_task_status",
33
+ "make_error",
34
+ "make_validation_error",
35
+ "make_not_found_error",
36
+ "register_high_level_mcp_apis",
37
+ "cleanup_dependencies_api_names",
38
+ "patch_gradio_api_suppression",
39
+ "HIGH_LEVEL_MCP_API_NAMES",
40
+ "MCP_FUNCTIONS",
41
+ ]
mcp_tools/common.py ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Common Utilities & Data Structures
3
+ Contains image and audio parsing utilities, task definitions, presets, and async task database for Lightricks LTX-2.5 Video Generation.
4
+ """
5
+
6
+ import os
7
+ import time
8
+ import urllib.parse
9
+ import urllib.request
10
+ import urllib.error
11
+ import base64
12
+ import io
13
+ import yaml
14
+ from typing import Dict, Any
15
+ from PIL import Image
16
+
17
+ _PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
+ _YAML_DIR = os.path.join(_PROJECT_ROOT, "yaml")
19
+
20
+ _MAX_MEDIA_DOWNLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
21
+ _MEDIA_DOWNLOAD_TIMEOUT = 30 # seconds
22
+ _ALLOWED_IMAGE_CONTENT_TYPES = frozenset([
23
+ "image/png", "image/jpeg", "image/jpg", "image/gif",
24
+ "image/webp", "image/bmp", "image/tiff",
25
+ ])
26
+
27
+ ASPECT_RATIO_PRESETS = {
28
+ "16:9 (Landscape)": {"width": 1344, "height": 768},
29
+ "9:16 (Portrait)": {"width": 768, "height": 1344},
30
+ "1:1 (Square)": {"width": 1024, "height": 1024},
31
+ "4:3 (Classic TV)": {"width": 1152, "height": 864},
32
+ "3:4 (Classic Portrait)": {"width": 864, "height": 1152},
33
+ "3:2 (Landscape)": {"width": 1248, "height": 832},
34
+ "2:3 (Portrait)": {"width": 832, "height": 1248},
35
+ "16:9 (Widescreen)": {"width": 1344, "height": 768},
36
+ "9:16 (Vertical)": {"width": 768, "height": 1344},
37
+ "3:2 (Photography)": {"width": 1248, "height": 832},
38
+ "2:3 (Photography Portrait)": {"width": 832, "height": 1248},
39
+ }
40
+
41
+
42
+ def _parse_image_param(image_param: Any) -> Any:
43
+ """Parse a Base64 Data URI, HTTP/HTTPS URL, or PIL.Image into a PIL Image object."""
44
+ if isinstance(image_param, Image.Image):
45
+ return image_param
46
+
47
+ if not isinstance(image_param, str) or not image_param.strip():
48
+ return None
49
+
50
+ image_param = image_param.strip()
51
+
52
+ if os.path.exists(image_param):
53
+ return Image.open(image_param)
54
+
55
+ if image_param.startswith("http://") or image_param.startswith("https://"):
56
+ return _download_image_from_url(image_param)
57
+
58
+ if image_param.startswith("data:image/"):
59
+ _, encoded = image_param.split(",", 1) if "," in image_param else ("", image_param)
60
+ data = base64.b64decode(encoded)
61
+ return Image.open(io.BytesIO(data))
62
+
63
+ if len(image_param) > 100:
64
+ try:
65
+ data = base64.b64decode(image_param)
66
+ return Image.open(io.BytesIO(data))
67
+ except Exception:
68
+ pass
69
+
70
+ raise ValueError(
71
+ "Invalid image parameter format. Expected a file path, Base64 Data URI, or an HTTP/HTTPS URL."
72
+ )
73
+
74
+
75
+ def _download_image_from_url(url: str) -> Image.Image:
76
+ """Download an image from an HTTP/HTTPS URL and return it as a PIL Image."""
77
+ req = urllib.request.Request(url, headers={"User-Agent": "LTX-2.5-VideoGen-MCP/1.0"})
78
+ try:
79
+ with urllib.request.urlopen(req, timeout=_MEDIA_DOWNLOAD_TIMEOUT) as resp:
80
+ content_type = resp.headers.get("Content-Type", "").split(";")[0].strip().lower()
81
+ if content_type and content_type not in _ALLOWED_IMAGE_CONTENT_TYPES:
82
+ raise ValueError(
83
+ f"URL returned non-image Content-Type '{content_type}'. "
84
+ f"Expected one of: {', '.join(sorted(_ALLOWED_IMAGE_CONTENT_TYPES))}."
85
+ )
86
+
87
+ content_length = resp.headers.get("Content-Length")
88
+ if content_length and int(content_length) > _MAX_MEDIA_DOWNLOAD_BYTES:
89
+ raise ValueError(
90
+ f"Image at URL is too large ({int(content_length)} bytes). "
91
+ f"Maximum allowed size is {_MAX_MEDIA_DOWNLOAD_BYTES} bytes."
92
+ )
93
+
94
+ chunks = []
95
+ total = 0
96
+ while True:
97
+ chunk = resp.read(8192)
98
+ if not chunk:
99
+ break
100
+ total += len(chunk)
101
+ if total > _MAX_MEDIA_DOWNLOAD_BYTES:
102
+ raise ValueError(f"Image download exceeded maximum allowed size of {_MAX_MEDIA_DOWNLOAD_BYTES} bytes.")
103
+ chunks.append(chunk)
104
+
105
+ data = b"".join(chunks)
106
+
107
+ except urllib.error.URLError as e:
108
+ raise ValueError(f"Failed to download image from URL: {e}") from e
109
+ except urllib.error.HTTPError as e:
110
+ raise ValueError(f"HTTP error {e.code} when downloading image from URL: {e.reason}") from e
111
+
112
+ if not data:
113
+ raise ValueError("Downloaded image data is empty.")
114
+
115
+ return Image.open(io.BytesIO(data))
116
+
117
+
118
+ def _parse_audio_param(audio_param: Any) -> str:
119
+ """Parse audio parameter (local path, URL, or Base64) and return local file path."""
120
+ if not isinstance(audio_param, str) or not audio_param.strip():
121
+ return None
122
+
123
+ audio_param = audio_param.strip()
124
+ if os.path.exists(audio_param):
125
+ return os.path.abspath(audio_param)
126
+
127
+ input_dir = os.path.join(_PROJECT_ROOT, "input")
128
+ os.makedirs(input_dir, exist_ok=True)
129
+ temp_filename = os.path.join(input_dir, f"mcp_audio_{int(time.time()*1000)}.wav")
130
+
131
+ if audio_param.startswith("http://") or audio_param.startswith("https://"):
132
+ req = urllib.request.Request(audio_param, headers={"User-Agent": "LTX-2.5-VideoGen-MCP/1.0"})
133
+ with urllib.request.urlopen(req, timeout=_MEDIA_DOWNLOAD_TIMEOUT) as resp:
134
+ with open(temp_filename, "wb") as f:
135
+ f.write(resp.read())
136
+ return temp_filename
137
+
138
+ if audio_param.startswith("data:audio/") or len(audio_param) > 100:
139
+ try:
140
+ _, encoded = audio_param.split(",", 1) if "," in audio_param else ("", audio_param)
141
+ data = base64.b64decode(encoded)
142
+ with open(temp_filename, "wb") as f:
143
+ f.write(data)
144
+ return temp_filename
145
+ except Exception:
146
+ pass
147
+
148
+ return audio_param
149
+
150
+
151
+ _GLOBAL_OPTIONAL_INPUTS_SCHEMA = {
152
+ "negative_prompt": {
153
+ "type": "string",
154
+ "default": "pc game, console game, video game, cartoon, childish, ugly",
155
+ "description": "Negative prompt for video generation."
156
+ },
157
+ "seed": {
158
+ "type": "integer",
159
+ "default": -1,
160
+ "description": "Random seed for video generation (-1 for random seed, >=0 for deterministic reproduction)."
161
+ },
162
+ "fps": {
163
+ "type": "string",
164
+ "enum": ["24fps", "25fps"],
165
+ "default": "24fps",
166
+ "description": "Output video frame rate (default: '24fps')."
167
+ },
168
+ "use_spatial_upscaler": {
169
+ "type": "boolean",
170
+ "default": False,
171
+ "description": "Enable 2x spatial latent upscaler."
172
+ },
173
+ "use_temporal_upscaler": {
174
+ "type": "boolean",
175
+ "default": False,
176
+ "description": "Enable 2x temporal latent upscaler."
177
+ },
178
+ "zero_gpu_duration": {
179
+ "type": "integer",
180
+ "default": 60,
181
+ "minimum": 1,
182
+ "description": "GPU execution time quota allocation in seconds on HuggingFace ZeroGPU spaces (default: 60)."
183
+ }
184
+ }
185
+
186
+ _TASK_DEFINITIONS = [
187
+ {
188
+ "task_type": "t2v",
189
+ "display_name": "Text-to-Video (LTX-2.5 T2V)",
190
+ "description": "Generate videos and synchronized audio simultaneously from text prompt.",
191
+ "required_inputs": ["prompt", "duration"],
192
+ "optional_inputs": ["negative_prompt", "resolution", "aspect_ratio", "width", "height", "fps", "seed", "use_spatial_upscaler", "use_temporal_upscaler", "zero_gpu_duration"],
193
+ "optional_inputs_schema": _GLOBAL_OPTIONAL_INPUTS_SCHEMA,
194
+ "aspect_ratio_presets": ASPECT_RATIO_PRESETS,
195
+ "example_json_params": {
196
+ "task_type": "t2v",
197
+ "prompt": "A futuristic sports car drifting through neon-lit streets at night, cinematic 4k",
198
+ "negative_prompt": "pc game, console game, video game, cartoon, childish, ugly",
199
+ "resolution": "768p",
200
+ "aspect_ratio": "16:9 (Landscape)",
201
+ "width": 1344,
202
+ "height": 768,
203
+ "duration": 5.0,
204
+ "fps": "24fps",
205
+ "seed": -1,
206
+ "use_spatial_upscaler": False,
207
+ "use_temporal_upscaler": False,
208
+ "zero_gpu_duration": 60
209
+ }
210
+ },
211
+ {
212
+ "task_type": "i2v",
213
+ "display_name": "Image-to-Video (LTX-2.5 I2V)",
214
+ "description": "Generate videos and audio starting from an initial image frame.",
215
+ "required_inputs": ["prompt", "start_image", "duration"],
216
+ "optional_inputs": ["negative_prompt", "resolution", "aspect_ratio", "width", "height", "fps", "seed", "use_spatial_upscaler", "use_temporal_upscaler", "zero_gpu_duration"],
217
+ "optional_inputs_schema": _GLOBAL_OPTIONAL_INPUTS_SCHEMA,
218
+ "aspect_ratio_presets": ASPECT_RATIO_PRESETS,
219
+ "example_json_params": {
220
+ "task_type": "i2v",
221
+ "prompt": "A majestic lion standing on a cliff edge roaring proudly",
222
+ "start_image": "https://example.com/start_frame.png",
223
+ "negative_prompt": "pc game, console game, video game, cartoon, childish, ugly",
224
+ "resolution": "768p",
225
+ "aspect_ratio": "16:9 (Landscape)",
226
+ "width": 1344,
227
+ "height": 768,
228
+ "duration": 5.0,
229
+ "seed": -1,
230
+ "zero_gpu_duration": 60
231
+ }
232
+ },
233
+ {
234
+ "task_type": "flf2v",
235
+ "display_name": "First & Last Frame-to-Video (LTX-2.5 FLF2V)",
236
+ "description": "Generate continuous video & audio smoothly transitioning from first frame to last frame.",
237
+ "required_inputs": ["prompt", "start_image", "end_image", "duration"],
238
+ "optional_inputs": ["negative_prompt", "resolution", "aspect_ratio", "width", "height", "fps", "seed", "use_spatial_upscaler", "use_temporal_upscaler", "zero_gpu_duration"],
239
+ "optional_inputs_schema": _GLOBAL_OPTIONAL_INPUTS_SCHEMA,
240
+ "aspect_ratio_presets": ASPECT_RATIO_PRESETS,
241
+ "example_json_params": {
242
+ "task_type": "flf2v",
243
+ "prompt": "A rose flower bud gradually opening into full bloom",
244
+ "start_image": "https://example.com/bud.png",
245
+ "end_image": "https://example.com/bloom.png",
246
+ "negative_prompt": "pc game, console game, video game, cartoon, childish, ugly",
247
+ "resolution": "768p",
248
+ "aspect_ratio": "16:9 (Landscape)",
249
+ "width": 1344,
250
+ "height": 768,
251
+ "duration": 5.0,
252
+ "seed": -1,
253
+ "zero_gpu_duration": 60
254
+ }
255
+ },
256
+ {
257
+ "task_type": "ta2v",
258
+ "display_name": "Text & Audio-to-Video (LTX-2.5 TA2V)",
259
+ "description": "Generate video synchronized with uploaded audio input.",
260
+ "required_inputs": ["prompt", "audio_file"],
261
+ "optional_inputs": ["negative_prompt", "resolution", "aspect_ratio", "width", "height", "fps", "seed", "use_spatial_upscaler", "use_temporal_upscaler", "zero_gpu_duration"],
262
+ "optional_inputs_schema": _GLOBAL_OPTIONAL_INPUTS_SCHEMA,
263
+ "aspect_ratio_presets": ASPECT_RATIO_PRESETS,
264
+ "example_json_params": {
265
+ "task_type": "ta2v",
266
+ "prompt": "Musicians playing jazz instruments on a dimly lit stage",
267
+ "audio_file": "https://example.com/jazz_track.mp3",
268
+ "negative_prompt": "pc game, console game, video game, cartoon, childish, ugly",
269
+ "resolution": "768p",
270
+ "aspect_ratio": "16:9 (Landscape)",
271
+ "width": 1344,
272
+ "height": 768,
273
+ "seed": -1,
274
+ "zero_gpu_duration": 60
275
+ }
276
+ },
277
+ {
278
+ "task_type": "ia2v",
279
+ "display_name": "Image & Audio-to-Video (LTX-2.5 IA2V)",
280
+ "description": "Generate video synchronized with uploaded audio and starting from initial image.",
281
+ "required_inputs": ["prompt", "start_image", "audio_file"],
282
+ "optional_inputs": ["negative_prompt", "resolution", "aspect_ratio", "width", "height", "fps", "seed", "use_spatial_upscaler", "use_temporal_upscaler", "zero_gpu_duration"],
283
+ "optional_inputs_schema": _GLOBAL_OPTIONAL_INPUTS_SCHEMA,
284
+ "aspect_ratio_presets": ASPECT_RATIO_PRESETS,
285
+ "example_json_params": {
286
+ "task_type": "ia2v",
287
+ "prompt": "The character in the image begins singing and dancing with expressive motion",
288
+ "start_image": "https://example.com/singer.png",
289
+ "audio_file": "https://example.com/vocal_track.mp3",
290
+ "negative_prompt": "pc game, console game, video game, cartoon, childish, ugly",
291
+ "resolution": "768p",
292
+ "aspect_ratio": "16:9 (Landscape)",
293
+ "width": 1344,
294
+ "height": 768,
295
+ "seed": -1,
296
+ "zero_gpu_duration": 60
297
+ }
298
+ }
299
+ ]
300
+
301
+ _TASKS_DB: Dict[str, Dict[str, Any]] = {}
302
+
303
+
304
+ class DummyProgress:
305
+ def __call__(self, progress=0.0, desc=None):
306
+ pass
307
+
308
+
309
+ def _get_public_base_url() -> str:
310
+ """Auto-resolve the publicly accessible base URL."""
311
+ public_url = os.getenv("PUBLIC_URL") or os.getenv("BASE_URL")
312
+ if public_url:
313
+ return public_url.rstrip("/")
314
+
315
+ space_host = os.getenv("SPACE_HOST")
316
+ if space_host:
317
+ if not space_host.startswith("http://") and not space_host.startswith("https://"):
318
+ return f"https://{space_host}"
319
+ return space_host.rstrip("/")
320
+
321
+ try:
322
+ from core.settings import GRADIO_SERVER_NAME, SERVER_PORT
323
+ except ImportError:
324
+ GRADIO_SERVER_NAME = "127.0.0.1"
325
+ SERVER_PORT = 7860
326
+
327
+ server_name = os.getenv("GRADIO_SERVER_NAME", GRADIO_SERVER_NAME)
328
+ if server_name == "0.0.0.0":
329
+ server_name = "127.0.0.1"
330
+ port = os.getenv("GRADIO_SERVER_PORT", str(SERVER_PORT))
331
+
332
+ return f"http://{server_name}:{port}"
333
+
334
+
335
+
336
+ def _execute_imagegen_pipeline(task_id: str, params: dict):
337
+ """Execute the LTX-2.5 video generation pipeline in the background and update _TASKS_DB."""
338
+ start_time = time.time()
339
+ try:
340
+ _TASKS_DB[task_id]["status"] = "processing"
341
+ _TASKS_DB[task_id]["progress"] = 10
342
+ _TASKS_DB[task_id]["updated_at"] = int(start_time)
343
+
344
+ task_type = params.get("task_type", "t2v")
345
+ # Aliases mapping
346
+ if task_type == "t2va": task_type = "t2v"
347
+ elif task_type == "i2va": task_type = "i2v"
348
+ elif task_type == "flf2va": task_type = "flf2v"
349
+
350
+ prompt = params.get("prompt", "")
351
+ negative_prompt = params.get("negative_prompt", "pc game, console game, video game, cartoon, childish, ugly")
352
+ resolution = params.get("resolution", "768p")
353
+ aspect_ratio = params.get("aspect_ratio", "16:9 (Landscape)")
354
+ width = int(params.get("width", 0))
355
+ height = int(params.get("height", 0))
356
+ duration = float(params.get("duration", 5.0))
357
+ fps = params.get("fps", "24fps")
358
+ seed = int(params.get("seed", -1))
359
+ zero_gpu_duration = int(params.get("zero_gpu_duration", 60))
360
+ use_spatial = bool(params.get("use_spatial_upscaler", False))
361
+ use_temporal = bool(params.get("use_temporal_upscaler", False))
362
+
363
+ ui_inputs = {
364
+ "positive_prompt": prompt,
365
+ "negative_prompt": negative_prompt,
366
+ "resolution": resolution,
367
+ "aspect_ratio": aspect_ratio,
368
+ "width": width,
369
+ "height": height,
370
+ "duration": duration,
371
+ "fps": fps,
372
+ "seed": seed,
373
+ "batch_count": 1,
374
+ "zero_gpu_duration": zero_gpu_duration,
375
+ "use_spatial_upscaler": use_spatial,
376
+ "use_temporal_upscaler": use_temporal,
377
+ }
378
+
379
+ if task_type == "t2v":
380
+ from ui.shared.t2v.ltx2_5_t2v_logic import process_inputs
381
+ elif task_type == "i2v":
382
+ from ui.shared.i2v.ltx2_5_i2v_logic import process_inputs
383
+ start_raw = params.get("start_image") or params.get("first_frame_image") or params.get("first_frame")
384
+ ui_inputs["start_image"] = _parse_image_param(start_raw)
385
+ elif task_type == "flf2v":
386
+ from ui.shared.flf2v.ltx2_5_flf2v_logic import process_inputs
387
+ start_raw = params.get("start_image") or params.get("first_frame_image") or params.get("first_frame")
388
+ end_raw = params.get("end_image") or params.get("last_frame_image") or params.get("last_frame")
389
+ ui_inputs["start_image"] = _parse_image_param(start_raw)
390
+ ui_inputs["end_image"] = _parse_image_param(end_raw)
391
+ elif task_type == "ta2v":
392
+ from ui.shared.ta2v.ltx2_5_ta2v_logic import process_inputs
393
+ audio_raw = params.get("audio_file") or params.get("audio") or params.get("ref_audio1")
394
+ ui_inputs["audio_file"] = _parse_audio_param(audio_raw)
395
+ elif task_type == "ia2v":
396
+ from ui.shared.ia2v.ltx2_5_ia2v_logic import process_inputs
397
+ start_raw = params.get("start_image") or params.get("first_frame_image") or params.get("first_frame")
398
+ audio_raw = params.get("audio_file") or params.get("audio") or params.get("ref_audio1")
399
+ ui_inputs["start_image"] = _parse_image_param(start_raw)
400
+ ui_inputs["audio_file"] = _parse_audio_param(audio_raw)
401
+ else:
402
+ raise ValueError(f"Unsupported task_type '{task_type}'")
403
+
404
+ _TASKS_DB[task_id]["progress"] = 50
405
+
406
+ from core.generation_logic import generate_ltx_video_wrapper
407
+ output = generate_ltx_video_wrapper(process_inputs, ui_inputs, progress=DummyProgress())
408
+
409
+ try:
410
+ from core.settings import OUTPUT_DIR
411
+ except ImportError:
412
+ OUTPUT_DIR = os.path.join(_PROJECT_ROOT, "output")
413
+
414
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
415
+
416
+ import tempfile
417
+ import gradio.processing_utils as pu
418
+
419
+ gradio_cache_dir = os.path.join(tempfile.gettempdir(), "gradio")
420
+ os.makedirs(gradio_cache_dir, exist_ok=True)
421
+
422
+ base_url = _get_public_base_url()
423
+ videos = []
424
+ raw_list = output if isinstance(output, list) else ([output] if output else [])
425
+ for idx, item in enumerate(raw_list):
426
+ target_path = None
427
+ if isinstance(item, str) and os.path.exists(item):
428
+ target_path = item
429
+
430
+ if target_path:
431
+ try:
432
+ cached_path = pu.save_file_to_cache(target_path, cache_dir=gradio_cache_dir)
433
+ abs_path = os.path.abspath(cached_path).replace("\\", "/")
434
+ except Exception as e:
435
+ print(f"Warning: Failed to cache video file to Gradio temp dir: {e}")
436
+ abs_path = os.path.abspath(target_path).replace("\\", "/")
437
+
438
+ url = f"{base_url}/gradio_api/file={urllib.parse.quote(abs_path)}"
439
+ videos.append(url)
440
+ elif item:
441
+ videos.append(str(item))
442
+
443
+ execution_time = round(time.time() - start_time, 2)
444
+ _TASKS_DB[task_id]["status"] = "completed"
445
+ _TASKS_DB[task_id]["progress"] = 100
446
+ _TASKS_DB[task_id]["completed_at"] = int(time.time())
447
+ _TASKS_DB[task_id]["result"] = {
448
+ "task_type": task_type,
449
+ "videos": videos,
450
+ "video_path": raw_list[0] if raw_list and isinstance(raw_list[0], str) else None,
451
+ "seed": seed,
452
+ "resolution": resolution,
453
+ "aspect_ratio": aspect_ratio,
454
+ "duration": duration,
455
+ "execution_time_seconds": execution_time,
456
+ }
457
+
458
+ except Exception as e:
459
+ _TASKS_DB[task_id]["status"] = "failed"
460
+ _TASKS_DB[task_id]["progress"] = 0
461
+ _TASKS_DB[task_id]["failed_at"] = int(time.time())
462
+ _TASKS_DB[task_id]["error"] = {
463
+ "code": "EXECUTION_ERROR",
464
+ "message": str(e),
465
+ }
mcp_tools/error_schema.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unified MCP tool error response format.
3
+
4
+ Error code enumeration:
5
+ - INVALID_PARAMS: Parameter validation failed (missing required fields, type errors, value out of range)
6
+ - MODEL_NOT_FOUND: The specified model name does not exist
7
+ - ARCHITECTURE_NOT_FOUND: The specified architecture name does not exist
8
+ - CHAIN_TYPE_NOT_FOUND: The specified chain/injector type is invalid
9
+ - FEATURE_NOT_SUPPORTED: The current model does not support the requested feature
10
+ - TASK_NOT_FOUND: The async task ID does not exist
11
+ - MODEL_OOM: GPU out of memory
12
+ - INTERNAL_ERROR: Internal server error
13
+ """
14
+
15
+
16
+ def make_error(code: str, message: str, details: dict = None) -> dict:
17
+ """
18
+ Construct a unified MCP tool error response.
19
+
20
+ Args:
21
+ code: Error code (UPPER_SNAKE_CASE format)
22
+ message: Human-readable error description
23
+ details: Optional details dictionary
24
+
25
+ Returns:
26
+ Standardized error response dictionary
27
+ """
28
+ error = {
29
+ "error": {
30
+ "code": code,
31
+ "message": message,
32
+ }
33
+ }
34
+ if details:
35
+ error["error"]["details"] = details
36
+ return error
37
+
38
+
39
+ def make_validation_error(
40
+ message: str = "Request validation failed.",
41
+ missing_fields: list = None,
42
+ invalid_fields: dict = None,
43
+ ) -> dict:
44
+ """
45
+ Construct a parameter validation failure error response.
46
+
47
+ Args:
48
+ message: Error description
49
+ missing_fields: List of missing required field names
50
+ invalid_fields: Key-value pairs of invalid fields, key=field name, value=reason description
51
+
52
+ Returns:
53
+ Standardized INVALID_PARAMS error response
54
+ """
55
+ details = {}
56
+ if missing_fields:
57
+ details["missing_fields"] = missing_fields
58
+ if invalid_fields:
59
+ details["invalid_fields"] = invalid_fields
60
+ return make_error("INVALID_PARAMS", message, details if details else None)
61
+
62
+
63
+ def make_not_found_error(resource_type: str, resource_id: str) -> dict:
64
+ """
65
+ Construct a resource-not-found error response.
66
+
67
+ Args:
68
+ resource_type: Resource type (e.g., "model", "architecture", "chain_type", "task")
69
+ resource_id: Resource identifier
70
+
71
+ Returns:
72
+ Standardized *_NOT_FOUND error response
73
+ """
74
+ code_map = {
75
+ "model": "MODEL_NOT_FOUND",
76
+ "architecture": "ARCHITECTURE_NOT_FOUND",
77
+ "chain_type": "CHAIN_TYPE_NOT_FOUND",
78
+ "task": "TASK_NOT_FOUND",
79
+ }
80
+ code = code_map.get(resource_type, f"{resource_type.upper()}_NOT_FOUND")
81
+ return make_error(
82
+ code,
83
+ f"The specified {resource_type} '{resource_id}' was not found.",
84
+ {"resource_type": resource_type, "resource_id": resource_id},
85
+ )
mcp_tools/get_task_list.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Tool: get_task_list
3
+ Get a list of all supported image generation task types along with their required/optional parameter lists.
4
+ """
5
+
6
+ from .common import _TASK_DEFINITIONS
7
+
8
+
9
+ def handle_get_task_list() -> list:
10
+ """Get a list of all supported image generation task types along with their required/optional parameter lists."""
11
+ return _TASK_DEFINITIONS
mcp_tools/get_task_status.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Tool: get_task_status
3
+ Query the processing progress and final results of an async image generation task.
4
+ """
5
+
6
+ from .common import _TASKS_DB
7
+ from .error_schema import make_validation_error, make_not_found_error
8
+
9
+
10
+ def handle_get_task_status(task_id: str) -> dict:
11
+ """Query the processing progress and final results of an async image generation task."""
12
+ if not task_id:
13
+ return make_validation_error(
14
+ "Parameter 'task_id' is required.",
15
+ missing_fields=["task_id"],
16
+ )
17
+
18
+ if task_id not in _TASKS_DB:
19
+ return make_not_found_error("task", task_id)
20
+
21
+ return _TASKS_DB[task_id]
mcp_tools/mcp_gradio_integration.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP & Gradio Integration Module
3
+
4
+ Provides:
5
+ 1. register_high_level_mcp_apis: Expose high-level abstract API/MCP endpoints (using gr.api without polluting the visual UI structure)
6
+ 2. cleanup_dependencies_api_names: Force cleanup of show_api attribute for non-high-level APIs in dependencies
7
+ 3. patch_gradio_api_suppression: No-op implementation retained for backward compatibility
8
+ """
9
+
10
+ import json
11
+ import gradio as gr
12
+
13
+ from .get_task_list import handle_get_task_list
14
+ from .run import handle_run
15
+ from .get_task_status import handle_get_task_status
16
+
17
+ HIGH_LEVEL_MCP_API_NAMES = {
18
+ "get_task_list",
19
+ "run",
20
+ "get_task_status",
21
+ }
22
+
23
+
24
+ def sanitize_keys(obj):
25
+ """Recursively ensure all dictionary keys are converted to str type to avoid Gradio 5 orjson TypeError: Dict key must be str."""
26
+ if isinstance(obj, dict):
27
+ return {str(k): sanitize_keys(v) for k, v in obj.items()}
28
+ elif isinstance(obj, list):
29
+ return [sanitize_keys(x) for x in obj]
30
+ elif isinstance(obj, tuple):
31
+ return tuple(sanitize_keys(x) for x in obj)
32
+ return obj
33
+
34
+
35
+ def patch_gradio_api_suppression():
36
+ """Retained for backward compatibility (no-op)."""
37
+ pass
38
+
39
+
40
+ def cleanup_dependencies_api_names(demo):
41
+ """
42
+ Clean up residual auto-generated API names in demo.fns and demo.dependencies.
43
+ Force only high-level abstract MCP APIs to be exposed as public endpoints.
44
+ """
45
+ for fn in demo.fns.values():
46
+ api_name = getattr(fn, "api_name", None)
47
+ if api_name not in HIGH_LEVEL_MCP_API_NAMES:
48
+ fn.show_api = False
49
+
50
+ deps = getattr(demo, "dependencies", None)
51
+ if deps is None and hasattr(demo, "config") and isinstance(demo.config, dict):
52
+ deps = demo.config.get("dependencies", [])
53
+
54
+ if deps:
55
+ for dep in deps:
56
+ if isinstance(dep, dict):
57
+ api_name = dep.get("api_name")
58
+ if api_name not in HIGH_LEVEL_MCP_API_NAMES:
59
+ dep["show_api"] = False
60
+
61
+ print("[MCP Protection] Cleaned up demo dependencies. Suppressed atomic API endpoints.")
62
+
63
+
64
+ def register_high_level_mcp_apis(demo):
65
+ """
66
+ Explicitly register high-level abstract MCP API endpoints on the Gradio demo using gr.api.
67
+ Using gr.api() never adds any visual UI components (such as Row, Textbox, Button, etc.), avoiding duplicate interface rendering.
68
+ """
69
+ def get_task_list() -> list:
70
+ """[Recommended Flow Step 1] Get a list of supported video generation task types (t2va, i2va, flf2va, ref2va) along with their required/optional parameters and paste-and-run example_json_params."""
71
+ return sanitize_keys(handle_get_task_list())
72
+
73
+ def run(json_params: str = "{}") -> dict:
74
+ """[Recommended Flow Step 2] Unified video generation task execution interface (t2va, i2va, flf2va, ref2va). Accepts a JSON string or dict of parameters.
75
+
76
+ REQUIRED PARAMETERS PER TASK TYPE:
77
+ - t2va: task_type, prompt, width, height, duration
78
+ - i2va: task_type, prompt, width, height, duration, first_frame_image
79
+ - flf2va: task_type, prompt, width, height, duration, first_frame_image, last_frame_image
80
+ - ref2va: task_type, prompt, width, height, duration
81
+
82
+ GLOBAL OPTIONAL CONTROL PARAMETERS:
83
+ - seed (int): Random seed (-1 for random, >=0 for deterministic reproducibility). Default: -1.
84
+ - zero_gpu_duration (int): GPU quota allocation in seconds on HuggingFace ZeroGPU spaces (default: 120).
85
+
86
+ REF2VA OPTIONAL MEDIA REFERENCES:
87
+ - ref_image1..9: Image file path or URL
88
+ - ref_video1..3: Video file path
89
+ - ref_audio1..3: Audio file path
90
+ """
91
+ try:
92
+ if isinstance(json_params, dict):
93
+ params = json_params
94
+ else:
95
+ params = json.loads(json_params or "{}")
96
+ except Exception as e:
97
+ return {"error": {"code": "INVALID_JSON", "message": f"Failed to parse JSON params: {e}"}}
98
+ return sanitize_keys(handle_run(params))
99
+
100
+ def get_task_status(task_id: str = "") -> dict:
101
+ """Query the progress, status, and final generated results of an async video generation task."""
102
+ if isinstance(task_id, dict):
103
+ task_id = task_id.get("task_id") or task_id.get("id") or ""
104
+ task_id_str = task_id.strip() if isinstance(task_id, str) else ""
105
+ return sanitize_keys(handle_get_task_status(task_id_str))
106
+
107
+ funcs = [
108
+ get_task_list,
109
+ run,
110
+ get_task_status,
111
+ ]
112
+
113
+ for func in funcs:
114
+ gr.api(func)
115
+
116
+ for fn in demo.fns.values():
117
+ if getattr(fn, "api_name", None) in HIGH_LEVEL_MCP_API_NAMES:
118
+ fn.show_api = True
119
+
120
+ print(f"[MCP Integration] Successfully registered {len(funcs)} High-Level Abstract MCP APIs via gr.api().")
mcp_tools/run.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Tool: run
3
+ Unified video generation task submission and execution interface for Lightricks LTX-2.5.
4
+ """
5
+
6
+ import time
7
+ import uuid
8
+ import threading
9
+ from .common import (
10
+ _TASK_DEFINITIONS,
11
+ _TASKS_DB,
12
+ _execute_imagegen_pipeline,
13
+ )
14
+ from .error_schema import make_validation_error
15
+
16
+
17
+ def handle_run(params: dict) -> dict:
18
+ """
19
+ Unified Lightricks LTX-2.5 video generation task execution interface.
20
+
21
+ [SUPPORTED TASK TYPES]
22
+ - t2v: Text-to-Video. Required: prompt, duration.
23
+ - i2v: Image-to-Video. Required: prompt, start_image, duration.
24
+ - flf2v: First & Last Frame-to-Video. Required: prompt, start_image, end_image, duration.
25
+ - ta2v: Text & Audio-to-Video. Required: prompt, audio_file.
26
+ - ia2v: Image & Audio-to-Video. Required: prompt, start_image, audio_file.
27
+
28
+ [GLOBAL OPTIONAL PARAMETERS]
29
+ - negative_prompt (str): Negative prompt (default: "pc game, console game, video game, cartoon, childish, ugly").
30
+ - resolution (str): Resolution preset: "544p", "768p", or "1080p" (default: "768p").
31
+ - aspect_ratio (str): Aspect ratio: "16:9 (Landscape)", "9:16 (Portrait)", "1:1 (Square)", "4:3 (Classic TV)", "3:4 (Classic Portrait)", "3:2 (Landscape)", "2:3 (Portrait)" (default: "16:9 (Landscape)").
32
+ - width (int): Custom width in pixels (overrides preset if specified).
33
+ - height (int): Custom height in pixels (overrides preset if specified).
34
+ - fps (str): Frame rate: "24fps" or "25fps" (default: "24fps").
35
+ - seed (int): Random seed (-1 for random seed, >=0 for deterministic reproduction). Default: -1.
36
+ - use_spatial_upscaler (bool): Enable 2x spatial latent upscaler (default: False).
37
+ - use_temporal_upscaler (bool): Enable 2x temporal latent upscaler (default: False).
38
+ - zero_gpu_duration (int): GPU execution time quota allocation in seconds on HuggingFace ZeroGPU spaces (default: 60).
39
+
40
+ [Paste-and-Run json_params Example (t2v)]
41
+ {
42
+ "task_type": "t2v",
43
+ "prompt": "A futuristic sports car driving through a cyber city at sunset, 4k resolution",
44
+ "negative_prompt": "pc game, console game, video game, cartoon, childish, ugly",
45
+ "resolution": "768p",
46
+ "aspect_ratio": "16:9 (Landscape)",
47
+ "width": 1344,
48
+ "height": 768,
49
+ "duration": 5.0,
50
+ "seed": -1,
51
+ "zero_gpu_duration": 60
52
+ }
53
+ """
54
+ if not isinstance(params, dict):
55
+ return make_validation_error("Request params must be an object.")
56
+
57
+ valid_tasks = [t["task_type"] for t in _TASK_DEFINITIONS]
58
+ # Add alias mappings
59
+ alias_map = {"t2va": "t2v", "i2va": "i2v", "flf2va": "flf2v"}
60
+ raw_task_type = params.get("task_type")
61
+ task_type = alias_map.get(raw_task_type, raw_task_type)
62
+
63
+ if not task_type or task_type not in valid_tasks:
64
+ return make_validation_error(
65
+ f"Invalid or missing 'task_type'. Must be one of {valid_tasks}.",
66
+ invalid_fields={"task_type": f"Must be in {valid_tasks}"},
67
+ )
68
+
69
+ task_def = next((t for t in _TASK_DEFINITIONS if t["task_type"] == task_type), None)
70
+ required_fields = task_def["required_inputs"] if task_def else ["prompt"]
71
+
72
+ missing = []
73
+ for req_field in required_fields:
74
+ if req_field not in params or params[req_field] is None or params[req_field] == "":
75
+ # Check legacy alias keys
76
+ if req_field == "start_image" and (params.get("first_frame_image") or params.get("first_frame")):
77
+ continue
78
+ if req_field == "end_image" and (params.get("last_frame_image") or params.get("last_frame")):
79
+ continue
80
+ if req_field == "audio_file" and (params.get("audio") or params.get("ref_audio1")):
81
+ continue
82
+ missing.append(req_field)
83
+
84
+ if missing:
85
+ return make_validation_error(
86
+ f"Missing required parameter(s) for task '{task_type}': {', '.join(missing)}",
87
+ missing_fields=missing,
88
+ )
89
+
90
+ task_id = f"v_task_{uuid.uuid4().hex[:10]}"
91
+ created_at = int(time.time())
92
+
93
+ _TASKS_DB[task_id] = {
94
+ "task_id": task_id,
95
+ "status": "queued",
96
+ "progress": 0,
97
+ "created_at": created_at,
98
+ }
99
+
100
+ async_exec = params.get("async_execution", False)
101
+
102
+ if async_exec:
103
+ t = threading.Thread(target=_execute_imagegen_pipeline, args=(task_id, params), daemon=True)
104
+ t.start()
105
+ return {
106
+ "status": "queued",
107
+ "task_id": task_id,
108
+ "poll_interval_ms": 2000,
109
+ "message": "Task queued successfully. Poll get_task_status for results.",
110
+ }
111
+ else:
112
+ _execute_imagegen_pipeline(task_id, params)
113
+ return _TASKS_DB[task_id]
mcp_tools/tool_handlers.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Tool Handlers — Aggregation entry point.
3
+ Core logic is located in individual task files (get_task_list.py, run.py, get_task_status.py).
4
+ """
5
+
6
+ from .get_task_list import handle_get_task_list
7
+ from .run import handle_run
8
+ from .get_task_status import handle_get_task_status
9
+ from .common import (
10
+ _TASK_DEFINITIONS,
11
+ _TASKS_DB,
12
+ _load_yaml,
13
+ )
14
+
15
+ __all__ = [
16
+ "handle_get_task_list",
17
+ "handle_run",
18
+ "handle_get_task_status",
19
+ ]
models.py DELETED
@@ -1,338 +0,0 @@
1
- """Model file registry: maps filename -> (HuggingFace repo, subfolder).
2
-
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
10
- import os
11
- import pathlib
12
- from collections.abc import Iterator
13
- from dataclasses import dataclass
14
-
15
- from huggingface_hub import hf_hub_download
16
-
17
- logger = logging.getLogger(__name__)
18
-
19
-
20
- @dataclass(frozen=True)
21
- class ModelEntry:
22
- repo_id: str
23
- subfolder: str = "" # path within the HF repo
24
- comfy_type: str = "checkpoints" # ComfyUI models/<comfy_type>/ subdirectory
25
- # If the workflow expects a different filename than what's in the HF repo
26
- # (e.g. user's local "ltx-2.3-22b-dev_transformer_only_fp8_scaled.safetensors"
27
- # is actually `_transformer_only_bf16.safetensors` in Kijai's repo), set
28
- # source_filename to the actual repo filename. The local symlink/copy uses
29
- # the registry key as its name.
30
- source_filename: str | None = None
31
-
32
-
33
- MODEL_REGISTRY: dict[str, ModelEntry] = {
34
- # Main LTX 2.3 transformer + LoRAs + upscalers
35
- "ltx-2.3-22b-distilled.safetensors": ModelEntry("Lightricks/LTX-2.3", comfy_type="checkpoints"),
36
- "ltx-2.3-22b-dev.safetensors": ModelEntry("Lightricks/LTX-2.3", comfy_type="checkpoints"),
37
- "ltx-2.3-spatial-upscaler-x2-1.0.safetensors": ModelEntry(
38
- "Lightricks/LTX-2.3", comfy_type="latent_upscale_models"
39
- ),
40
- "ltx-2.3-22b-distilled-lora-384.safetensors": ModelEntry(
41
- "Lightricks/LTX-2.3", comfy_type="loras"
42
- ),
43
- # Gemma 3 12B (5 shards + tokenizer/preprocessor)
44
- **{
45
- f"model-{i:05d}-of-00005.safetensors": ModelEntry(
46
- "google/gemma-3-12b-it-qat-q4_0-unquantized",
47
- comfy_type="text_encoders",
48
- subfolder="gemma-3-12b-it",
49
- )
50
- for i in range(1, 6)
51
- },
52
- "model.safetensors.index.json": ModelEntry(
53
- "google/gemma-3-12b-it-qat-q4_0-unquantized",
54
- comfy_type="text_encoders",
55
- subfolder="gemma-3-12b-it",
56
- ),
57
- "tokenizer.model": ModelEntry(
58
- "google/gemma-3-12b-it-qat-q4_0-unquantized",
59
- comfy_type="text_encoders",
60
- subfolder="gemma-3-12b-it",
61
- ),
62
- "preprocessor_config.json": ModelEntry(
63
- "google/gemma-3-12b-it-qat-q4_0-unquantized",
64
- comfy_type="text_encoders",
65
- subfolder="gemma-3-12b-it",
66
- ),
67
- # Kijai's LTX 2.3 ComfyUI assets — files live in vae/ and text_encoders/
68
- # subfolders within the repo, not at root.
69
- "LTX23_video_vae_bf16.safetensors": ModelEntry(
70
- "Kijai/LTX2.3_comfy", subfolder="vae", comfy_type="vae"
71
- ),
72
- "LTX23_audio_vae_bf16.safetensors": ModelEntry(
73
- "Kijai/LTX2.3_comfy", subfolder="vae", comfy_type="vae"
74
- ),
75
- "ltx-2.3_text_projection_bf16.safetensors": ModelEntry(
76
- "Kijai/LTX2.3_comfy", subfolder="text_encoders", comfy_type="text_encoders"
77
- ),
78
- # IC-LoRAs
79
- "ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors": ModelEntry(
80
- "Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control", comfy_type="loras"
81
- ),
82
- "ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors": ModelEntry(
83
- "Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control", comfy_type="loras"
84
- ),
85
- "ltx-2-19b-ic-lora-detailer.safetensors": ModelEntry(
86
- "Lightricks/LTX-2-19b-IC-LoRA-Detailer", comfy_type="loras"
87
- ),
88
- "ltx-2-19b-ic-lora-pose-control.safetensors": ModelEntry(
89
- "Lightricks/LTX-2-19b-IC-LoRA-Pose-Control", comfy_type="loras"
90
- ),
91
- # Camera-control LoRAs (one repo each — explicit hyphen-aware capitalization
92
- # produces "Dolly-In", "Dolly-Out", etc. matching the actual HF org repo names.)
93
- **{
94
- f"ltx-2-19b-lora-camera-control-{movement}.safetensors": ModelEntry(
95
- f"Lightricks/LTX-2-19b-LoRA-Camera-Control-{'-'.join(p.capitalize() for p in movement.split('-'))}",
96
- comfy_type="loras",
97
- )
98
- for movement in (
99
- "static",
100
- "dolly-in",
101
- "dolly-out",
102
- "dolly-left",
103
- "dolly-right",
104
- "jib-up",
105
- "jib-down",
106
- )
107
- },
108
- # ----- Renamed/aliased filenames the user's master workflow references.
109
- # The names look like quantized variants (FP4, FP8, GGUF) but the actual
110
- # bytes behind them are BF16 — the user's local setup uses symlinks to
111
- # canonical sources. On Spaces we download the same canonical sources via
112
- # huggingface_hub and place them under the workflow-expected filename.
113
- # All of these entries set `subfolder` to the path within the repo and
114
- # rely on hf_hub_download returning the cached snapshot path (which we
115
- # then symlink to comfy_models/<comfy_type>/<filename>).
116
- "gemma_3_12B_it_fp4_mixed.safetensors": ModelEntry(
117
- # Comfy-Org/ltx-2 ships BF16 Gemma packed as `gemma_3_12B_it.safetensors`
118
- # in split_files/text_encoders/. The workflow expects the FP4-named
119
- # variant; we serve the same file under that name.
120
- "Comfy-Org/ltx-2",
121
- subfolder="split_files/text_encoders",
122
- comfy_type="text_encoders",
123
- source_filename="gemma_3_12B_it.safetensors",
124
- ),
125
- "gemma_3_12B_it.safetensors": ModelEntry(
126
- "Comfy-Org/ltx-2",
127
- subfolder="split_files/text_encoders",
128
- comfy_type="text_encoders",
129
- ),
130
- "ltx-2.3-22b-dev_transformer_only_fp8_scaled.safetensors": ModelEntry(
131
- # Kijai's BF16 transformer-only — actual repo filename has `_bf16` suffix.
132
- "Kijai/LTX2.3_comfy",
133
- subfolder="diffusion_models",
134
- comfy_type="diffusion_models",
135
- source_filename="ltx-2.3-22b-dev_transformer_only_bf16.safetensors",
136
- ),
137
- "ltx-2-3-22b-dev-Q4_K_M.gguf": ModelEntry(
138
- # Unsloth's GGUF in BF16 (named `…-BF16.gguf` in repo).
139
- "unsloth/LTX-2.3-GGUF",
140
- comfy_type="diffusion_models",
141
- source_filename="ltx-2.3-22b-dev-BF16.gguf",
142
- ),
143
- "taeltx2_3.safetensors": ModelEntry(
144
- "Kijai/LTX2.3_comfy",
145
- subfolder="vae",
146
- comfy_type="vae",
147
- ),
148
- "ltx-2.3-22b-distilled-lora-dynamic_fro09_avg_rank_105_bf16.safetensors": ModelEntry(
149
- "Kijai/LTX2.3_comfy",
150
- subfolder="loras",
151
- comfy_type="loras",
152
- ),
153
- }
154
-
155
-
156
- LOADER_NODE_TYPES: tuple[str, ...] = (
157
- "CheckpointLoaderSimple",
158
- "UNETLoader",
159
- "UnetLoaderGGUF",
160
- "VAELoader",
161
- "VAELoaderKJ",
162
- "LoraLoader",
163
- "Power Lora Loader (rgthree)",
164
- "LTXVGemmaCLIPModelLoader",
165
- "LatentUpscaleModelLoader",
166
- "DualCLIPLoader",
167
- )
168
-
169
-
170
- _USER_INPUT_LOADERS = {"LoadImage", "VHS_LoadVideo", "VHS_LoadAudioUpload"}
171
- _MODEL_EXTS = (".safetensors", ".gguf", ".pt", ".bin", ".ckpt")
172
-
173
-
174
- def _walk_for_filenames(value, into: set[str]) -> None:
175
- """Depth-first walk of a node's inputs, picking out model filenames.
176
-
177
- Power Lora Loader stores its rows nested as `inputs.lora_1 = {on, lora,
178
- strength}` and similar — a flat values() loop misses these. Recurse
179
- through dicts and lists/tuples so nested filenames are caught.
180
-
181
- Skips Power Lora Loader rows with `on: false` — those LoRAs aren't
182
- actually loaded at runtime so there's no point downloading them.
183
- """
184
- if isinstance(value, str):
185
- if value.endswith(_MODEL_EXTS) or value == "tokenizer.model":
186
- into.add(value)
187
- elif isinstance(value, dict):
188
- # Power Lora Loader row: {"on": bool, "lora": "...", "strength": ...}
189
- if "on" in value and "lora" in value and not value.get("on"):
190
- return
191
- for v in value.values():
192
- _walk_for_filenames(v, into)
193
- elif isinstance(value, (list, tuple)):
194
- for v in value:
195
- _walk_for_filenames(v, into)
196
-
197
-
198
- def walk_workflow_for_models(workflow: dict) -> set[str]:
199
- """Return the set of model filenames referenced by the API-format workflow.
200
-
201
- Walks `{node_id: {class_type, inputs}}` and recursively scans each node's
202
- inputs for strings ending in a model extension. Skips loaders that read
203
- user-supplied files (LoadImage, VHS_LoadVideo, VHS_LoadAudioUpload).
204
- Unknown filenames are harmless — `ensure_models` log-warns and skips
205
- anything not in the registry, so being inclusive here costs nothing.
206
- """
207
- needed: set[str] = set()
208
- for node in workflow.values():
209
- if not isinstance(node, dict):
210
- continue
211
- if node.get("class_type") in _USER_INPUT_LOADERS:
212
- continue
213
- _walk_for_filenames(node.get("inputs") or {}, needed)
214
- return needed
215
-
216
-
217
- @dataclass
218
- class DownloadEvent:
219
- filename: str
220
- mb_done: float
221
- mb_total: float
222
-
223
-
224
- def _on_spaces() -> bool:
225
- return bool(os.environ.get("SPACES_ZERO_GPU"))
226
-
227
-
228
- def _comfy_models_dir() -> pathlib.Path:
229
- raw = os.environ.get("COMFY_MODELS_DIR")
230
- if raw:
231
- return pathlib.Path(raw)
232
- if _on_spaces():
233
- return pathlib.Path.home() / "comfyui" / "models"
234
- return pathlib.Path(__file__).parent / "comfyui" / "models"
235
-
236
-
237
- def ensure_models(filenames: set[str]) -> Iterator[DownloadEvent]:
238
- """Ensure each requested model is materialized in comfyui/models/<type>/.
239
-
240
- Local mode: hf_hub_download into the user's HF cache; symlink to comfyui/models/.
241
- Spaces mode: hf_hub_download with cache_dir under $HOME (no /data dependency);
242
- files staged at ~/comfyui/models/<comfy_type>/<filename>.
243
-
244
- Files not in MODEL_REGISTRY are skipped (with a warning) — useful when the
245
- workflow has been manually customized with non-canonical filenames that the
246
- user supplies via their own ComfyUI install.
247
-
248
- Yields DownloadEvent on each successfully materialized file (mb_done==mb_total
249
- when already cached locally).
250
- """
251
- comfy_models = _comfy_models_dir()
252
- cache_dir = pathlib.Path(
253
- os.environ.get(
254
- "HF_HUB_CACHE",
255
- pathlib.Path.home() / ".cache" / "huggingface" / "hub",
256
- )
257
- )
258
-
259
- for filename in filenames:
260
- if filename not in MODEL_REGISTRY:
261
- logger.warning(
262
- "model file %r not in MODEL_REGISTRY; skipping. "
263
- "Add an entry to MODEL_REGISTRY or override the loader in the workflow.",
264
- filename,
265
- )
266
- continue
267
- entry = MODEL_REGISTRY[filename]
268
-
269
- # Short-circuit: if the file is already present at its expected location
270
- # comfyui/models/<comfy_type>/<filename>, skip. Subfolder is part of the
271
- # HF source path, not the destination, so the dest is always a flat
272
- # comfyui/models/<comfy_type>/<filename>.
273
- existing_dest = comfy_models / entry.comfy_type / filename
274
- if existing_dest.exists() or existing_dest.is_symlink():
275
- yield DownloadEvent(filename, 0.0, 0.0)
276
- continue
277
-
278
- # The HF-side filename may differ from the workflow-expected name
279
- # (e.g. user's `_fp8_scaled.safetensors` is actually `_bf16.safetensors`
280
- # in the upstream repo). Honor `source_filename` when set.
281
- hf_filename = entry.source_filename or filename
282
- hf_path = f"{entry.subfolder}/{hf_filename}" if entry.subfolder else hf_filename
283
-
284
- try:
285
- source = pathlib.Path(
286
- hf_hub_download(
287
- repo_id=entry.repo_id,
288
- filename=hf_path,
289
- cache_dir=str(cache_dir),
290
- local_dir=None,
291
- )
292
- )
293
- size_mb = source.stat().st_size / 1024 / 1024
294
- yield DownloadEvent(filename, size_mb, size_mb)
295
- except Exception as exc:
296
- # Fall back to scanning the cache for a matching file (test mode +
297
- # offline mode). Look for either the workflow filename OR the
298
- # HF-side filename. Skip `.no_exist/` markers and 0-byte stubs —
299
- # the HF lib leaves those after a 404, and symlinking them past
300
- # safetensors yields a confusing "header too small" error
301
- # downstream.
302
- def _viable(path):
303
- try:
304
- return ".no_exist" not in path.parts and path.stat().st_size > 64
305
- except OSError:
306
- return False
307
-
308
- candidates = [
309
- p for p in cache_dir.rglob(filename) if _viable(p)
310
- ] or [
311
- p for p in cache_dir.rglob(hf_filename) if _viable(p)
312
- ]
313
- if not candidates:
314
- logger.warning(
315
- "could not download or locate %r (hf=%r) in HF cache: %s; skipping",
316
- filename, hf_filename, exc,
317
- )
318
- continue
319
- source = candidates[0]
320
- yield DownloadEvent(filename, 0.0, 0.0)
321
-
322
- # Stage at comfy_models/<comfy_type>/<filename> (workflow-expected name).
323
- dest_dir = comfy_models / entry.comfy_type
324
- dest_dir.mkdir(parents=True, exist_ok=True)
325
- dest = dest_dir / filename
326
-
327
- if dest.is_symlink() or dest.exists():
328
- dest.unlink()
329
- dest.symlink_to(source)
330
-
331
-
332
- def ensure_models_for_mode(mode: str) -> Iterator[DownloadEvent]:
333
- """Convenience: walk a mode's workflow and ensure all referenced models exist."""
334
- import workflow as workflow_module # local import to avoid cycle at import time
335
-
336
- wf = workflow_module.load_template(mode)
337
- needed = walk_workflow_for_models(wf)
338
- yield from ensure_models(needed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modes.py DELETED
@@ -1,217 +0,0 @@
1
- """MODE_REGISTRY — one Mode entry per generation mode.
2
-
3
- Each Mode declares:
4
- - name: short id ("t2v", "i2v", ...)
5
- - label: display name
6
- - icon: single-character or emoji icon for the sidebar
7
- - stage_map: list of (label, expected_share_pct) for the status banner
8
- - parameterize_fn: (Gradio inputs dict) -> list[(node_id, field_name, value)]
9
-
10
- The workflows live in `workflows/<mode>.json` in ComfyUI's API format
11
- (`{node_id_str: {class_type, inputs}}` — produced by the editor's
12
- "Save (API Format)" feature). That format is what `PromptExecutor.execute()`
13
- consumes directly, so parameterize_fns just patch field values by node id;
14
- no graph→API conversion is needed.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- from collections.abc import Callable
20
- from dataclasses import dataclass, field
21
- from typing import Any
22
-
23
- # (node_id, field_name, value)
24
- Patch = tuple[str, str, Any]
25
- ParameterizeFn = Callable[[dict[str, Any]], list[Patch]]
26
-
27
-
28
- @dataclass(frozen=True)
29
- class Stage:
30
- label: str
31
- share_pct: int # rough share of total time, sums to ~100 across stages
32
-
33
-
34
- @dataclass(frozen=True)
35
- class Mode:
36
- name: str
37
- label: str
38
- icon: str
39
- parameterize_fn: ParameterizeFn
40
- stage_map: list[Stage] = field(default_factory=list)
41
-
42
-
43
- MODE_REGISTRY: dict[str, Mode] = {}
44
-
45
-
46
- # ---------------------------------------------------------------------------
47
- # Shared user-input node IDs across all 6 mode API workflows.
48
- # Captured 2026-05-01 from `/Users/techfreakworm/Downloads/workflows/*_api.json`
49
- # (master workflow exported via "Save API Format" per mode).
50
- # ---------------------------------------------------------------------------
51
-
52
- NODE_PROMPT = "5536" # CLIPTextEncode (positive) — inputs.text
53
- NODE_NEG_PROMPT = "5537" # CLIPTextEncode (negative) — inputs.text
54
- NODE_WIDTH = "5383" # INTConstant — inputs.value
55
- NODE_HEIGHT = "5382" # INTConstant — inputs.value
56
- NODE_FPS = "5445" # INTConstant — inputs.value
57
- NODE_CLIP_SECONDS = "196" # mxSlider — inputs.Xi (length in seconds; frames = Xi*fps+1)
58
- NODE_IMAGE_1 = "149" # LoadImage (first frame / portrait) — inputs.image
59
- NODE_IMAGE_2 = "5437" # LoadImage (last frame for keyframe mode) — inputs.image
60
- NODE_AUDIO = "5400" # VHS_LoadAudioUpload — inputs.audio
61
- NODE_VIDEO = "5444" # VHS_LoadVideo — inputs.video
62
-
63
- # Per-mode RandomNoise (subgraph-internal): id format `<subgraph_inst>:<inner>`.
64
- SEED_NODE_BY_MODE: dict[str, str] = {
65
- "t2v": "5464:5539",
66
- "a2v": "463:5540",
67
- "i2v": "209:5541",
68
- "lipsync": "521:5542",
69
- "keyframe": "670:5543",
70
- "style": "5364:5545",
71
- }
72
-
73
-
74
- def _seconds_for(frames: int, fps: int) -> int:
75
- """Inverse of `frames = seconds*fps + 1` from the master's MathExpression."""
76
- return max(1, (max(1, int(frames)) - 1) // max(1, int(fps)))
77
-
78
-
79
- def _shared_patches(inp: dict[str, Any], mode: str) -> list[Patch]:
80
- return [
81
- (NODE_PROMPT, "text", inp.get("prompt", "")),
82
- (NODE_NEG_PROMPT, "text", inp.get("negative_prompt", "")),
83
- (NODE_WIDTH, "value", int(inp.get("width", 512))),
84
- (NODE_HEIGHT, "value", int(inp.get("height", 768))),
85
- (NODE_FPS, "value", int(inp.get("fps", 24))),
86
- (
87
- NODE_CLIP_SECONDS,
88
- "Xi",
89
- _seconds_for(int(inp.get("frames", 81)), int(inp.get("fps", 24))),
90
- ),
91
- (SEED_NODE_BY_MODE[mode], "noise_seed", int(inp.get("seed", 42))),
92
- ]
93
-
94
-
95
- def _t2v_parameterize(inp: dict[str, Any]) -> list[Patch]:
96
- return _shared_patches(inp, "t2v")
97
-
98
-
99
- def _i2v_parameterize(inp: dict[str, Any]) -> list[Patch]:
100
- return _shared_patches(inp, "i2v") + [
101
- (NODE_IMAGE_1, "image", inp["image"]),
102
- ]
103
-
104
-
105
- def _a2v_parameterize(inp: dict[str, Any]) -> list[Patch]:
106
- return _shared_patches(inp, "a2v") + [
107
- (NODE_AUDIO, "audio", inp["audio"]),
108
- ]
109
-
110
-
111
- def _lipsync_parameterize(inp: dict[str, Any]) -> list[Patch]:
112
- return _shared_patches(inp, "lipsync") + [
113
- (NODE_IMAGE_1, "image", inp["image"]),
114
- (NODE_AUDIO, "audio", inp["audio"]),
115
- ]
116
-
117
-
118
- def _keyframe_parameterize(inp: dict[str, Any]) -> list[Patch]:
119
- return _shared_patches(inp, "keyframe") + [
120
- (NODE_IMAGE_1, "image", inp["first_frame"]),
121
- (NODE_IMAGE_2, "image", inp["last_frame"]),
122
- ]
123
-
124
-
125
- def _style_parameterize(inp: dict[str, Any]) -> list[Patch]:
126
- return _shared_patches(inp, "style") + [
127
- (NODE_IMAGE_1, "image", inp["image"]),
128
- (NODE_VIDEO, "video", inp["input_video"]),
129
- (NODE_VIDEO, "skip_first_frames", 0),
130
- ]
131
-
132
-
133
- _T2V_STAGES = [
134
- Stage("Encode prompt", 5),
135
- Stage("Diffusion (Stage 1)", 60),
136
- Stage("Spatial upscale", 7),
137
- Stage("Diffusion (Stage 2)", 18),
138
- Stage("Decode video", 10),
139
- ]
140
-
141
- _I2V_STAGES = [
142
- Stage("Encode prompt", 5),
143
- Stage("Encode image", 3),
144
- Stage("Diffusion (Stage 1)", 55),
145
- Stage("Spatial upscale", 7),
146
- Stage("Diffusion (Stage 2)", 20),
147
- Stage("Decode video", 10),
148
- ]
149
-
150
- _A2V_STAGES = [
151
- Stage("Encode prompt", 5),
152
- Stage("Encode audio", 5),
153
- Stage("Diffusion (Stage 1)", 55),
154
- Stage("Spatial upscale", 7),
155
- Stage("Diffusion (Stage 2)", 18),
156
- Stage("Decode video", 10),
157
- ]
158
-
159
- _LIPSYNC_STAGES = list(_A2V_STAGES)
160
- _KEYFRAME_STAGES = [
161
- Stage("Encode prompt", 5),
162
- Stage("Encode keyframes", 5),
163
- Stage("Diffusion (Stage 1)", 55),
164
- Stage("Spatial upscale", 7),
165
- Stage("Diffusion (Stage 2)", 18),
166
- Stage("Decode video", 10),
167
- ]
168
- _STYLE_STAGES = [
169
- Stage("Encode prompt", 5),
170
- Stage("Encode source video", 10),
171
- Stage("Diffusion", 70),
172
- Stage("Decode video", 15),
173
- ]
174
-
175
-
176
- MODE_REGISTRY["t2v"] = Mode(
177
- name="t2v",
178
- label="Text → Video",
179
- icon="📝",
180
- parameterize_fn=_t2v_parameterize,
181
- stage_map=_T2V_STAGES,
182
- )
183
- MODE_REGISTRY["i2v"] = Mode(
184
- name="i2v",
185
- label="Image → Video",
186
- icon="🖼",
187
- parameterize_fn=_i2v_parameterize,
188
- stage_map=_I2V_STAGES,
189
- )
190
- MODE_REGISTRY["a2v"] = Mode(
191
- name="a2v",
192
- label="Audio → Video",
193
- icon="🎵",
194
- parameterize_fn=_a2v_parameterize,
195
- stage_map=_A2V_STAGES,
196
- )
197
- MODE_REGISTRY["lipsync"] = Mode(
198
- name="lipsync",
199
- label="Lipsync",
200
- icon="👄",
201
- parameterize_fn=_lipsync_parameterize,
202
- stage_map=_LIPSYNC_STAGES,
203
- )
204
- MODE_REGISTRY["keyframe"] = Mode(
205
- name="keyframe",
206
- label="Keyframe → Video",
207
- icon="🎞",
208
- parameterize_fn=_keyframe_parameterize,
209
- stage_map=_KEYFRAME_STAGES,
210
- )
211
- MODE_REGISTRY["style"] = Mode(
212
- name="style",
213
- label="Style Transfer",
214
- icon="🎨",
215
- parameterize_fn=_style_parameterize,
216
- stage_map=_STYLE_STAGES,
217
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyproject.toml DELETED
@@ -1,17 +0,0 @@
1
- [tool.pytest.ini_options]
2
- pythonpath = ["."]
3
- markers = [
4
- "gpu: marks tests that need a GPU (use --gpu to enable)",
5
- ]
6
-
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"]
14
- ignore = ["E501"] # line length is enforced by formatter, not linter
15
-
16
- [tool.ruff.lint.per-file-ignores]
17
- "tests/*" = ["E402"] # imports inside test functions are fine
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,60 +1,43 @@
1
- gradio>=5.0,<6.0
2
- spaces>=0.30.0
3
- huggingface_hub>=0.27.0
4
- psutil # used by app.py model status badge
5
-
6
- torch>=2.4.0
7
  torchvision
8
- # HF Spaces ships torch==2.10.0; bare 'torchaudio' lets pip pick an older
9
- # wheel whose C++ ABI doesn't match → import crashes the whole audio code
10
- # path including ComfyUI-LTXVideo's audio VAE. Pin the matching version.
11
- torchaudio==2.10.0
12
- numpy
13
- Pillow
14
  einops
15
- safetensors
16
- tqdm
17
-
18
- # ComfyUI core requirements (also pip-installed by setup.sh from comfyui/requirements.txt
19
- # locally, but Spaces won't run setup.sh — these are the deps ComfyUI itself needs)
20
- transformers>=4.50,<6
21
- tokenizers
22
  sentencepiece
23
- av
24
- kornia
25
- spandrel
26
- torchsde
 
27
  scipy
28
- aiohttp
29
- pydantic
30
- pydantic-settings
31
- python-dotenv
32
- yarl
33
- PyOpenGL
34
- glfw
35
- SQLAlchemy
36
  alembic
37
- Mako
38
- networkx
39
- sympy
40
- mpmath
 
 
 
41
  blake3
42
- comfyui-frontend-package>=1.42
43
- comfyui-workflow-templates>=0.9
44
- comfyui-embedded-docs
45
 
46
- # Custom-node deps that we discovered are required for the LTX 2.3 workflow
47
- # but aren't auto-installed by their respective nodes' requirements.txt.
48
- gguf # ComfyUI-GGUF (UnetLoaderGGUF)
49
- imageio_ffmpeg # ComfyUI-VideoHelperSuite (video write/read backend)
50
- opencv-python # ComfyUI_LayerStyle, multiple custom nodes
51
- matplotlib # comfyui_controlnet_aux dwpose / pose preprocessors
52
- scikit-image # comfyui_controlnet_aux dwpose hand keypoint detector
53
- onnxruntime # comfyui_controlnet_aux dwpose accelerator (CPU/CoreML on Mac, CUDA on Spaces)
54
- diffusers # ComfyUI-SeedVR2 (used during init even when the node isn't called)
55
- yt-dlp # ComfyUI-MediaMixer (init-time import)
56
 
57
- # Dev / test
58
- pytest>=8.0
59
- pytest-asyncio>=0.23
60
- ruff>=0.5
 
1
+ comfyui-frontend-package==1.49.6
2
+ comfyui-workflow-templates==0.11.43
3
+ comfyui-embedded-docs==0.5.10
4
+ torch
5
+ torchsde
 
6
  torchvision
7
+ torchaudio
8
+ numpy>=1.25.0
 
 
 
 
9
  einops
10
+ transformers>=4.50.3
11
+ tokenizers>=0.13.3
 
 
 
 
 
12
  sentencepiece
13
+ safetensors>=0.4.2
14
+ aiohttp>=3.11.8
15
+ yarl>=1.18.0
16
+ pyyaml
17
+ Pillow
18
  scipy
19
+ tqdm
20
+ psutil
 
 
 
 
 
 
21
  alembic
22
+ SQLAlchemy>=2.0.0
23
+ filelock
24
+ av>=16.0.0
25
+ comfy-kitchen==0.2.31
26
+ comfy-aimdo==0.4.13
27
+ requests
28
+ simpleeval>=1.0.0
29
  blake3
 
 
 
30
 
31
+ #non essential dependencies:
32
+ kornia>=0.7.1
33
+ spandrel
34
+ pydantic~=2.0
35
+ pydantic-settings~=2.0
36
+ PyOpenGL>=3.1.8
37
+ comfy-angle
38
+
 
 
39
 
40
+ huggingface-hub
41
+ imageio
42
+ spaces
43
+ sageattention @ https://huggingface.co/RioShiina/Sage-Attention-ZeroGPU-Space-Build/resolve/main/sageattention-2.2.0-cp312-cp312-linux_x86_64.whl
setup.sh DELETED
@@ -1,51 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
- cd "$REPO_ROOT"
6
-
7
- echo "▶ Creating Python 3.11 venv"
8
- python3.11 -m venv .venv
9
- # shellcheck disable=SC1091
10
- source .venv/bin/activate
11
- pip install -U pip wheel
12
-
13
- echo "▶ Initializing ComfyUI submodule"
14
- git submodule update --init --recursive
15
-
16
- echo "▶ Installing ComfyUI core requirements"
17
- pip install -r comfyui/requirements.txt
18
-
19
- echo "▶ Installing pinned custom nodes"
20
- mkdir -p comfyui/custom_nodes
21
- cd comfyui/custom_nodes
22
- for repo in \
23
- Lightricks/ComfyUI-LTXVideo \
24
- kijai/ComfyUI-KJNodes \
25
- rgthree/rgthree-comfy \
26
- Kosinkadink/ComfyUI-VideoHelperSuite \
27
- pythongosssss/ComfyUI-Custom-Scripts ; do
28
- name="${repo##*/}"
29
- if [[ ! -d "$name" ]]; then
30
- git clone --depth 1 "https://github.com/$repo.git" "$name"
31
- fi
32
- if [[ -f "$name/requirements.txt" ]]; then
33
- pip install -r "$name/requirements.txt"
34
- fi
35
- done
36
- cd "$REPO_ROOT"
37
-
38
- echo "▶ Installing AIO app dependencies"
39
- pip install -r requirements.txt
40
-
41
- echo "▶ Symlinking models from HF cache"
42
- if [[ -f tools/refresh_models.py ]]; then
43
- python tools/refresh_models.py
44
- else
45
- echo " (tools/refresh_models.py not yet present — skipping; will be added in Task 16)"
46
- fi
47
-
48
- echo
49
- echo "✓ Setup complete."
50
- echo " Activate venv: source .venv/bin/activate"
51
- echo " Run app: python app.py"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/conftest.py DELETED
@@ -1,158 +0,0 @@
1
- """Shared pytest fixtures and CLI flags."""
2
-
3
- import json
4
- import os
5
- import pathlib
6
- from typing import Any
7
-
8
- import pytest
9
-
10
- REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
11
-
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
- )
20
-
21
-
22
- def pytest_addoption(parser: pytest.Parser) -> None:
23
- parser.addoption("--gpu", action="store_true", help="Run L4 GPU smoke tests.")
24
- parser.addoption(
25
- "--comfy-real",
26
- action="store_true",
27
- help="Use bundled ComfyUI for L2 graph validation (slower).",
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:
35
- if "gpu" in item.keywords:
36
- item.add_marker(skip_gpu)
37
-
38
-
39
- @pytest.fixture(scope="session")
40
- def master_workflow() -> dict[str, Any]:
41
- """The full LTX 2.3 All-In-One workflow JSON (loaded from user's ComfyUI)."""
42
- if not DEFAULT_MASTER_WORKFLOW.exists():
43
- pytest.skip(
44
- f"Master workflow not found at {DEFAULT_MASTER_WORKFLOW}. "
45
- "Set LTX23_MASTER_WORKFLOW env var to its path."
46
- )
47
- return json.loads(DEFAULT_MASTER_WORKFLOW.read_text())
48
-
49
-
50
- @pytest.fixture
51
- def canonical_inputs() -> dict[str, dict[str, Any]]:
52
- """Known-good Gradio input dicts per mode (used by L1/L2 tests)."""
53
- return {
54
- "t2v": {
55
- "prompt": "a tiger walking through a misty forest at dawn, cinematic",
56
- "negative_prompt": "",
57
- "preset": "balanced",
58
- "width": 512,
59
- "height": 768,
60
- "frames": 81,
61
- "fps": 24,
62
- "seed": 42,
63
- "camera_lora": "none",
64
- "camera_strength": 0.8,
65
- "detailer_on": False,
66
- "detailer_strength": 0.5,
67
- },
68
- "i2v": {
69
- "prompt": "the subject turns toward the camera and smiles",
70
- "image": "/tmp/portrait.png",
71
- "preset": "balanced",
72
- "width": 512,
73
- "height": 768,
74
- "frames": 81,
75
- "fps": 24,
76
- "seed": 42,
77
- "camera_lora": "none",
78
- "camera_strength": 0.8,
79
- "detailer_on": True,
80
- "detailer_strength": 0.5,
81
- "ic_lora": "union",
82
- "ic_strength": 0.5,
83
- "pose_on": False,
84
- },
85
- "a2v": {
86
- "prompt": "a dancer moves to the beat in a neon-lit studio",
87
- "audio": "/tmp/track.wav",
88
- "preset": "balanced",
89
- "width": 512,
90
- "height": 768,
91
- "frames": 81,
92
- "fps": 24,
93
- "seed": 42,
94
- "audio_cfg": 7.0,
95
- },
96
- "lipsync": {
97
- "prompt": "the person speaks the audio with natural mouth movement",
98
- "image": "/tmp/portrait.png",
99
- "audio": "/tmp/speech.wav",
100
- "preset": "balanced",
101
- "image_strength": 0.7,
102
- "frames": 81,
103
- "fps": 24,
104
- "seed": 42,
105
- },
106
- "keyframe": {
107
- "prompt": "smooth transition between the two frames",
108
- "first_frame": "/tmp/start.png",
109
- "last_frame": "/tmp/end.png",
110
- "preset": "balanced",
111
- "frames": 81,
112
- "fps": 24,
113
- "seed": 42,
114
- },
115
- "style": {
116
- "prompt": "in the style of a renaissance oil painting",
117
- "input_video": "/tmp/source.mp4",
118
- "preset": "balanced",
119
- "frames": 81,
120
- "fps": 24,
121
- "seed": 42,
122
- "ic_lora": "motion-track",
123
- "ic_strength": 0.5,
124
- },
125
- }
126
-
127
-
128
- @pytest.fixture
129
- def fake_hf_cache(tmp_path: pathlib.Path) -> pathlib.Path:
130
- """A fake ~/.cache/huggingface/hub layout with placeholder files."""
131
- hub = tmp_path / "huggingface" / "hub"
132
- layouts = {
133
- "models--Lightricks--LTX-2.3": [
134
- "ltx-2.3-22b-distilled.safetensors",
135
- "ltx-2.3-spatial-upscaler-x2-1.0.safetensors",
136
- "ltx-2.3-22b-distilled-lora-384.safetensors",
137
- ],
138
- "models--google--gemma-3-12b-it-qat-q4_0-unquantized": [
139
- "model-00001-of-00005.safetensors",
140
- "model-00002-of-00005.safetensors",
141
- "model-00003-of-00005.safetensors",
142
- "model-00004-of-00005.safetensors",
143
- "model-00005-of-00005.safetensors",
144
- "model.safetensors.index.json",
145
- "tokenizer.model",
146
- "preprocessor_config.json",
147
- ],
148
- "models--Kijai--LTX2.3_comfy": [
149
- "LTX23_video_vae_bf16.safetensors",
150
- "LTX23_audio_vae_bf16.safetensors",
151
- ],
152
- }
153
- for repo, files in layouts.items():
154
- snapshot_dir = hub / repo / "snapshots" / "deadbeef"
155
- snapshot_dir.mkdir(parents=True, exist_ok=True)
156
- for filename in files:
157
- (snapshot_dir / filename).write_text("") # placeholder
158
- return hub
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_backend.py DELETED
@@ -1,14 +0,0 @@
1
- """Backend tests — most are smoke / structural since the real work is GPU."""
2
-
3
- import backend
4
-
5
-
6
- def test_backend_class_exists():
7
- assert hasattr(backend, "ComfyUILibraryBackend")
8
-
9
-
10
- def test_progress_event_dataclasses_exist():
11
- assert hasattr(backend, "DownloadEvent")
12
- assert hasattr(backend, "ProgressEvent")
13
- assert hasattr(backend, "OutputEvent")
14
- assert hasattr(backend, "ErrorEvent")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_extract_modes.py DELETED
@@ -1,39 +0,0 @@
1
- """Tests for the workflow-mode extractor."""
2
-
3
- import json
4
- import subprocess
5
- import sys
6
-
7
- from tests.conftest import REPO_ROOT
8
-
9
-
10
- def test_extract_creates_six_mode_files(master_workflow, tmp_path):
11
- """extract_modes.py emits six valid mode-specific JSON templates."""
12
- out_dir = tmp_path / "workflows"
13
- master_path = tmp_path / "master.json"
14
- master_path.write_text(json.dumps(master_workflow))
15
-
16
- result = subprocess.run(
17
- [
18
- sys.executable,
19
- str(REPO_ROOT / "tools" / "extract_modes.py"),
20
- "--master",
21
- str(master_path),
22
- "--out",
23
- str(out_dir),
24
- ],
25
- check=False,
26
- capture_output=True,
27
- text=True,
28
- )
29
-
30
- assert result.returncode == 0, result.stderr
31
- expected = {"t2v.json", "a2v.json", "i2v.json", "lipsync.json", "keyframe.json", "style.json"}
32
- actual = {p.name for p in out_dir.iterdir()}
33
- assert actual == expected
34
-
35
- # Each file must be valid JSON with at least one node.
36
- for path in out_dir.iterdir():
37
- wf = json.loads(path.read_text())
38
- assert "nodes" in wf
39
- assert len(wf["nodes"]) > 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_models.py DELETED
@@ -1,73 +0,0 @@
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
-
14
- def test_model_registry_includes_gemma_shards():
15
- for i in range(1, 6):
16
- key = f"model-{i:05d}-of-00005.safetensors"
17
- assert key in models.MODEL_REGISTRY
18
- assert "gemma-3-12b-it" in models.MODEL_REGISTRY[key].repo_id
19
-
20
-
21
- def test_walk_workflow_for_models_finds_t2v_loaders():
22
- wf = workflow.load_template("t2v")
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)
30
-
31
-
32
- def test_ensure_models_creates_symlinks_local(tmp_path, monkeypatch, fake_hf_cache):
33
- """In local mode, ensure_models creates symlinks from comfy/models -> HF cache."""
34
- monkeypatch.setenv("HF_HUB_CACHE", str(fake_hf_cache))
35
- monkeypatch.setattr(models, "_on_spaces", lambda: False)
36
-
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"
44
- monkeypatch.setattr(models, "_comfy_models_dir", lambda: comfy_models)
45
-
46
- needed = {
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)