Commit ·
2348409
1
Parent(s): 4f80c8f
LTX 2.5
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +0 -46
- .gitmodules +0 -3
- CLAUDE.md +0 -263
- LICENSE +674 -21
- README.md +6 -74
- SKILLS.md +0 -299
- app.py +76 -1217
- assets/seed_inputs/5.FLF2.png +0 -0
- assets/seed_inputs/IMG-20210721-WA0008.jpg +0 -0
- assets/seed_inputs/Sway Dance Lesson 31s.mp4 +0 -0
- assets/seed_inputs/beauty_pagent_dialogue.mp3 +0 -0
- assets/seed_inputs/influencer_mic_hd.png +0 -0
- backend.py +0 -603
- chain_injectors/__init__.py +50 -0
- chain_injectors/lora_injector.py +76 -0
- {tests → comfy_integration}/__init__.py +0 -0
- comfy_integration/nodes.py +44 -0
- comfy_integration/setup.py +74 -0
- {tools → core}/__init__.py +0 -0
- core/generation_logic.py +149 -0
- core/model_manager.py +32 -0
- core/pipelines/__init__.py +0 -0
- core/pipelines/base_pipeline.py +55 -0
- core/pipelines/pipeline_input_processor.py +36 -0
- core/pipelines/workflow_executor.py +131 -0
- core/settings.py +135 -0
- core/shared_state.py +1 -0
- core/workflow_assembler.py +203 -0
- docs/future_improvements.md +0 -148
- docs/superpowers/plans/2026-04-30-ltx23-aio-generator.md +0 -2932
- docs/superpowers/plans/2026-05-01-topaz-drawer-redesign.md +0 -535
- docs/superpowers/specs/2026-04-30-ltx23-aio-generator-design.md +0 -483
- docs/superpowers/specs/2026-05-01-topaz-drawer-redesign-design.md +0 -104
- mcp_tools/__init__.py +41 -0
- mcp_tools/common.py +465 -0
- mcp_tools/error_schema.py +85 -0
- mcp_tools/get_task_list.py +11 -0
- mcp_tools/get_task_status.py +21 -0
- mcp_tools/mcp_gradio_integration.py +120 -0
- mcp_tools/run.py +113 -0
- mcp_tools/tool_handlers.py +19 -0
- models.py +0 -338
- modes.py +0 -217
- pyproject.toml +0 -17
- requirements.txt +35 -52
- setup.sh +0 -51
- tests/conftest.py +0 -158
- tests/test_backend.py +0 -14
- tests/test_extract_modes.py +0 -39
- 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
of this
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 3 |
emoji: 🎬
|
| 4 |
colorFrom: purple
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
-
python_version:
|
| 10 |
-
|
| 11 |
-
hf_oauth: false
|
| 12 |
models:
|
| 13 |
-
-
|
| 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 |
-
|
| 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
|
| 16 |
-
import
|
| 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 |
-
|
| 222 |
-
|
| 223 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 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 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 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 |
-
|
| 747 |
-
|
| 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 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
_BACKEND: backend_module.ComfyUILibraryBackend | None = None
|
| 770 |
-
|
| 771 |
|
| 772 |
-
|
| 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 |
-
|
| 807 |
-
|
| 808 |
-
except
|
| 809 |
-
|
| 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 |
-
|
| 1102 |
try:
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
if not os.path.exists(input_file):
|
| 1108 |
-
#print(f"Error: Input file {input_file} does not exist")
|
| 1109 |
-
return False
|
| 1110 |
|
| 1111 |
-
|
| 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 |
-
|
| 1126 |
-
|
|
|
|
|
|
|
|
|
|
| 1127 |
|
| 1128 |
-
|
| 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 |
-
|
| 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 |
-
|
| 1148 |
-
|
| 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 |
-
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
torch>=2.4.0
|
| 7 |
torchvision
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
# path including ComfyUI-LTXVideo's audio VAE. Pin the matching version.
|
| 11 |
-
torchaudio==2.10.0
|
| 12 |
-
numpy
|
| 13 |
-
Pillow
|
| 14 |
einops
|
| 15 |
-
|
| 16 |
-
|
| 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 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
| 27 |
scipy
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
pydantic-settings
|
| 31 |
-
python-dotenv
|
| 32 |
-
yarl
|
| 33 |
-
PyOpenGL
|
| 34 |
-
glfw
|
| 35 |
-
SQLAlchemy
|
| 36 |
alembic
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
| 41 |
blake3
|
| 42 |
-
comfyui-frontend-package>=1.42
|
| 43 |
-
comfyui-workflow-templates>=0.9
|
| 44 |
-
comfyui-embedded-docs
|
| 45 |
|
| 46 |
-
#
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
diffusers # ComfyUI-SeedVR2 (used during init even when the node isn't called)
|
| 55 |
-
yt-dlp # ComfyUI-MediaMixer (init-time import)
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|