Upload 11 files
Browse files- README.md +171 -1
- __init__.py +121 -0
- joyecho_prompt_source.py +151 -0
- joyecho_ref_batch.py +57 -0
- joyecho_ref_picker.py +186 -0
- joyecho_script_picker.py +89 -0
- libs/ltx_distillation/models/ltx_wrapper.py +835 -0
- libs/ltx_distillation/utils.py +261 -0
- nodes.py +1757 -0
- prompts/long_story_writer_system_prompt.md +63 -0
- rebels_loaders.py +840 -0
README.md
CHANGED
|
@@ -1,3 +1,173 @@
|
|
| 1 |
---
|
| 2 |
-
license:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
license: other
|
| 3 |
+
license_name: ltx-2-community-license
|
| 4 |
+
license_link: https://huggingface.co/Lightricks/LTX-2/blob/main/LICENSE.txt
|
| 5 |
+
tags:
|
| 6 |
+
- comfyui
|
| 7 |
+
- joyai-echo
|
| 8 |
+
- ltx-video
|
| 9 |
+
- multishot
|
| 10 |
+
- patch
|
| 11 |
---
|
| 12 |
+
|
| 13 |
+
# JoyAI-Echo GGUF nodes — multishot fixes + automation patch
|
| 14 |
+
|
| 15 |
+
A set of bug fixes and features layered on top of the community
|
| 16 |
+
`ComfyUI_JoyAI_Echo_GGUF_Nodes` pack (the Rebels GGUF loader stack around
|
| 17 |
+
JoyAI-Echo). Everything here targets the **multi-shot** path (`JoyEcho_Generate`
|
| 18 |
+
+ the discrete Rebels loaders / `JoyEcho_ModelLoader`).
|
| 19 |
+
|
| 20 |
+
This is a **patch drop**, not a standalone pack: copy these files over a working
|
| 21 |
+
install of the same pack (back up first). The files are interdependent — in
|
| 22 |
+
particular `nodes.py` calls new signatures added to the two `libs/` files, so
|
| 23 |
+
apply them together.
|
| 24 |
+
|
| 25 |
+
Tested on an RTX 5090 (32 GB) and a 3090 (24 GB), ComfyUI 0.26–0.27,
|
| 26 |
+
torch 2.8–2.11, with the JoyAI-Echo bf16 release and self-built Q8 GGUFs.
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## Files in this package
|
| 31 |
+
|
| 32 |
+
```
|
| 33 |
+
nodes.py # JoyEcho_TextEncode / _Generate / _ModelLoader / _LLMEnhance
|
| 34 |
+
__init__.py # registrations for the new nodes
|
| 35 |
+
rebels_loaders.py # discrete GGUF loaders (text-encoder fixes)
|
| 36 |
+
joyecho_prompt_source.py (new node) # one dropdown: .txt briefs + .json scripts
|
| 37 |
+
joyecho_ref_picker.py (new node) # auto reference-image picker by character name
|
| 38 |
+
joyecho_ref_batch.py (new node) # None-tolerant image batcher
|
| 39 |
+
joyecho_script_picker.py (new node) # JSON dropdown (superseded by PromptSource)
|
| 40 |
+
libs/ltx_distillation/models/ltx_wrapper.py # fp8 quantization passthrough
|
| 41 |
+
libs/ltx_distillation/utils.py # tiled VAE decode
|
| 42 |
+
prompts/long_story_writer_system_prompt.md # (optional) de-musicked + character-age edits
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## Bug fixes
|
| 48 |
+
|
| 49 |
+
### 1. `enable_audio_memory=False` silently disabled ALL cross-shot memory
|
| 50 |
+
The pack computed `audio_memory_latent=None` when audio memory was off, and the
|
| 51 |
+
video **memory-bank save was gated on that latent being non-None** — so with
|
| 52 |
+
audio memory off (the standard anti-drone setting) the bank never filled and
|
| 53 |
+
cross-shot **identity** silently died (symptom: `memory_size=0` every shot even
|
| 54 |
+
with `memory_max_size=7`; a new face each shot).
|
| 55 |
+
Fix: memory storage is now unconditional; `enable_audio_memory` gates only the
|
| 56 |
+
audio-memory **injection** path. Verify: console `memory_size=` should climb
|
| 57 |
+
0,1,2,… capped at your `memory_max_size`. (`nodes.py`)
|
| 58 |
+
|
| 59 |
+
### 2. GGUF text-encoder loader (`RebelsJE_TextEncoder`)
|
| 60 |
+
Two fixes so a text-only Gemma-3 GGUF loads cleanly:
|
| 61 |
+
- **meta-strip**: drop `vision_tower` / `multi_modal_projector` / `lm_head`
|
| 62 |
+
(the text-only GGUF has no weights for them → "Cannot copy out of meta tensor").
|
| 63 |
+
- **device-unify**: pin the embeddings-processor to the encoder's actual device
|
| 64 |
+
(GGUF Gemma runs on CPU while the connector was on cuda → addmm device mismatch).
|
| 65 |
+
(`rebels_loaders.py`)
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## Features
|
| 70 |
+
|
| 71 |
+
### 3. Split per-domain negative lever (`JoyEcho_TextEncode`)
|
| 72 |
+
The DMD pipeline has no CFG, so the only steering lever is embedding-space.
|
| 73 |
+
Instead of one `negative_prompt`/`negative_scale` that steers both branches,
|
| 74 |
+
this splits it:
|
| 75 |
+
- `negative_prompt_video` / `negative_scale_video` — kills burned-in
|
| 76 |
+
captions/subtitles. Working value ~0.5. **Above ~0.8 it over-rotates the
|
| 77 |
+
video context and locks every shot to shot 1's composition** (scene-lock).
|
| 78 |
+
- `negative_prompt_audio` / `negative_scale_audio` — kills invented
|
| 79 |
+
music/score. Keep ≤ ~0.4 or dialogue suffers.
|
| 80 |
+
Steering is norm-preserving (RescaleCFG-style): `cond' = renorm(cond + s*(cond − neg))`.
|
| 81 |
+
Old single-widget names still work as a fallback. (`nodes.py`)
|
| 82 |
+
|
| 83 |
+
### 4. Passthrough mode (`JoyEcho_LLMEnhance`)
|
| 84 |
+
`mode = "passthrough (raw JSON, skip LLM)"` — feed a finished
|
| 85 |
+
`{"prompts":[...]}` script straight through with no LLM call / no API key.
|
| 86 |
+
Auto-detects when `story_idea` already parses as that JSON. (`nodes.py`)
|
| 87 |
+
|
| 88 |
+
### 5. Reference-image conditioning — I2V-as-reference (`JoyEcho_Generate`)
|
| 89 |
+
New `reference_image` (IMAGE batch, up to 4). Identity references are prepended
|
| 90 |
+
as **video-only conditioning clips** at the memory-encode step — they are
|
| 91 |
+
**never** written into the paired audio/video bank. (An earlier attempt that
|
| 92 |
+
seeded refs into the bank with zero-filled audio latents injected loud
|
| 93 |
+
background noise with 2+ refs; video-only conditioning avoids it entirely.)
|
| 94 |
+
Also new: `head_trim_frames` (auto 8 with refs) drops the first N frames of each
|
| 95 |
+
shot, where the model morphs out of the reference/memory content. (`nodes.py`)
|
| 96 |
+
|
| 97 |
+
### 6. Shot transitions (`JoyEcho_Generate`)
|
| 98 |
+
`transition`: `cut` (original) / `dissolve` (overlap cross-dissolve + equal-power
|
| 99 |
+
audio crossfade) / `vhs_glitch` (analog static burst at each boundary: snow,
|
| 100 |
+
tear bands, dropout lines + a raised-cosine tape-noise audio bed).
|
| 101 |
+
`transition_frames`, `glitch_intensity` tune it. (`nodes.py`)
|
| 102 |
+
|
| 103 |
+
### 7. fp8 transformer quantization (`JoyEcho_ModelLoader`)
|
| 104 |
+
New `fp8_transformer` toggle. Quantizes the DiT's attention/FF linear weights to
|
| 105 |
+
`float8_e4m3fn` **at load, from the normal bf16 checkpoint** (uses the vendored
|
| 106 |
+
`ltx_core.quantization.QuantizationPolicy.fp8_cast()` — upcasts per-layer at
|
| 107 |
+
inference). Roughly halves DiT weight memory and halves sequential-offload PCIe
|
| 108 |
+
traffic; keeps memory training + all tensors; VAEs/text-encoder/non-linears stay
|
| 109 |
+
bf16. Ignored when a GGUF DiT is selected (already quantized).
|
| 110 |
+
(`nodes.py` + `libs/ltx_distillation/models/ltx_wrapper.py` — new `quantization`
|
| 111 |
+
param; the quantized build path skips the post-load dtype cast that would
|
| 112 |
+
otherwise silently upcast fp8 back to bf16.)
|
| 113 |
+
|
| 114 |
+
### 8. Tiled VAE decode (`JoyEcho_Generate`)
|
| 115 |
+
Decoding a long high-res shot (e.g. 241f @ 1280×736) in one pass hard-aborts the
|
| 116 |
+
VAE decode on a 24–32 GB card (fatal cuDNN abort mid-conv, not a catchable OOM).
|
| 117 |
+
New `decode_tiling` (`auto`/`on`/`off`) routes decode through the vendored
|
| 118 |
+
`VideoDecoder.tiled_decode` — **temporal-only** 64-frame chunks with 24-frame
|
| 119 |
+
blended overlap (no spatial tiles → no spatial seams), streaming each chunk to
|
| 120 |
+
CPU. `auto` engages only above a size threshold, so small renders keep the
|
| 121 |
+
original single-pass decode bit-for-bit.
|
| 122 |
+
(`nodes.py` + `libs/ltx_distillation/utils.py` — `decode_benchmark_sample` gains
|
| 123 |
+
a `video_tiling_config` kwarg + `_decode_video_tiled_uint8`.)
|
| 124 |
+
|
| 125 |
+
### 9. Model dropdown (`JoyEcho_ModelLoader`)
|
| 126 |
+
New `model_file` combo lists every `.safetensors` / `.gguf` under the ComfyUI
|
| 127 |
+
`checkpoints` / `diffusion_models` / `unet` dirs. Pick a `.safetensors` → full
|
| 128 |
+
checkpoint (replaces `checkpoint_path`); pick a `.gguf` → DiT loaded from GGUF
|
| 129 |
+
while `checkpoint_path` still supplies the VAEs / vocoder / text connectors.
|
| 130 |
+
`"(use checkpoint_path)"` keeps the old typed-path behavior. Plus a clear
|
| 131 |
+
early error if `gemma_path` is a `.gguf`/file/sidecar-less dir (this loader
|
| 132 |
+
needs the HF `gemma-3-12b-it` folder; GGUF Gemma only works via
|
| 133 |
+
`RebelsJE_TextEncoder`). (`nodes.py`)
|
| 134 |
+
|
| 135 |
+
### 10. Automation / batching nodes (new)
|
| 136 |
+
- **`JoyEcho_PromptSource`** — one dropdown listing LPFF-style `.txt` briefs
|
| 137 |
+
(from the inspire-pack prompts tree) **and** passthrough `.json` scripts
|
| 138 |
+
(`input/joyecho_prompts/`). Multi-block briefs fan out like
|
| 139 |
+
LoadPromptsFromFile. Emits `story_idea` (→ LLMEnhance) + `character`
|
| 140 |
+
(→ RefPicker) + `count`. Replaces the LPFF→UnzipPrompt chain and lets you
|
| 141 |
+
switch prompt sources with one dropdown instead of rewiring.
|
| 142 |
+
- **`JoyEcho_RefPicker`** — auto-selects a character reference image from a
|
| 143 |
+
folder tree keyed by character name (typed, or wired from PromptSource, or
|
| 144 |
+
scanned from the prompt — dialogue mentions are stripped so only the on-screen
|
| 145 |
+
subject wins). `on_no_match=no_reference` returns nothing so a batch keeps
|
| 146 |
+
running.
|
| 147 |
+
- **`JoyEcho_RefBatch`** — None-tolerant image batcher: combines up to 4
|
| 148 |
+
optional IMAGE inputs (e.g. two RefPickers for a two-character shot), skips
|
| 149 |
+
missing refs, resizes mismatched sizes to the first image, outputs `None` if
|
| 150 |
+
all are missing (Generate then just skips identity seeding). The stock KJNodes
|
| 151 |
+
`ImageBatchMulti` crashes with `'NoneType' has no attribute 'shape'` on a
|
| 152 |
+
missing ref; this replaces it.
|
| 153 |
+
- **`JoyEcho_ScriptPicker`** — JSON dropdown (superseded by PromptSource; kept
|
| 154 |
+
for compatibility).
|
| 155 |
+
|
| 156 |
+
---
|
| 157 |
+
|
| 158 |
+
## Applying
|
| 159 |
+
|
| 160 |
+
1. Back up your existing pack folder.
|
| 161 |
+
2. Copy each file over the same relative path in
|
| 162 |
+
`ComfyUI/custom_nodes/ComfyUI_JoyAI_Echo_GGUF_Nodes/`.
|
| 163 |
+
3. Restart ComfyUI. New widgets append at the **end** of existing nodes, so
|
| 164 |
+
saved graphs keep their values; the four new nodes appear under the
|
| 165 |
+
`JoyAI-Echo` category. Press `R` after adding model files to refresh the
|
| 166 |
+
`model_file` dropdown.
|
| 167 |
+
|
| 168 |
+
The `libs/` files must match the vendored `ltx_core` / `ltx_distillation` in
|
| 169 |
+
your pack (same JoyAI-Echo release). If your `libs/` differ substantially,
|
| 170 |
+
cherry-pick the changes described above rather than overwriting.
|
| 171 |
+
|
| 172 |
+
Not included (intentionally): model weights, the `gemma_assets/` tokenizer
|
| 173 |
+
binaries, `.bak` snapshots, and `__pycache__`.
|
__init__.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ComfyUI nodes for JoyAI-Echo: minute-level multi-shot audio-video generation.
|
| 2 |
+
|
| 3 |
+
Registers:
|
| 4 |
+
- the 7 upstream JoyEcho nodes (nodes.py)
|
| 5 |
+
- the discrete Rebels loader nodes (rebels_loaders.py)
|
| 6 |
+
- the Rebels staged single-node pipeline for 16GB RAM (rebels_staged.py)
|
| 7 |
+
|
| 8 |
+
The libs/ folder MUST be on sys.path before anything imports ltx_core /
|
| 9 |
+
ltx_distillation, so that setup happens first.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
_NODE_ROOT = Path(__file__).resolve().parent
|
| 16 |
+
_LIBS = str(_NODE_ROOT / "libs")
|
| 17 |
+
|
| 18 |
+
if _LIBS not in sys.path:
|
| 19 |
+
sys.path.insert(0, _LIBS)
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------- base maps
|
| 22 |
+
NODE_CLASS_MAPPINGS = {}
|
| 23 |
+
NODE_DISPLAY_NAME_MAPPINGS = {}
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------- upstream JoyEcho nodes
|
| 26 |
+
from .nodes import (
|
| 27 |
+
JoyEcho_ModelLoader,
|
| 28 |
+
JoyEcho_TextEncode,
|
| 29 |
+
JoyEcho_Generate,
|
| 30 |
+
JoyEcho_SingleShotGenerate,
|
| 31 |
+
JoyEcho_PromptFormat,
|
| 32 |
+
JoyEcho_LLMEnhance,
|
| 33 |
+
JoyEcho_PromptAtIndex,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
NODE_CLASS_MAPPINGS.update({
|
| 37 |
+
"JoyEcho_ModelLoader": JoyEcho_ModelLoader,
|
| 38 |
+
"JoyEcho_TextEncode": JoyEcho_TextEncode,
|
| 39 |
+
"JoyEcho_Generate": JoyEcho_Generate,
|
| 40 |
+
"JoyEcho_SingleShotGenerate": JoyEcho_SingleShotGenerate,
|
| 41 |
+
"JoyEcho_PromptFormat": JoyEcho_PromptFormat,
|
| 42 |
+
"JoyEcho_LLMEnhance": JoyEcho_LLMEnhance,
|
| 43 |
+
"JoyEcho_PromptAtIndex": JoyEcho_PromptAtIndex,
|
| 44 |
+
})
|
| 45 |
+
NODE_DISPLAY_NAME_MAPPINGS.update({
|
| 46 |
+
"JoyEcho_ModelLoader": "JoyEcho Model Loader",
|
| 47 |
+
"JoyEcho_TextEncode": "JoyEcho Text Encode",
|
| 48 |
+
"JoyEcho_Generate": "JoyEcho Generate (Multi-Shot)",
|
| 49 |
+
"JoyEcho_SingleShotGenerate": "JoyEcho Single Shot Generate",
|
| 50 |
+
"JoyEcho_PromptFormat": "JoyEcho Prompt Format (Helper)",
|
| 51 |
+
"JoyEcho_LLMEnhance": "JoyEcho LLM Enhance",
|
| 52 |
+
"JoyEcho_PromptAtIndex": "JoyEcho Prompt At Index",
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------- Rebels discrete loaders
|
| 56 |
+
try:
|
| 57 |
+
from .rebels_loaders import (
|
| 58 |
+
NODE_CLASS_MAPPINGS as _RL_CM,
|
| 59 |
+
NODE_DISPLAY_NAME_MAPPINGS as _RL_DM,
|
| 60 |
+
)
|
| 61 |
+
NODE_CLASS_MAPPINGS.update(_RL_CM)
|
| 62 |
+
NODE_DISPLAY_NAME_MAPPINGS.update(_RL_DM)
|
| 63 |
+
except Exception as e:
|
| 64 |
+
print(f"[Rebels JE] rebels_loaders failed to load: {e!r}", flush=True)
|
| 65 |
+
|
| 66 |
+
# ---------------------------------------------------------------- Rebels staged pipeline (16GB)
|
| 67 |
+
try:
|
| 68 |
+
from .rebels_staged import (
|
| 69 |
+
NODE_CLASS_MAPPINGS as _ST_CM,
|
| 70 |
+
NODE_DISPLAY_NAME_MAPPINGS as _ST_DM,
|
| 71 |
+
)
|
| 72 |
+
NODE_CLASS_MAPPINGS.update(_ST_CM)
|
| 73 |
+
NODE_DISPLAY_NAME_MAPPINGS.update(_ST_DM)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"[Rebels JE] rebels_staged failed to load: {e!r}", flush=True)
|
| 76 |
+
|
| 77 |
+
# ---------------------------------------------------------------- Script picker (JSON dropdown)
|
| 78 |
+
try:
|
| 79 |
+
from .joyecho_script_picker import (
|
| 80 |
+
NODE_CLASS_MAPPINGS as _SP_CM,
|
| 81 |
+
NODE_DISPLAY_NAME_MAPPINGS as _SP_DM,
|
| 82 |
+
)
|
| 83 |
+
NODE_CLASS_MAPPINGS.update(_SP_CM)
|
| 84 |
+
NODE_DISPLAY_NAME_MAPPINGS.update(_SP_DM)
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"[Rebels JE] joyecho_script_picker failed to load: {e!r}", flush=True)
|
| 87 |
+
|
| 88 |
+
# ---------------------------------------------------------------- Prompt source (unified txt+json dropdown)
|
| 89 |
+
try:
|
| 90 |
+
from .joyecho_prompt_source import (
|
| 91 |
+
NODE_CLASS_MAPPINGS as _PS_CM,
|
| 92 |
+
NODE_DISPLAY_NAME_MAPPINGS as _PS_DM,
|
| 93 |
+
)
|
| 94 |
+
NODE_CLASS_MAPPINGS.update(_PS_CM)
|
| 95 |
+
NODE_DISPLAY_NAME_MAPPINGS.update(_PS_DM)
|
| 96 |
+
except Exception as e:
|
| 97 |
+
print(f"[Rebels JE] joyecho_prompt_source failed to load: {e!r}", flush=True)
|
| 98 |
+
|
| 99 |
+
# ---------------------------------------------------------------- Reference batch (None-tolerant)
|
| 100 |
+
try:
|
| 101 |
+
from .joyecho_ref_batch import (
|
| 102 |
+
NODE_CLASS_MAPPINGS as _RB_CM,
|
| 103 |
+
NODE_DISPLAY_NAME_MAPPINGS as _RB_DM,
|
| 104 |
+
)
|
| 105 |
+
NODE_CLASS_MAPPINGS.update(_RB_CM)
|
| 106 |
+
NODE_DISPLAY_NAME_MAPPINGS.update(_RB_DM)
|
| 107 |
+
except Exception as e:
|
| 108 |
+
print(f"[Rebels JE] joyecho_ref_batch failed to load: {e!r}", flush=True)
|
| 109 |
+
|
| 110 |
+
# ---------------------------------------------------------------- Reference picker (auto by character)
|
| 111 |
+
try:
|
| 112 |
+
from .joyecho_ref_picker import (
|
| 113 |
+
NODE_CLASS_MAPPINGS as _RP_CM,
|
| 114 |
+
NODE_DISPLAY_NAME_MAPPINGS as _RP_DM,
|
| 115 |
+
)
|
| 116 |
+
NODE_CLASS_MAPPINGS.update(_RP_CM)
|
| 117 |
+
NODE_DISPLAY_NAME_MAPPINGS.update(_RP_DM)
|
| 118 |
+
except Exception as e:
|
| 119 |
+
print(f"[Rebels JE] joyecho_ref_picker failed to load: {e!r}", flush=True)
|
| 120 |
+
|
| 121 |
+
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
joyecho_prompt_source.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JoyEcho Prompt Source - ONE dropdown for both prompt pipelines.
|
| 2 |
+
|
| 3 |
+
Lists LPFF-style brief/JSON-block .txt files (from the inspire-pack prompts
|
| 4 |
+
tree) AND passthrough .json scripts (from <ComfyUI>/input/joyecho_prompts/) in
|
| 5 |
+
a single combo, and always emits the same outputs:
|
| 6 |
+
|
| 7 |
+
story_idea (STRING, list) -> JoyEcho_LLMEnhance.story_idea
|
| 8 |
+
character (STRING, list) -> JoyEcho_RefPicker.character
|
| 9 |
+
count (INT)
|
| 10 |
+
|
| 11 |
+
.txt files are parsed LPFF-style (blocks split on ---, positive:/negative:/
|
| 12 |
+
name: fields); each block becomes one queue item, so multi-block files fan out
|
| 13 |
+
exactly like LoadPromptsFromFile. .json files load as a single passthrough item
|
| 14 |
+
(the raw JSON text). Blocks without a name: line emit "" (RefPicker falls
|
| 15 |
+
through to prompt-scan / fallback), never the filename.
|
| 16 |
+
|
| 17 |
+
Replaces the LPFF -> UnzipPrompt chain and the Script Picker: wire once,
|
| 18 |
+
switch sources by picking a different file.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import re
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import folder_paths
|
| 26 |
+
|
| 27 |
+
_JSON_SUBDIR = "joyecho_prompts"
|
| 28 |
+
_TXT_PREFIX = "TXT: "
|
| 29 |
+
_JSON_PREFIX = "JSON: "
|
| 30 |
+
_EMPTY = "(no prompt files found)"
|
| 31 |
+
|
| 32 |
+
_BLOCK_SPLIT = re.compile(r"\n\s*-+\s*\n")
|
| 33 |
+
_BLOCK_PATTERN = re.compile(
|
| 34 |
+
r"^(?:(?:name:(?P<name>.*?)|positive:(?P<positive>.*?)|negative:(?P<negative>.*?))\n*)+$",
|
| 35 |
+
re.DOTALL,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _txt_root() -> Path | None:
|
| 40 |
+
try:
|
| 41 |
+
roots = folder_paths.get_folder_paths("inspire_prompts")
|
| 42 |
+
return Path(roots[0]) if roots else None
|
| 43 |
+
except Exception:
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _json_root() -> Path:
|
| 48 |
+
d = Path(folder_paths.get_input_directory()) / _JSON_SUBDIR
|
| 49 |
+
try:
|
| 50 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
except OSError:
|
| 52 |
+
pass
|
| 53 |
+
return d
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _list_files() -> list[str]:
|
| 57 |
+
out = []
|
| 58 |
+
root = _txt_root()
|
| 59 |
+
if root and root.is_dir():
|
| 60 |
+
for p in sorted(root.rglob("*.txt")):
|
| 61 |
+
out.append(_TXT_PREFIX + str(p.relative_to(root)))
|
| 62 |
+
jroot = _json_root()
|
| 63 |
+
for p in sorted(jroot.glob("*.json")):
|
| 64 |
+
out.append(_JSON_PREFIX + p.name)
|
| 65 |
+
return out or [_EMPTY]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _resolve(choice: str) -> Path | None:
|
| 69 |
+
if choice.startswith(_TXT_PREFIX):
|
| 70 |
+
root = _txt_root()
|
| 71 |
+
return (root / choice[len(_TXT_PREFIX):]) if root else None
|
| 72 |
+
if choice.startswith(_JSON_PREFIX):
|
| 73 |
+
return _json_root() / choice[len(_JSON_PREFIX):]
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class JoyEcho_PromptSource:
|
| 78 |
+
@classmethod
|
| 79 |
+
def INPUT_TYPES(cls):
|
| 80 |
+
return {
|
| 81 |
+
"required": {
|
| 82 |
+
"source_file": (_list_files(),),
|
| 83 |
+
},
|
| 84 |
+
"optional": {
|
| 85 |
+
"load_cap": ("INT", {"default": 0, "min": 0,
|
| 86 |
+
"tooltip": "TXT files only: 0 = all blocks, N = first N from start_index."}),
|
| 87 |
+
"start_index": ("INT", {"default": 0, "min": 0,
|
| 88 |
+
"tooltip": "TXT files only: first block index to load."}),
|
| 89 |
+
"character_override": ("STRING", {"default": "",
|
| 90 |
+
"tooltip": "Force this character for EVERY emitted item "
|
| 91 |
+
"(useful for .json scripts, which carry no name field)."}),
|
| 92 |
+
},
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
RETURN_TYPES = ("STRING", "STRING", "INT")
|
| 96 |
+
RETURN_NAMES = ("story_idea", "character", "count")
|
| 97 |
+
OUTPUT_IS_LIST = (True, True, False)
|
| 98 |
+
FUNCTION = "load"
|
| 99 |
+
CATEGORY = "JoyAI-Echo"
|
| 100 |
+
|
| 101 |
+
@classmethod
|
| 102 |
+
def IS_CHANGED(cls, source_file, load_cap=0, start_index=0, character_override=""):
|
| 103 |
+
p = _resolve(source_file)
|
| 104 |
+
try:
|
| 105 |
+
return f"{source_file}:{p.stat().st_mtime}:{load_cap}:{start_index}:{character_override}"
|
| 106 |
+
except Exception:
|
| 107 |
+
return source_file
|
| 108 |
+
|
| 109 |
+
def load(self, source_file, load_cap=0, start_index=0, character_override=""):
|
| 110 |
+
if source_file == _EMPTY:
|
| 111 |
+
raise ValueError("PromptSource: no prompt files found in the inspire prompts tree "
|
| 112 |
+
"or input/joyecho_prompts/.")
|
| 113 |
+
p = _resolve(source_file)
|
| 114 |
+
if p is None or not p.exists():
|
| 115 |
+
raise FileNotFoundError(f"PromptSource: {source_file} -> {p} not found. "
|
| 116 |
+
"Refresh the node list (R) after adding files.")
|
| 117 |
+
text = p.read_text(encoding="utf-8")
|
| 118 |
+
override = character_override.strip().lower()
|
| 119 |
+
|
| 120 |
+
if source_file.startswith(_JSON_PREFIX):
|
| 121 |
+
data = json.loads(text) # fail early with a clear error
|
| 122 |
+
arr = data.get("prompts") or data.get("shots")
|
| 123 |
+
if not isinstance(arr, list) or not arr:
|
| 124 |
+
raise ValueError(f"{p.name} must contain a non-empty 'prompts' (or 'shots') array.")
|
| 125 |
+
print(f"[JoyEcho] PromptSource: {p.name} (json, {len(arr)} shots, 1 item).", flush=True)
|
| 126 |
+
return ([text], [override], 1)
|
| 127 |
+
|
| 128 |
+
# TXT: LPFF-style blocks
|
| 129 |
+
items, names = [], []
|
| 130 |
+
for blk in _BLOCK_SPLIT.split(text):
|
| 131 |
+
m = _BLOCK_PATTERN.search(blk)
|
| 132 |
+
if not m or m.group("positive") is None:
|
| 133 |
+
continue
|
| 134 |
+
items.append(m.group("positive").strip())
|
| 135 |
+
nm = (m.group("name") or "").strip().lower()
|
| 136 |
+
names.append(override or nm)
|
| 137 |
+
total = len(items)
|
| 138 |
+
items = items[start_index:]
|
| 139 |
+
names = names[start_index:]
|
| 140 |
+
if load_cap > 0:
|
| 141 |
+
items = items[:load_cap]
|
| 142 |
+
names = names[:load_cap]
|
| 143 |
+
if not items:
|
| 144 |
+
raise ValueError(f"PromptSource: {p.name} yielded no blocks "
|
| 145 |
+
f"(total {total}, start_index {start_index}, load_cap {load_cap}).")
|
| 146 |
+
print(f"[JoyEcho] PromptSource: {p.name} (txt, {len(items)}/{total} blocks).", flush=True)
|
| 147 |
+
return (items, names, len(items))
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
NODE_CLASS_MAPPINGS = {"JoyEcho_PromptSource": JoyEcho_PromptSource}
|
| 151 |
+
NODE_DISPLAY_NAME_MAPPINGS = {"JoyEcho_PromptSource": "JoyEcho Prompt Source (txt briefs + json scripts)"}
|
joyecho_ref_batch.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JoyEcho Reference Batch - None-tolerant image batcher for reference images.
|
| 2 |
+
|
| 3 |
+
Combines up to 4 optional IMAGE inputs into one batch for
|
| 4 |
+
JoyEcho_Generate.reference_image. Unlike generic batch nodes (KJNodes
|
| 5 |
+
ImageBatchMulti crashes on None), inputs that are missing - e.g. a RefPicker
|
| 6 |
+
that found no character match and emitted its "no reference" None - are simply
|
| 7 |
+
skipped. If ALL inputs are missing, outputs None, which Generate treats as
|
| 8 |
+
"no reference wired": the item renders without identity seeding instead of
|
| 9 |
+
killing the queue.
|
| 10 |
+
|
| 11 |
+
Mixed sizes are resized (lanczos, center-crop semantics via common_upscale)
|
| 12 |
+
to the first present image's dimensions; Generate cover-fits every ref frame
|
| 13 |
+
to the video dimensions afterwards anyway.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
|
| 18 |
+
import comfy.utils
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class JoyEcho_RefBatch:
|
| 22 |
+
@classmethod
|
| 23 |
+
def INPUT_TYPES(cls):
|
| 24 |
+
return {
|
| 25 |
+
"required": {},
|
| 26 |
+
"optional": {
|
| 27 |
+
"image_1": ("IMAGE",),
|
| 28 |
+
"image_2": ("IMAGE",),
|
| 29 |
+
"image_3": ("IMAGE",),
|
| 30 |
+
"image_4": ("IMAGE",),
|
| 31 |
+
},
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
RETURN_TYPES = ("IMAGE",)
|
| 35 |
+
RETURN_NAMES = ("reference_image",)
|
| 36 |
+
FUNCTION = "batch"
|
| 37 |
+
CATEGORY = "JoyAI-Echo"
|
| 38 |
+
|
| 39 |
+
def batch(self, image_1=None, image_2=None, image_3=None, image_4=None):
|
| 40 |
+
imgs = [i for i in (image_1, image_2, image_3, image_4)
|
| 41 |
+
if i is not None and i.shape[0] > 0]
|
| 42 |
+
if not imgs:
|
| 43 |
+
print("[JoyEcho] RefBatch: no reference images present; "
|
| 44 |
+
"passing None (Generate skips identity seeding).", flush=True)
|
| 45 |
+
return (None,)
|
| 46 |
+
h, w = imgs[0].shape[1], imgs[0].shape[2]
|
| 47 |
+
out = []
|
| 48 |
+
for img in imgs:
|
| 49 |
+
if img.shape[1] != h or img.shape[2] != w:
|
| 50 |
+
img = comfy.utils.common_upscale(
|
| 51 |
+
img.movedim(-1, 1), w, h, "lanczos", "center").movedim(1, -1)
|
| 52 |
+
out.append(img)
|
| 53 |
+
return (torch.cat(out, dim=0),)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
NODE_CLASS_MAPPINGS = {"JoyEcho_RefBatch": JoyEcho_RefBatch}
|
| 57 |
+
NODE_DISPLAY_NAME_MAPPINGS = {"JoyEcho_RefBatch": "JoyEcho Reference Batch (None-tolerant)"}
|
joyecho_ref_picker.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JoyEcho Reference Picker - auto-select a character reference image.
|
| 2 |
+
|
| 3 |
+
Feeds JoyEcho_Generate's reference_image input in LPFF batch queues:
|
| 4 |
+
LPFF block carries `name: zara` -> UnzipPrompt name output -> this node ->
|
| 5 |
+
picks an image from <ComfyUI>/input/joyecho_refs/zara/ -> IMAGE out.
|
| 6 |
+
|
| 7 |
+
Resolution order per run:
|
| 8 |
+
1. `character` input (usually UnzipPrompt's name output), lowercased.
|
| 9 |
+
LPFF quirk: blocks WITHOUT a name: line emit the prompt FILENAME here -
|
| 10 |
+
that never matches a folder, so it falls through cleanly.
|
| 11 |
+
2. scan `prompt_text` for any refs-folder name as a whole word (longest first).
|
| 12 |
+
3. `fallback_image` input if wired.
|
| 13 |
+
4. clear error.
|
| 14 |
+
|
| 15 |
+
Pick strategies: by_seed (sorted files, seed % count - reproducible, vary the
|
| 16 |
+
seed to vary the ref), first, newest.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import torch
|
| 25 |
+
from PIL import Image, ImageOps
|
| 26 |
+
|
| 27 |
+
import folder_paths
|
| 28 |
+
|
| 29 |
+
_REFS_SUBDIR = "joyecho_refs"
|
| 30 |
+
_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _refs_root(custom_root: str = "") -> Path:
|
| 34 |
+
if custom_root and custom_root.strip():
|
| 35 |
+
return Path(custom_root.strip())
|
| 36 |
+
d = Path(folder_paths.get_input_directory()) / _REFS_SUBDIR
|
| 37 |
+
try:
|
| 38 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 39 |
+
except OSError:
|
| 40 |
+
pass
|
| 41 |
+
return d
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _character_dirs(root: Path) -> list[str]:
|
| 45 |
+
try:
|
| 46 |
+
return sorted(p.name for p in root.iterdir() if p.is_dir())
|
| 47 |
+
except OSError:
|
| 48 |
+
return []
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _images_in(folder: Path) -> list[Path]:
|
| 52 |
+
try:
|
| 53 |
+
return sorted(p for p in folder.iterdir()
|
| 54 |
+
if p.is_file() and p.suffix.lower() in _EXTS)
|
| 55 |
+
except OSError:
|
| 56 |
+
return []
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _load_image(path: Path) -> torch.Tensor:
|
| 60 |
+
img = Image.open(path)
|
| 61 |
+
img = ImageOps.exif_transpose(img).convert("RGB")
|
| 62 |
+
arr = np.asarray(img).astype(np.float32) / 255.0
|
| 63 |
+
return torch.from_numpy(arr)[None, ...] # [1, H, W, C]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class JoyEcho_RefPicker:
|
| 67 |
+
@classmethod
|
| 68 |
+
def INPUT_TYPES(cls):
|
| 69 |
+
return {
|
| 70 |
+
"required": {
|
| 71 |
+
"pick": (["by_seed", "first", "newest"],),
|
| 72 |
+
"seed": ("INT", {"default": 0, "min": 0, "max": 2**31 - 1,
|
| 73 |
+
"tooltip": "Used by by_seed: index = seed % image count."}),
|
| 74 |
+
},
|
| 75 |
+
"optional": {
|
| 76 |
+
"refs_root": ("STRING", {
|
| 77 |
+
"default": "G:\\RIFT Assets\\Rift Character Reference Images",
|
| 78 |
+
"tooltip": "Root folder holding one subfolder per character. Empty = ComfyUI/input/joyecho_refs/. Folder-name matching is case-insensitive on Windows.",
|
| 79 |
+
}),
|
| 80 |
+
"on_no_match": (["no_reference", "error"], {
|
| 81 |
+
"default": "no_reference",
|
| 82 |
+
"tooltip": "When no character matches and no fallback_image is wired: "
|
| 83 |
+
"no_reference = output nothing (Generate simply skips identity "
|
| 84 |
+
"seeding for this item; the batch keeps running). error = stop the run.",
|
| 85 |
+
}),
|
| 86 |
+
"character": ("STRING", {"default": "",
|
| 87 |
+
"tooltip": "Character folder name (e.g. marcus). TYPE it here for a "
|
| 88 |
+
"manual pick, or right-click the node > 'Convert character "
|
| 89 |
+
"to input' and wire PromptSource's character output for "
|
| 90 |
+
"automatic per-item picks."}),
|
| 91 |
+
"prompt_text": ("STRING", {"default": "", "forceInput": True,
|
| 92 |
+
"tooltip": "Fallback: scanned for any refs folder name as a whole word."}),
|
| 93 |
+
"fallback_image": ("IMAGE",),
|
| 94 |
+
},
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
RETURN_TYPES = ("IMAGE", "STRING",)
|
| 98 |
+
RETURN_NAMES = ("reference_image", "picked_path",)
|
| 99 |
+
FUNCTION = "pick_ref"
|
| 100 |
+
CATEGORY = "JoyAI-Echo"
|
| 101 |
+
|
| 102 |
+
@classmethod
|
| 103 |
+
def IS_CHANGED(cls, pick, seed, on_no_match="no_reference", refs_root="", character="", prompt_text="", fallback_image=None):
|
| 104 |
+
# Re-run when the resolved folder's contents change.
|
| 105 |
+
root = _refs_root(refs_root)
|
| 106 |
+
sig = [pick, str(seed), str(root), character.strip().lower()]
|
| 107 |
+
for d in _character_dirs(root):
|
| 108 |
+
folder = root / d
|
| 109 |
+
imgs = _images_in(folder)
|
| 110 |
+
sig.append(f"{d}:{len(imgs)}:{max((p.stat().st_mtime for p in imgs), default=0)}")
|
| 111 |
+
return "|".join(sig)
|
| 112 |
+
|
| 113 |
+
def pick_ref(self, pick, seed, on_no_match="no_reference", refs_root="", character="", prompt_text="", fallback_image=None):
|
| 114 |
+
root = _refs_root(refs_root)
|
| 115 |
+
dirs = _character_dirs(root)
|
| 116 |
+
|
| 117 |
+
chosen_dir = None
|
| 118 |
+
want = (character or "").strip().lower()
|
| 119 |
+
# LPFF quirk: blocks without a `name:` line emit the prompt FILENAME as
|
| 120 |
+
# the name - anything path/file-shaped is not a character.
|
| 121 |
+
if any(s in want for s in ("\\", "/", ".txt", ".json")):
|
| 122 |
+
want = ""
|
| 123 |
+
if want and (root / want).is_dir():
|
| 124 |
+
chosen_dir = root / want
|
| 125 |
+
|
| 126 |
+
if chosen_dir is None and prompt_text:
|
| 127 |
+
# Ignore names spoken INSIDE dialogue: absent characters get
|
| 128 |
+
# mentioned in quotes ("Alana thinks I am imagining it"), while
|
| 129 |
+
# on-screen characters are named in the descriptive prose. Strip
|
| 130 |
+
# JSON-escaped quotes, plain double quotes, and says,-introduced
|
| 131 |
+
# single-quoted lines before scanning.
|
| 132 |
+
scrub = prompt_text
|
| 133 |
+
scrub = re.sub(r'\\"(?:[^"\\]|\\.)*?\\"', " ", scrub) # \"...\" (JSON-escaped)
|
| 134 |
+
scrub = re.sub(r'"(?:[^"\\]|\\.)*?"', " ", scrub) # "..."
|
| 135 |
+
scrub = re.sub(r"says,\s*'(?:[^'])*?'", " ", scrub) # says, '...'
|
| 136 |
+
low = scrub.lower()
|
| 137 |
+
# The subject of a brief dominates its text: most-mentioned folder
|
| 138 |
+
# name wins; earliest first-mention breaks ties. (Longest-match-first
|
| 139 |
+
# wrongly picked a side character once - MARCUS over ZARA.)
|
| 140 |
+
best = None # (count, -first_pos, dirname)
|
| 141 |
+
for d in dirs:
|
| 142 |
+
hits = [m.start() for m in re.finditer(r"\b" + re.escape(d.lower()) + r"\b", low)]
|
| 143 |
+
if hits:
|
| 144 |
+
key = (len(hits), -hits[0])
|
| 145 |
+
if best is None or key > best[0]:
|
| 146 |
+
best = (key, d)
|
| 147 |
+
if best is not None:
|
| 148 |
+
chosen_dir = root / best[1]
|
| 149 |
+
|
| 150 |
+
if chosen_dir is None:
|
| 151 |
+
if fallback_image is not None:
|
| 152 |
+
print("[JoyEcho] RefPicker: no character match; using fallback_image.", flush=True)
|
| 153 |
+
return (fallback_image, "(fallback_image)")
|
| 154 |
+
if on_no_match == "no_reference":
|
| 155 |
+
print(f"[JoyEcho] RefPicker: no character match (character={character!r}); "
|
| 156 |
+
f"continuing WITHOUT a reference.", flush=True)
|
| 157 |
+
return (None, "(no reference)")
|
| 158 |
+
raise ValueError(
|
| 159 |
+
f"RefPicker: no reference folder matched. character={character!r}, "
|
| 160 |
+
f"available folders in {root}: {dirs or '(none - create input/joyecho_refs/<name>/)'}"
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
imgs = _images_in(chosen_dir)
|
| 164 |
+
if not imgs:
|
| 165 |
+
if fallback_image is not None:
|
| 166 |
+
print(f"[JoyEcho] RefPicker: {chosen_dir.name}/ is empty; using fallback_image.", flush=True)
|
| 167 |
+
return (fallback_image, "(fallback_image)")
|
| 168 |
+
if on_no_match == "no_reference":
|
| 169 |
+
print(f"[JoyEcho] RefPicker: {chosen_dir.name}/ is empty; continuing WITHOUT a reference.", flush=True)
|
| 170 |
+
return (None, "(no reference)")
|
| 171 |
+
raise ValueError(f"RefPicker: no images in {chosen_dir} (put .png/.jpg refs there).")
|
| 172 |
+
|
| 173 |
+
if pick == "first":
|
| 174 |
+
path = imgs[0]
|
| 175 |
+
elif pick == "newest":
|
| 176 |
+
path = max(imgs, key=lambda p: p.stat().st_mtime)
|
| 177 |
+
else: # by_seed
|
| 178 |
+
path = imgs[seed % len(imgs)]
|
| 179 |
+
|
| 180 |
+
print(f"[JoyEcho] RefPicker: {chosen_dir.name} -> {path.name} "
|
| 181 |
+
f"({pick}, {len(imgs)} candidates).", flush=True)
|
| 182 |
+
return (_load_image(path), str(path))
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
NODE_CLASS_MAPPINGS = {"JoyEcho_RefPicker": JoyEcho_RefPicker}
|
| 186 |
+
NODE_DISPLAY_NAME_MAPPINGS = {"JoyEcho_RefPicker": "JoyEcho Reference Picker (auto by character)"}
|
joyecho_script_picker.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JoyEcho Script Picker — a dropdown node for selecting a saved prompt JSON.
|
| 2 |
+
|
| 3 |
+
The stock JoyEcho_TextEncode 'prompts' field only accepts pasted JSON / a typed
|
| 4 |
+
path, which is painful in the canvas. This node lists every *.json in
|
| 5 |
+
<ComfyUI>/input/joyecho_prompts/
|
| 6 |
+
as a COMBO dropdown and outputs the file's contents, wired straight into
|
| 7 |
+
JoyEcho_TextEncode's 'prompts' input (which accepts inline {"prompts":[...]} JSON).
|
| 8 |
+
|
| 9 |
+
Add a .json to that folder, hit the ComfyUI refresh button (or press R) to
|
| 10 |
+
repopulate the dropdown, pick it, run. Editing the file re-triggers execution
|
| 11 |
+
automatically (IS_CHANGED tracks mtime) — no need to reselect.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import folder_paths
|
| 18 |
+
|
| 19 |
+
_PROMPTS_SUBDIR = "joyecho_prompts"
|
| 20 |
+
_EMPTY = "(no .json in input/joyecho_prompts)"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _scripts_dir() -> Path:
|
| 24 |
+
d = Path(folder_paths.get_input_directory()) / _PROMPTS_SUBDIR
|
| 25 |
+
try:
|
| 26 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
except OSError:
|
| 28 |
+
pass
|
| 29 |
+
return d
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _list_scripts() -> list[str]:
|
| 33 |
+
d = _scripts_dir()
|
| 34 |
+
try:
|
| 35 |
+
files = sorted(p.name for p in d.glob("*.json"))
|
| 36 |
+
except OSError:
|
| 37 |
+
files = []
|
| 38 |
+
return files if files else [_EMPTY]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class JoyEcho_ScriptPicker:
|
| 42 |
+
"""Pick a prompt-script .json from input/joyecho_prompts via a dropdown."""
|
| 43 |
+
|
| 44 |
+
@classmethod
|
| 45 |
+
def INPUT_TYPES(cls):
|
| 46 |
+
return {"required": {"script": (_list_scripts(),)}}
|
| 47 |
+
|
| 48 |
+
RETURN_TYPES = ("STRING", "STRING",)
|
| 49 |
+
RETURN_NAMES = ("prompts_json", "path",)
|
| 50 |
+
FUNCTION = "load"
|
| 51 |
+
CATEGORY = "JoyAI-Echo"
|
| 52 |
+
|
| 53 |
+
@classmethod
|
| 54 |
+
def IS_CHANGED(cls, script):
|
| 55 |
+
# Re-run when the selected file changes on disk, so edits are picked up.
|
| 56 |
+
p = _scripts_dir() / script
|
| 57 |
+
try:
|
| 58 |
+
return f"{script}:{p.stat().st_mtime}"
|
| 59 |
+
except OSError:
|
| 60 |
+
return script
|
| 61 |
+
|
| 62 |
+
def load(self, script):
|
| 63 |
+
if script == _EMPTY:
|
| 64 |
+
raise ValueError(
|
| 65 |
+
f"No .json scripts found. Put your prompt JSON in {_scripts_dir()} "
|
| 66 |
+
"and press the refresh button (or R) to repopulate the dropdown."
|
| 67 |
+
)
|
| 68 |
+
p = _scripts_dir() / script
|
| 69 |
+
if not p.exists():
|
| 70 |
+
raise FileNotFoundError(
|
| 71 |
+
f"Script not found: {p}. Refresh the node list (R) after adding files."
|
| 72 |
+
)
|
| 73 |
+
text = p.read_text(encoding="utf-8")
|
| 74 |
+
# Fail early with a clear message rather than deep in the text encoder.
|
| 75 |
+
try:
|
| 76 |
+
data = json.loads(text)
|
| 77 |
+
except json.JSONDecodeError as e:
|
| 78 |
+
raise ValueError(f"{script} is not valid JSON: {e}")
|
| 79 |
+
arr = data.get("prompts") if isinstance(data, dict) else None
|
| 80 |
+
if arr is None and isinstance(data, dict):
|
| 81 |
+
arr = data.get("shots")
|
| 82 |
+
if not isinstance(arr, list) or not arr:
|
| 83 |
+
raise ValueError(f"{script} must contain a non-empty 'prompts' (or 'shots') array.")
|
| 84 |
+
print(f"[JoyEcho] ScriptPicker: {script} ({len(arr)} shots).", flush=True)
|
| 85 |
+
return (text, str(p),)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
NODE_CLASS_MAPPINGS = {"JoyEcho_ScriptPicker": JoyEcho_ScriptPicker}
|
| 89 |
+
NODE_DISPLAY_NAME_MAPPINGS = {"JoyEcho_ScriptPicker": "JoyEcho Script Picker (JSON dropdown)"}
|
libs/ltx_distillation/models/ltx_wrapper.py
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LTX-2 Diffusion Model Wrapper for DMD distillation.
|
| 3 |
+
|
| 4 |
+
This wrapper adapts LTX-2's audio-video joint generation model for use in
|
| 5 |
+
DMD (Distribution Matching Distillation) training.
|
| 6 |
+
|
| 7 |
+
Model Architecture:
|
| 8 |
+
- patch_size = (1, 1, 1): No spatial/temporal grouping
|
| 9 |
+
- Patchification: Simple reshape [B, C, F, H, W] → [B, F*H*W, C]
|
| 10 |
+
- Each token: 128-dimensional latent vector (one per spatial-temporal position)
|
| 11 |
+
- Model input projection: Linear(128, 4096)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from dataclasses import replace
|
| 15 |
+
from typing import Any, Dict, Optional, Tuple
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
import torch.nn as nn
|
| 19 |
+
|
| 20 |
+
from ltx_core.components.patchifiers import (
|
| 21 |
+
AudioPatchifier,
|
| 22 |
+
VideoLatentPatchifier,
|
| 23 |
+
get_pixel_coords,
|
| 24 |
+
)
|
| 25 |
+
from ltx_core.guidance.perturbations import (
|
| 26 |
+
BatchedPerturbationConfig,
|
| 27 |
+
Perturbation,
|
| 28 |
+
PerturbationConfig,
|
| 29 |
+
PerturbationType,
|
| 30 |
+
)
|
| 31 |
+
from ltx_core.loader import LoraPathStrengthAndSDOps
|
| 32 |
+
from ltx_core.loader.registry import Registry
|
| 33 |
+
from ltx_core.model.transformer import LTXModel, X0Model
|
| 34 |
+
from ltx_core.model.transformer.modality import Modality
|
| 35 |
+
from ltx_core.types import (
|
| 36 |
+
AudioLatentShape,
|
| 37 |
+
SpatioTemporalScaleFactors,
|
| 38 |
+
VideoLatentShape,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class LTX2DiffusionWrapper(nn.Module):
|
| 43 |
+
"""
|
| 44 |
+
Wrapper for LTX-2 model to provide DMD-compatible interface.
|
| 45 |
+
|
| 46 |
+
Handles:
|
| 47 |
+
- Input format conversion: [B, F, C, H, W] -> Modality
|
| 48 |
+
- Timestep handling: sigma values for all tokens
|
| 49 |
+
- Position computation for video (3D) and audio (1D)
|
| 50 |
+
- Output format: x0 predictions for both video and audio
|
| 51 |
+
|
| 52 |
+
Uses official LTX-2 patchifiers (patch_size=1) to ensure consistency
|
| 53 |
+
with the pretrained model weights.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
# Time alignment constants
|
| 57 |
+
VIDEO_LATENT_FPS = 3.0 # 24fps / 8 (VAE compression)
|
| 58 |
+
AUDIO_LATENT_FPS = 25.0 # 16kHz / 160 / 4 (mel hop / VAE compression)
|
| 59 |
+
ALIGNMENT_RATIO = AUDIO_LATENT_FPS / VIDEO_LATENT_FPS # ~8.33
|
| 60 |
+
|
| 61 |
+
# Video FPS for position computation
|
| 62 |
+
VIDEO_FPS = 24.0
|
| 63 |
+
|
| 64 |
+
# VAE scale factors (temporal=8, height=32, width=32)
|
| 65 |
+
DEFAULT_SCALE_FACTORS = SpatioTemporalScaleFactors.default()
|
| 66 |
+
|
| 67 |
+
def __init__(
|
| 68 |
+
self,
|
| 69 |
+
model: LTXModel,
|
| 70 |
+
video_height: int = 512,
|
| 71 |
+
video_width: int = 768,
|
| 72 |
+
vae_spatial_compression: int = 32,
|
| 73 |
+
):
|
| 74 |
+
"""
|
| 75 |
+
Args:
|
| 76 |
+
model: X0Model instance (wraps velocity model, returns x0 predictions)
|
| 77 |
+
video_height: Video height in pixels
|
| 78 |
+
video_width: Video width in pixels
|
| 79 |
+
vae_spatial_compression: VAE spatial compression factor
|
| 80 |
+
"""
|
| 81 |
+
super().__init__()
|
| 82 |
+
self.model = model
|
| 83 |
+
self.video_height = video_height
|
| 84 |
+
self.video_width = video_width
|
| 85 |
+
self.vae_spatial_compression = vae_spatial_compression
|
| 86 |
+
|
| 87 |
+
# Compute latent dimensions
|
| 88 |
+
self.latent_height = video_height // vae_spatial_compression # 16
|
| 89 |
+
self.latent_width = video_width // vae_spatial_compression # 24
|
| 90 |
+
|
| 91 |
+
# Official patchifiers with patch_size=1 (no spatial grouping)
|
| 92 |
+
self.video_patchifier = VideoLatentPatchifier(patch_size=1)
|
| 93 |
+
self.audio_patchifier = AudioPatchifier(patch_size=1)
|
| 94 |
+
|
| 95 |
+
# Frame sequence length: with patch_size=1, each spatial position is one token
|
| 96 |
+
# For 512x768: H'*W' = 16*24 = 384 tokens per frame
|
| 97 |
+
self.video_frame_seqlen = self.latent_height * self.latent_width # 384
|
| 98 |
+
|
| 99 |
+
def set_module_grad(self, module_grad: Dict[str, bool]) -> None:
|
| 100 |
+
"""
|
| 101 |
+
Set gradient requirements for model components.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
module_grad: Dict mapping component names to requires_grad flags
|
| 105 |
+
"""
|
| 106 |
+
if module_grad.get("model", True):
|
| 107 |
+
self.model.requires_grad_(True)
|
| 108 |
+
else:
|
| 109 |
+
self.model.requires_grad_(False)
|
| 110 |
+
self.model.eval()
|
| 111 |
+
|
| 112 |
+
def enable_gradient_checkpointing(self) -> None:
|
| 113 |
+
"""Enable gradient checkpointing for memory efficiency."""
|
| 114 |
+
if hasattr(self.model, "velocity_model"):
|
| 115 |
+
self.model.velocity_model.set_gradient_checkpointing(True)
|
| 116 |
+
elif hasattr(self.model, "set_gradient_checkpointing"):
|
| 117 |
+
self.model.set_gradient_checkpointing(True)
|
| 118 |
+
|
| 119 |
+
def _flatten_video_latent(
|
| 120 |
+
self,
|
| 121 |
+
video_latent: torch.Tensor,
|
| 122 |
+
) -> torch.Tensor:
|
| 123 |
+
"""
|
| 124 |
+
Flatten video latent from [B, F, C, H, W] to [B, T, C] using patch_size=1.
|
| 125 |
+
|
| 126 |
+
With patch_size=1, this is a simple reshape — no spatial grouping.
|
| 127 |
+
The official VideoLatentPatchifier(patch_size=1) does:
|
| 128 |
+
"b c (f 1) (h 1) (w 1) -> b (f h w) (c 1 1 1)" = "b c f h w -> b (f h w) c"
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
video_latent: Shape [B, F, C, H, W] where
|
| 132 |
+
- F: number of latent frames
|
| 133 |
+
- C: latent channels (128)
|
| 134 |
+
- H, W: latent spatial dimensions (16, 24)
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
Flattened tensor [B, T, C] where:
|
| 138 |
+
- T = F * H * W (e.g., 16 * 16 * 24 = 6144)
|
| 139 |
+
- C = 128 (unchanged, since patch_size=1)
|
| 140 |
+
"""
|
| 141 |
+
B, F, C, H, W = video_latent.shape
|
| 142 |
+
assert C == 128, (
|
| 143 |
+
f"Expected video latent C=128 at dim 2, got shape {video_latent.shape}. "
|
| 144 |
+
f"Input should be [B, F, C, H, W] with C=128."
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# Convert from [B, F, C, H, W] to [B, C, F, H, W] (official format)
|
| 148 |
+
video_latent = video_latent.permute(0, 2, 1, 3, 4)
|
| 149 |
+
|
| 150 |
+
# Use official patchifier: [B, C, F, H, W] -> [B, F*H*W, C]
|
| 151 |
+
# With patch_size=1 this is equivalent to:
|
| 152 |
+
# einops.rearrange(x, "b c f h w -> b (f h w) c")
|
| 153 |
+
video_latent = self.video_patchifier.patchify(video_latent)
|
| 154 |
+
|
| 155 |
+
return video_latent
|
| 156 |
+
|
| 157 |
+
def _unflatten_video_latent(
|
| 158 |
+
self,
|
| 159 |
+
flat_latent: torch.Tensor,
|
| 160 |
+
num_frames: int,
|
| 161 |
+
) -> torch.Tensor:
|
| 162 |
+
"""
|
| 163 |
+
Unflatten video latent from [B, T, C] back to [B, F, C, H, W].
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
flat_latent: Shape [B, T, C] where C = 128 (patch_size=1)
|
| 167 |
+
num_frames: Number of latent frames F
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
Video latent [B, F, C, H, W]
|
| 171 |
+
"""
|
| 172 |
+
B, T, C = flat_latent.shape
|
| 173 |
+
H = self.latent_height
|
| 174 |
+
W = self.latent_width
|
| 175 |
+
F = num_frames
|
| 176 |
+
|
| 177 |
+
# Use official unpatchifier: [B, T, C] -> [B, C, F, H, W]
|
| 178 |
+
output_shape = VideoLatentShape(
|
| 179 |
+
batch=B, channels=C, frames=F, height=H, width=W
|
| 180 |
+
)
|
| 181 |
+
video_latent = self.video_patchifier.unpatchify(flat_latent, output_shape)
|
| 182 |
+
|
| 183 |
+
# Convert from [B, C, F, H, W] to [B, F, C, H, W] (DMD format)
|
| 184 |
+
video_latent = video_latent.permute(0, 2, 1, 3, 4)
|
| 185 |
+
|
| 186 |
+
return video_latent
|
| 187 |
+
|
| 188 |
+
def _compute_video_positions(
|
| 189 |
+
self,
|
| 190 |
+
video_latent: torch.Tensor,
|
| 191 |
+
downscale_factor: int = 1,
|
| 192 |
+
start_frame: int = 0,
|
| 193 |
+
) -> torch.Tensor:
|
| 194 |
+
"""
|
| 195 |
+
Compute 3D position indices for video tokens with [start, end) bounds.
|
| 196 |
+
|
| 197 |
+
Uses the official VideoLatentPatchifier.get_patch_grid_bounds() and
|
| 198 |
+
get_pixel_coords() to ensure consistency with the pretrained model.
|
| 199 |
+
|
| 200 |
+
The RoPE computation expects positions in the format [B, 3, T, 2] where:
|
| 201 |
+
- dim 1 (size 3): temporal, height, width dimensions
|
| 202 |
+
- dim 3 (size 2): [start, end) bounds for each patch
|
| 203 |
+
|
| 204 |
+
Returns:
|
| 205 |
+
Position tensor of shape [B, 3, T, 2] with patch bounds in pixel space
|
| 206 |
+
"""
|
| 207 |
+
B, F, C, H, W = video_latent.shape
|
| 208 |
+
device = video_latent.device
|
| 209 |
+
|
| 210 |
+
# Build VideoLatentShape for the patchifier
|
| 211 |
+
video_shape = VideoLatentShape(
|
| 212 |
+
batch=B, channels=C, frames=F, height=H, width=W
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
# Get patch grid bounds in latent coordinates: [B, 3, T, 2]
|
| 216 |
+
# With patch_size=1, each token covers [i, i+1) in each dimension
|
| 217 |
+
latent_coords = self.video_patchifier.get_patch_grid_bounds(
|
| 218 |
+
output_shape=video_shape,
|
| 219 |
+
device=device,
|
| 220 |
+
)
|
| 221 |
+
if start_frame != 0:
|
| 222 |
+
latent_coords = latent_coords.clone()
|
| 223 |
+
latent_coords[:, 0, :, :] += int(start_frame)
|
| 224 |
+
|
| 225 |
+
# Convert to pixel coordinates using official helper
|
| 226 |
+
# Applies scale_factors (temporal=8, height=32, width=32)
|
| 227 |
+
# and causal_fix (first frame temporal offset)
|
| 228 |
+
pixel_coords = get_pixel_coords(
|
| 229 |
+
latent_coords=latent_coords,
|
| 230 |
+
scale_factors=self.DEFAULT_SCALE_FACTORS,
|
| 231 |
+
causal_fix=True,
|
| 232 |
+
).float()
|
| 233 |
+
|
| 234 |
+
# Convert temporal dimension from frames to seconds (divide by fps=24)
|
| 235 |
+
# This matches VideoLatentTools.create_initial_state
|
| 236 |
+
pixel_coords[:, 0, ...] = pixel_coords[:, 0, ...] / self.VIDEO_FPS
|
| 237 |
+
|
| 238 |
+
if downscale_factor != 1:
|
| 239 |
+
pixel_coords = pixel_coords.clone()
|
| 240 |
+
pixel_coords[:, 1, ...] *= downscale_factor
|
| 241 |
+
pixel_coords[:, 2, ...] *= downscale_factor
|
| 242 |
+
|
| 243 |
+
return pixel_coords
|
| 244 |
+
|
| 245 |
+
# Audio timing constants (from AudioPatchifier defaults)
|
| 246 |
+
AUDIO_SAMPLE_RATE = 16000
|
| 247 |
+
AUDIO_HOP_LENGTH = 160
|
| 248 |
+
AUDIO_LATENT_DOWNSAMPLE_FACTOR = 4
|
| 249 |
+
AUDIO_IS_CAUSAL = True
|
| 250 |
+
|
| 251 |
+
def _get_audio_latent_time_in_sec(
|
| 252 |
+
self,
|
| 253 |
+
start_latent: int,
|
| 254 |
+
end_latent: int,
|
| 255 |
+
dtype: torch.dtype,
|
| 256 |
+
device: torch.device,
|
| 257 |
+
) -> torch.Tensor:
|
| 258 |
+
"""
|
| 259 |
+
Converts latent indices into real-time seconds while honoring causal
|
| 260 |
+
offsets and the configured hop length.
|
| 261 |
+
|
| 262 |
+
Matches AudioPatchifier._get_audio_latent_time_in_sec exactly.
|
| 263 |
+
"""
|
| 264 |
+
audio_latent_frame = torch.arange(start_latent, end_latent, dtype=dtype, device=device)
|
| 265 |
+
audio_mel_frame = audio_latent_frame * self.AUDIO_LATENT_DOWNSAMPLE_FACTOR
|
| 266 |
+
|
| 267 |
+
if self.AUDIO_IS_CAUSAL:
|
| 268 |
+
# Frame offset for causal alignment.
|
| 269 |
+
causal_offset = 1
|
| 270 |
+
audio_mel_frame = (audio_mel_frame + causal_offset - self.AUDIO_LATENT_DOWNSAMPLE_FACTOR).clip(min=0)
|
| 271 |
+
|
| 272 |
+
return audio_mel_frame * self.AUDIO_HOP_LENGTH / self.AUDIO_SAMPLE_RATE
|
| 273 |
+
|
| 274 |
+
def _compute_audio_positions(
|
| 275 |
+
self,
|
| 276 |
+
audio_latent: torch.Tensor,
|
| 277 |
+
start_frame: int = 0,
|
| 278 |
+
) -> torch.Tensor:
|
| 279 |
+
"""
|
| 280 |
+
Compute 1D temporal positions for audio tokens with [start, end) bounds.
|
| 281 |
+
|
| 282 |
+
The RoPE computation expects positions in the format [B, 1, T, 2] where:
|
| 283 |
+
- dim 1 (size 1): temporal dimension only (audio is 1D)
|
| 284 |
+
- dim 3 (size 2): [start, end) bounds in seconds
|
| 285 |
+
|
| 286 |
+
Returns:
|
| 287 |
+
Position tensor of shape [B, 1, T, 2] with temporal bounds in seconds
|
| 288 |
+
"""
|
| 289 |
+
B, T, C = audio_latent.shape
|
| 290 |
+
device = audio_latent.device
|
| 291 |
+
|
| 292 |
+
# Compute start timings for each audio frame
|
| 293 |
+
start_timings = self._get_audio_latent_time_in_sec(
|
| 294 |
+
int(start_frame), int(start_frame) + T, torch.float32, device
|
| 295 |
+
)
|
| 296 |
+
start_timings = start_timings.unsqueeze(0).expand(B, -1).unsqueeze(1) # [B, 1, T]
|
| 297 |
+
|
| 298 |
+
# Compute end timings for each audio frame (shifted by 1)
|
| 299 |
+
end_timings = self._get_audio_latent_time_in_sec(
|
| 300 |
+
int(start_frame) + 1, int(start_frame) + T + 1, torch.float32, device
|
| 301 |
+
)
|
| 302 |
+
end_timings = end_timings.unsqueeze(0).expand(B, -1).unsqueeze(1) # [B, 1, T]
|
| 303 |
+
|
| 304 |
+
# Stack to create [B, 1, T, 2] with [start, end) bounds
|
| 305 |
+
positions = torch.stack([start_timings, end_timings], dim=-1)
|
| 306 |
+
|
| 307 |
+
return positions
|
| 308 |
+
|
| 309 |
+
def _compute_timesteps_for_tokens(
|
| 310 |
+
self,
|
| 311 |
+
sigma: torch.Tensor,
|
| 312 |
+
num_tokens: int,
|
| 313 |
+
tokens_per_frame: int,
|
| 314 |
+
) -> torch.Tensor:
|
| 315 |
+
"""
|
| 316 |
+
Expand sigma to per-token timesteps.
|
| 317 |
+
|
| 318 |
+
In the official pipeline, timesteps = denoise_mask * sigma, producing
|
| 319 |
+
shape [B, T, 1]. Here we replicate sigma to each token belonging to
|
| 320 |
+
the same frame and add a trailing dimension for broadcasting with
|
| 321 |
+
the latent channels.
|
| 322 |
+
|
| 323 |
+
Args:
|
| 324 |
+
sigma: Shape [B] or [B, F] - sigma values per frame
|
| 325 |
+
num_tokens: Total number of tokens
|
| 326 |
+
tokens_per_frame: Number of tokens per frame
|
| 327 |
+
|
| 328 |
+
Returns:
|
| 329 |
+
Timesteps tensor [B, T, 1] for correct broadcasting with [B, T, C]
|
| 330 |
+
"""
|
| 331 |
+
B = sigma.shape[0]
|
| 332 |
+
|
| 333 |
+
if sigma.dim() == 1:
|
| 334 |
+
# Single sigma per sample -> expand to all tokens
|
| 335 |
+
return sigma.view(B, 1, 1).expand(B, num_tokens, 1)
|
| 336 |
+
else:
|
| 337 |
+
# Per-frame sigma [B, F] -> expand to per-token [B, T, 1]
|
| 338 |
+
F = sigma.shape[1]
|
| 339 |
+
expanded = sigma.unsqueeze(2).expand(B, F, tokens_per_frame).reshape(B, -1)
|
| 340 |
+
return expanded.unsqueeze(-1) # [B, T, 1]
|
| 341 |
+
|
| 342 |
+
@staticmethod
|
| 343 |
+
def _memory_slot_ranges(total_seq_len: int, num_slots: int) -> list[tuple[int, int]]:
|
| 344 |
+
if total_seq_len <= 0 or num_slots <= 0:
|
| 345 |
+
return []
|
| 346 |
+
|
| 347 |
+
ranges: list[tuple[int, int]] = []
|
| 348 |
+
start = 0
|
| 349 |
+
for slot_idx in range(num_slots):
|
| 350 |
+
end = round((slot_idx + 1) * total_seq_len / num_slots)
|
| 351 |
+
if end > start:
|
| 352 |
+
ranges.append((start, end))
|
| 353 |
+
start = end
|
| 354 |
+
return ranges
|
| 355 |
+
|
| 356 |
+
@staticmethod
|
| 357 |
+
def _memory_slot_ranges_from_lengths(
|
| 358 |
+
lengths: tuple[int, ...] | None,
|
| 359 |
+
*,
|
| 360 |
+
total_seq_len: int,
|
| 361 |
+
num_slots: int,
|
| 362 |
+
) -> list[tuple[int, int]]:
|
| 363 |
+
if not lengths or len(lengths) != num_slots:
|
| 364 |
+
return LTX2DiffusionWrapper._memory_slot_ranges(total_seq_len, num_slots)
|
| 365 |
+
|
| 366 |
+
ranges: list[tuple[int, int]] = []
|
| 367 |
+
start = 0
|
| 368 |
+
for raw_length in lengths:
|
| 369 |
+
length = max(0, int(raw_length))
|
| 370 |
+
end = min(start + length, total_seq_len)
|
| 371 |
+
if end > start:
|
| 372 |
+
ranges.append((start, end))
|
| 373 |
+
start = end
|
| 374 |
+
if start != total_seq_len:
|
| 375 |
+
return LTX2DiffusionWrapper._memory_slot_ranges(total_seq_len, num_slots)
|
| 376 |
+
return ranges
|
| 377 |
+
|
| 378 |
+
@classmethod
|
| 379 |
+
def _build_paired_memory_cross_mask(
|
| 380 |
+
cls,
|
| 381 |
+
*,
|
| 382 |
+
batch_size: int,
|
| 383 |
+
query_memory_seq_len: int,
|
| 384 |
+
query_target_seq_len: int,
|
| 385 |
+
kv_memory_seq_len: int,
|
| 386 |
+
kv_target_seq_len: int,
|
| 387 |
+
num_memory_slots: int,
|
| 388 |
+
device: torch.device,
|
| 389 |
+
query_segment_lengths: tuple[tuple[int, ...], ...] | None = None,
|
| 390 |
+
kv_segment_lengths: tuple[tuple[int, ...], ...] | None = None,
|
| 391 |
+
) -> torch.Tensor:
|
| 392 |
+
query_total_seq_len = query_memory_seq_len + query_target_seq_len
|
| 393 |
+
kv_total_seq_len = kv_memory_seq_len + kv_target_seq_len
|
| 394 |
+
mask = torch.zeros(
|
| 395 |
+
batch_size,
|
| 396 |
+
query_total_seq_len,
|
| 397 |
+
kv_total_seq_len,
|
| 398 |
+
dtype=torch.bool,
|
| 399 |
+
device=device,
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
for batch_idx in range(batch_size):
|
| 403 |
+
query_lengths = (
|
| 404 |
+
query_segment_lengths[batch_idx]
|
| 405 |
+
if query_segment_lengths is not None and batch_idx < len(query_segment_lengths)
|
| 406 |
+
else None
|
| 407 |
+
)
|
| 408 |
+
kv_lengths = (
|
| 409 |
+
kv_segment_lengths[batch_idx]
|
| 410 |
+
if kv_segment_lengths is not None and batch_idx < len(kv_segment_lengths)
|
| 411 |
+
else None
|
| 412 |
+
)
|
| 413 |
+
query_ranges = cls._memory_slot_ranges_from_lengths(
|
| 414 |
+
query_lengths,
|
| 415 |
+
total_seq_len=query_memory_seq_len,
|
| 416 |
+
num_slots=num_memory_slots,
|
| 417 |
+
)
|
| 418 |
+
kv_ranges = cls._memory_slot_ranges_from_lengths(
|
| 419 |
+
kv_lengths,
|
| 420 |
+
total_seq_len=kv_memory_seq_len,
|
| 421 |
+
num_slots=num_memory_slots,
|
| 422 |
+
)
|
| 423 |
+
for (q_start, q_end), (k_start, k_end) in zip(query_ranges, kv_ranges, strict=False):
|
| 424 |
+
mask[batch_idx, q_start:q_end, k_start:k_end] = True
|
| 425 |
+
|
| 426 |
+
if query_target_seq_len > 0 and kv_target_seq_len > 0:
|
| 427 |
+
mask[:, query_memory_seq_len:, kv_memory_seq_len:] = True
|
| 428 |
+
return mask
|
| 429 |
+
|
| 430 |
+
@staticmethod
|
| 431 |
+
def _build_memory_self_attention_block_mask(
|
| 432 |
+
*,
|
| 433 |
+
batch_size: int,
|
| 434 |
+
memory_seq_len: int,
|
| 435 |
+
target_seq_len: int,
|
| 436 |
+
device: torch.device,
|
| 437 |
+
) -> torch.Tensor | None:
|
| 438 |
+
if memory_seq_len <= 0:
|
| 439 |
+
return None
|
| 440 |
+
|
| 441 |
+
total_seq_len = memory_seq_len + target_seq_len
|
| 442 |
+
attention_mask = torch.ones(
|
| 443 |
+
batch_size,
|
| 444 |
+
total_seq_len,
|
| 445 |
+
total_seq_len,
|
| 446 |
+
dtype=torch.bool,
|
| 447 |
+
device=device,
|
| 448 |
+
)
|
| 449 |
+
attention_mask[:, :, :memory_seq_len] = False
|
| 450 |
+
attention_mask[:, :memory_seq_len, :] = False
|
| 451 |
+
attention_mask[:, :memory_seq_len, :memory_seq_len] = True
|
| 452 |
+
return attention_mask
|
| 453 |
+
|
| 454 |
+
def forward(
|
| 455 |
+
self,
|
| 456 |
+
noisy_image_or_video: torch.Tensor,
|
| 457 |
+
conditional_dict: Dict[str, Any],
|
| 458 |
+
timestep: torch.Tensor,
|
| 459 |
+
noisy_audio: Optional[torch.Tensor] = None,
|
| 460 |
+
audio_timestep: Optional[torch.Tensor] = None,
|
| 461 |
+
memory_video: Optional[torch.Tensor] = None,
|
| 462 |
+
memory_audio: Optional[torch.Tensor] = None,
|
| 463 |
+
memory_audio_timestep: Optional[torch.Tensor] = None,
|
| 464 |
+
memory_audio_segment_lengths: tuple[tuple[int, ...], ...] | None = None,
|
| 465 |
+
paired_audio_memory: bool = False,
|
| 466 |
+
v2a_grad_scale: float = 1.0,
|
| 467 |
+
memory_position_mode: str = "reference",
|
| 468 |
+
memory_downscale_factor: int = 1,
|
| 469 |
+
skip_a2v_cross_attn: bool = False,
|
| 470 |
+
skip_v2a_cross_attn: bool = False,
|
| 471 |
+
skip_video_self_attn: bool = False,
|
| 472 |
+
skip_audio_self_attn: bool = False,
|
| 473 |
+
use_causal_timestep: bool = False, # ignored, for API compatibility
|
| 474 |
+
**kwargs,
|
| 475 |
+
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
|
| 476 |
+
"""
|
| 477 |
+
Forward pass for DMD distillation.
|
| 478 |
+
|
| 479 |
+
Args:
|
| 480 |
+
noisy_image_or_video: Noisy video latent [B, F, C, H, W]
|
| 481 |
+
conditional_dict: Dictionary containing:
|
| 482 |
+
- video_context: [B, seq_len, dim]
|
| 483 |
+
- audio_context: [B, seq_len, dim]
|
| 484 |
+
- attention_mask: [B, seq_len]
|
| 485 |
+
timestep: Sigma values [B] or [B, F]
|
| 486 |
+
noisy_audio: Noisy audio latent [B, F_a, C_audio] (optional)
|
| 487 |
+
where C_audio = 128 (= 8 channels * 16 mel_bins, post-patchify)
|
| 488 |
+
audio_timestep: Audio sigma values [B] or [B, F_a] (optional)
|
| 489 |
+
memory_audio: Optional clean memory-audio prefix [B, F_mem_a, C_audio]
|
| 490 |
+
memory_audio_timestep: Optional memory-audio sigma values [B] or [B, F_mem_a]
|
| 491 |
+
|
| 492 |
+
Returns:
|
| 493 |
+
Tuple of (video_x0_pred, audio_x0_pred)
|
| 494 |
+
- video_x0_pred: [B, F, C, H, W]
|
| 495 |
+
- audio_x0_pred: [B, F_a, C_audio] or None
|
| 496 |
+
"""
|
| 497 |
+
B = noisy_image_or_video.shape[0]
|
| 498 |
+
num_video_frames = noisy_image_or_video.shape[1]
|
| 499 |
+
device = noisy_image_or_video.device
|
| 500 |
+
memory_position_mode = str(memory_position_mode).lower()
|
| 501 |
+
if memory_position_mode == "reference":
|
| 502 |
+
memory_position_mode = "legacy"
|
| 503 |
+
if memory_position_mode not in {"legacy", "prefix_continuous"}:
|
| 504 |
+
raise ValueError(
|
| 505 |
+
"memory_position_mode must be one of {'reference', 'legacy', 'prefix_continuous'}, "
|
| 506 |
+
f"got {memory_position_mode}"
|
| 507 |
+
)
|
| 508 |
+
if memory_video is not None and int(memory_video.shape[1]) == 0:
|
| 509 |
+
memory_video = None
|
| 510 |
+
|
| 511 |
+
# Flatten target video latent: [B, F, C, H, W] -> [B, T, C]
|
| 512 |
+
# With patch_size=1: T = F*H*W, C = 128
|
| 513 |
+
target_video_flat = self._flatten_video_latent(noisy_image_or_video)
|
| 514 |
+
num_target_video_tokens = target_video_flat.shape[1]
|
| 515 |
+
|
| 516 |
+
# Compute target video positions / timesteps
|
| 517 |
+
target_video_position_start = (
|
| 518 |
+
int(memory_video.shape[1])
|
| 519 |
+
if memory_position_mode == "prefix_continuous" and memory_video is not None
|
| 520 |
+
else 0
|
| 521 |
+
)
|
| 522 |
+
target_video_positions = self._compute_video_positions(
|
| 523 |
+
noisy_image_or_video,
|
| 524 |
+
start_frame=target_video_position_start,
|
| 525 |
+
)
|
| 526 |
+
target_video_timesteps = self._compute_timesteps_for_tokens(
|
| 527 |
+
timestep, num_target_video_tokens, self.video_frame_seqlen
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
memory_seq_len = 0
|
| 531 |
+
if memory_video is not None:
|
| 532 |
+
memory_video_flat = self._flatten_video_latent(memory_video)
|
| 533 |
+
memory_video_positions = self._compute_video_positions(
|
| 534 |
+
memory_video, downscale_factor=memory_downscale_factor
|
| 535 |
+
)
|
| 536 |
+
memory_video_timesteps = torch.zeros(
|
| 537 |
+
B,
|
| 538 |
+
memory_video_flat.shape[1],
|
| 539 |
+
1,
|
| 540 |
+
device=device,
|
| 541 |
+
dtype=target_video_timesteps.dtype,
|
| 542 |
+
)
|
| 543 |
+
video_flat = torch.cat([memory_video_flat, target_video_flat], dim=1)
|
| 544 |
+
video_positions = torch.cat([memory_video_positions, target_video_positions], dim=2)
|
| 545 |
+
video_timesteps = torch.cat([memory_video_timesteps, target_video_timesteps], dim=1)
|
| 546 |
+
memory_seq_len = memory_video_flat.shape[1]
|
| 547 |
+
else:
|
| 548 |
+
video_flat = target_video_flat
|
| 549 |
+
video_positions = target_video_positions
|
| 550 |
+
video_timesteps = target_video_timesteps
|
| 551 |
+
|
| 552 |
+
# Build video modality
|
| 553 |
+
video_sigma = timestep if timestep.dim() == 1 else timestep[:, 0]
|
| 554 |
+
video_modality = Modality(
|
| 555 |
+
latent=video_flat,
|
| 556 |
+
sigma=video_sigma,
|
| 557 |
+
timesteps=video_timesteps,
|
| 558 |
+
positions=video_positions,
|
| 559 |
+
context=conditional_dict["video_context"],
|
| 560 |
+
context_mask=conditional_dict.get("attention_mask"),
|
| 561 |
+
enabled=True,
|
| 562 |
+
)
|
| 563 |
+
|
| 564 |
+
# Build audio modality if provided
|
| 565 |
+
audio_modality = None
|
| 566 |
+
memory_audio_seq_len = 0
|
| 567 |
+
if noisy_audio is None and (memory_audio is not None or memory_audio_timestep is not None):
|
| 568 |
+
raise ValueError("memory_audio requires noisy_audio")
|
| 569 |
+
if noisy_audio is not None:
|
| 570 |
+
target_audio = noisy_audio
|
| 571 |
+
target_audio_frames = target_audio.shape[1]
|
| 572 |
+
|
| 573 |
+
# Use provided audio timestep or derive from video timestep
|
| 574 |
+
if audio_timestep is None:
|
| 575 |
+
# In bidirectional mode, audio uses same sigma as video.
|
| 576 |
+
# video timestep could be [B] or [B, F_v]. For audio we need [B]
|
| 577 |
+
# or [B, F_a]. If timestep is [B, F_v] (per-frame video), take the
|
| 578 |
+
# first frame's sigma since bidirectional uses uniform sigma anyway.
|
| 579 |
+
if timestep.dim() == 1:
|
| 580 |
+
audio_timestep = timestep # [B]
|
| 581 |
+
else:
|
| 582 |
+
# All video frames have same sigma in bidirectional mode,
|
| 583 |
+
# take the first frame's value and broadcast to audio frames
|
| 584 |
+
audio_timestep = timestep[:, 0] # [B]
|
| 585 |
+
|
| 586 |
+
if audio_timestep.dim() == 1:
|
| 587 |
+
target_audio_timestep = audio_timestep[:, None].expand(B, target_audio_frames)
|
| 588 |
+
elif audio_timestep.shape == (B, target_audio_frames):
|
| 589 |
+
target_audio_timestep = audio_timestep
|
| 590 |
+
else:
|
| 591 |
+
raise ValueError(
|
| 592 |
+
"audio_timestep must have shape [B] or [B, F_a], "
|
| 593 |
+
f"got {tuple(audio_timestep.shape)} vs {(B, target_audio_frames)}"
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
if memory_audio_timestep is not None and memory_audio is None:
|
| 597 |
+
raise ValueError("memory_audio_timestep requires memory_audio")
|
| 598 |
+
|
| 599 |
+
if memory_audio is not None:
|
| 600 |
+
memory_audio = memory_audio.to(device=device, dtype=target_audio.dtype)
|
| 601 |
+
memory_audio_seq_len = memory_audio.shape[1]
|
| 602 |
+
if memory_audio_timestep is None:
|
| 603 |
+
prefix_audio_timestep = torch.zeros(
|
| 604 |
+
B,
|
| 605 |
+
memory_audio_seq_len,
|
| 606 |
+
device=device,
|
| 607 |
+
dtype=target_audio_timestep.dtype,
|
| 608 |
+
)
|
| 609 |
+
elif memory_audio_timestep.dim() == 1:
|
| 610 |
+
prefix_audio_timestep = memory_audio_timestep[:, None].expand(B, memory_audio_seq_len)
|
| 611 |
+
elif memory_audio_timestep.shape == (B, memory_audio_seq_len):
|
| 612 |
+
prefix_audio_timestep = memory_audio_timestep
|
| 613 |
+
else:
|
| 614 |
+
raise ValueError(
|
| 615 |
+
"memory_audio_timestep must have shape [B] or [B, F_mem_a], "
|
| 616 |
+
f"got {tuple(memory_audio_timestep.shape)} vs {(B, memory_audio_seq_len)}"
|
| 617 |
+
)
|
| 618 |
+
|
| 619 |
+
noisy_audio = torch.cat([memory_audio, target_audio], dim=1)
|
| 620 |
+
combined_audio_timestep = torch.cat([prefix_audio_timestep, target_audio_timestep], dim=1)
|
| 621 |
+
else:
|
| 622 |
+
noisy_audio = target_audio
|
| 623 |
+
combined_audio_timestep = target_audio_timestep
|
| 624 |
+
|
| 625 |
+
num_audio_tokens = noisy_audio.shape[1]
|
| 626 |
+
audio_timesteps = self._compute_timesteps_for_tokens(combined_audio_timestep, num_audio_tokens, 1)
|
| 627 |
+
if memory_audio_seq_len > 0:
|
| 628 |
+
memory_audio_positions = self._compute_audio_positions(memory_audio)
|
| 629 |
+
target_audio_position_start = memory_audio_seq_len if memory_position_mode == "prefix_continuous" else 0
|
| 630 |
+
target_audio_positions = self._compute_audio_positions(
|
| 631 |
+
target_audio,
|
| 632 |
+
start_frame=target_audio_position_start,
|
| 633 |
+
)
|
| 634 |
+
audio_positions = torch.cat([memory_audio_positions, target_audio_positions], dim=2)
|
| 635 |
+
else:
|
| 636 |
+
audio_positions = self._compute_audio_positions(noisy_audio)
|
| 637 |
+
audio_sigma = target_audio_timestep[:, 0]
|
| 638 |
+
audio_modality = Modality(
|
| 639 |
+
latent=noisy_audio,
|
| 640 |
+
sigma=audio_sigma,
|
| 641 |
+
timesteps=audio_timesteps,
|
| 642 |
+
positions=audio_positions,
|
| 643 |
+
context=conditional_dict.get("audio_context", conditional_dict["video_context"]),
|
| 644 |
+
context_mask=conditional_dict.get("attention_mask"),
|
| 645 |
+
enabled=True,
|
| 646 |
+
v2a_grad_scale=float(v2a_grad_scale),
|
| 647 |
+
)
|
| 648 |
+
|
| 649 |
+
if bool(paired_audio_memory) and memory_seq_len > 0 and audio_modality is not None and memory_audio_seq_len > 0:
|
| 650 |
+
num_memory_slots = int(memory_video.shape[1]) if memory_video is not None else 0
|
| 651 |
+
if num_memory_slots > 0:
|
| 652 |
+
target_audio_seq_len = int(audio_modality.latent.shape[1] - memory_audio_seq_len)
|
| 653 |
+
a2v_pairwise_mask = self._build_paired_memory_cross_mask(
|
| 654 |
+
batch_size=B,
|
| 655 |
+
query_memory_seq_len=memory_seq_len,
|
| 656 |
+
query_target_seq_len=num_target_video_tokens,
|
| 657 |
+
kv_memory_seq_len=memory_audio_seq_len,
|
| 658 |
+
kv_target_seq_len=target_audio_seq_len,
|
| 659 |
+
num_memory_slots=num_memory_slots,
|
| 660 |
+
device=device,
|
| 661 |
+
kv_segment_lengths=memory_audio_segment_lengths,
|
| 662 |
+
)
|
| 663 |
+
v2a_pairwise_mask = self._build_paired_memory_cross_mask(
|
| 664 |
+
batch_size=B,
|
| 665 |
+
query_memory_seq_len=memory_audio_seq_len,
|
| 666 |
+
query_target_seq_len=target_audio_seq_len,
|
| 667 |
+
kv_memory_seq_len=memory_seq_len,
|
| 668 |
+
kv_target_seq_len=num_target_video_tokens,
|
| 669 |
+
num_memory_slots=num_memory_slots,
|
| 670 |
+
device=device,
|
| 671 |
+
query_segment_lengths=memory_audio_segment_lengths,
|
| 672 |
+
)
|
| 673 |
+
video_cross_query_mask = torch.ones(
|
| 674 |
+
B,
|
| 675 |
+
video_modality.latent.shape[1],
|
| 676 |
+
device=device,
|
| 677 |
+
dtype=torch.bool,
|
| 678 |
+
)
|
| 679 |
+
audio_cross_query_mask = torch.ones(
|
| 680 |
+
B,
|
| 681 |
+
audio_modality.latent.shape[1],
|
| 682 |
+
device=device,
|
| 683 |
+
dtype=torch.bool,
|
| 684 |
+
)
|
| 685 |
+
audio_attention_mask = self._build_memory_self_attention_block_mask(
|
| 686 |
+
batch_size=B,
|
| 687 |
+
memory_seq_len=memory_audio_seq_len,
|
| 688 |
+
target_seq_len=target_audio_seq_len,
|
| 689 |
+
device=device,
|
| 690 |
+
)
|
| 691 |
+
video_modality = replace(
|
| 692 |
+
video_modality,
|
| 693 |
+
cross_kv_mask=v2a_pairwise_mask,
|
| 694 |
+
cross_query_mask=video_cross_query_mask,
|
| 695 |
+
late_cross_kv_mask=v2a_pairwise_mask,
|
| 696 |
+
late_cross_query_mask=video_cross_query_mask,
|
| 697 |
+
)
|
| 698 |
+
audio_modality = replace(
|
| 699 |
+
audio_modality,
|
| 700 |
+
attention_mask=audio_attention_mask,
|
| 701 |
+
cross_kv_mask=a2v_pairwise_mask,
|
| 702 |
+
cross_query_mask=audio_cross_query_mask,
|
| 703 |
+
late_cross_kv_mask=a2v_pairwise_mask,
|
| 704 |
+
late_cross_query_mask=audio_cross_query_mask,
|
| 705 |
+
)
|
| 706 |
+
|
| 707 |
+
# Forward through model. The optional perturbation flags let inference
|
| 708 |
+
# freeze one direction of cross-modal interaction without modifying the
|
| 709 |
+
# shared core transformer implementation.
|
| 710 |
+
perturbation_items: list[Perturbation] = []
|
| 711 |
+
if skip_a2v_cross_attn:
|
| 712 |
+
perturbation_items.append(
|
| 713 |
+
Perturbation(
|
| 714 |
+
type=PerturbationType.SKIP_A2V_CROSS_ATTN,
|
| 715 |
+
blocks=None,
|
| 716 |
+
)
|
| 717 |
+
)
|
| 718 |
+
if skip_v2a_cross_attn:
|
| 719 |
+
perturbation_items.append(
|
| 720 |
+
Perturbation(
|
| 721 |
+
type=PerturbationType.SKIP_V2A_CROSS_ATTN,
|
| 722 |
+
blocks=None,
|
| 723 |
+
)
|
| 724 |
+
)
|
| 725 |
+
if skip_video_self_attn:
|
| 726 |
+
perturbation_items.append(
|
| 727 |
+
Perturbation(
|
| 728 |
+
type=PerturbationType.SKIP_VIDEO_SELF_ATTN,
|
| 729 |
+
blocks=None,
|
| 730 |
+
)
|
| 731 |
+
)
|
| 732 |
+
if skip_audio_self_attn:
|
| 733 |
+
perturbation_items.append(
|
| 734 |
+
Perturbation(
|
| 735 |
+
type=PerturbationType.SKIP_AUDIO_SELF_ATTN,
|
| 736 |
+
blocks=None,
|
| 737 |
+
)
|
| 738 |
+
)
|
| 739 |
+
|
| 740 |
+
if perturbation_items:
|
| 741 |
+
perturbation_config = PerturbationConfig(perturbations=perturbation_items)
|
| 742 |
+
perturbations = BatchedPerturbationConfig(
|
| 743 |
+
[perturbation_config for _ in range(B)]
|
| 744 |
+
)
|
| 745 |
+
else:
|
| 746 |
+
perturbations = BatchedPerturbationConfig.empty(batch_size=B)
|
| 747 |
+
|
| 748 |
+
# The model returns x0 predictions (X0Model wraps velocity model)
|
| 749 |
+
video_x0, audio_x0 = self.model(
|
| 750 |
+
video=video_modality,
|
| 751 |
+
audio=audio_modality,
|
| 752 |
+
perturbations=perturbations,
|
| 753 |
+
)
|
| 754 |
+
|
| 755 |
+
# Unflatten video output: [B, T, C] -> [B, F, C, H, W]
|
| 756 |
+
if video_x0 is not None:
|
| 757 |
+
if memory_seq_len > 0:
|
| 758 |
+
video_x0 = video_x0[:, memory_seq_len:, :]
|
| 759 |
+
video_x0 = self._unflatten_video_latent(video_x0, num_video_frames)
|
| 760 |
+
if audio_x0 is not None and memory_audio_seq_len > 0:
|
| 761 |
+
audio_x0 = audio_x0[:, memory_audio_seq_len:, :]
|
| 762 |
+
|
| 763 |
+
return video_x0, audio_x0
|
| 764 |
+
|
| 765 |
+
def load_state_dict(self, state_dict: Dict[str, Any], strict: bool = True) -> None:
|
| 766 |
+
"""Load state dict, handling potential key mismatches."""
|
| 767 |
+
# Remove 'model.' prefix if present
|
| 768 |
+
new_state_dict = {}
|
| 769 |
+
for k, v in state_dict.items():
|
| 770 |
+
if k.startswith("model."):
|
| 771 |
+
new_state_dict[k] = v
|
| 772 |
+
else:
|
| 773 |
+
new_state_dict[f"model.{k}"] = v
|
| 774 |
+
|
| 775 |
+
super().load_state_dict(new_state_dict, strict=strict)
|
| 776 |
+
|
| 777 |
+
|
| 778 |
+
def create_ltx2_wrapper(
|
| 779 |
+
checkpoint_path: str,
|
| 780 |
+
gemma_path: str,
|
| 781 |
+
device: torch.device,
|
| 782 |
+
dtype: torch.dtype = torch.bfloat16,
|
| 783 |
+
video_height: int = 512,
|
| 784 |
+
video_width: int = 768,
|
| 785 |
+
loras: tuple[LoraPathStrengthAndSDOps, ...] = (),
|
| 786 |
+
registry: Registry | None = None,
|
| 787 |
+
quantization=None,
|
| 788 |
+
) -> LTX2DiffusionWrapper:
|
| 789 |
+
"""
|
| 790 |
+
Factory function to create LTX2DiffusionWrapper from checkpoint.
|
| 791 |
+
|
| 792 |
+
Args:
|
| 793 |
+
checkpoint_path: Path to LTX-2 checkpoint
|
| 794 |
+
gemma_path: Path to Gemma text encoder
|
| 795 |
+
device: Target device
|
| 796 |
+
dtype: Model dtype
|
| 797 |
+
video_height: Video height
|
| 798 |
+
video_width: Video width
|
| 799 |
+
|
| 800 |
+
Returns:
|
| 801 |
+
Configured LTX2DiffusionWrapper
|
| 802 |
+
"""
|
| 803 |
+
from ltx_pipelines.utils.model_ledger import ModelLedger
|
| 804 |
+
|
| 805 |
+
# IMPORTANT: Load to CPU first, then move to target device
|
| 806 |
+
# safetensors doesn't support device indices like "cuda:4"
|
| 807 |
+
# It only accepts "cuda" or "cpu"
|
| 808 |
+
ledger = ModelLedger(
|
| 809 |
+
dtype=dtype,
|
| 810 |
+
device=torch.device("cpu"), # Load to CPU first
|
| 811 |
+
checkpoint_path=checkpoint_path,
|
| 812 |
+
gemma_root_path=gemma_path,
|
| 813 |
+
loras=loras,
|
| 814 |
+
registry=registry,
|
| 815 |
+
quantization=quantization,
|
| 816 |
+
)
|
| 817 |
+
|
| 818 |
+
# Get X0Model (wraps velocity model)
|
| 819 |
+
x0_model = ledger.transformer()
|
| 820 |
+
|
| 821 |
+
# Move to target device. A dtype cast here would silently upcast
|
| 822 |
+
# quantized fp8 weights back to bf16, so only move the device when a
|
| 823 |
+
# quantization policy is active.
|
| 824 |
+
if quantization is None:
|
| 825 |
+
x0_model = x0_model.to(device=device, dtype=dtype)
|
| 826 |
+
else:
|
| 827 |
+
x0_model = x0_model.to(device=device)
|
| 828 |
+
|
| 829 |
+
wrapper = LTX2DiffusionWrapper(
|
| 830 |
+
model=x0_model,
|
| 831 |
+
video_height=video_height,
|
| 832 |
+
video_width=video_width,
|
| 833 |
+
)
|
| 834 |
+
|
| 835 |
+
return wrapper
|
libs/ltx_distillation/utils.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared utilities for inference: latent computation, noise, media I/O, video concat."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import subprocess
|
| 6 |
+
import tempfile
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Optional
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
import torchaudio
|
| 12 |
+
try:
|
| 13 |
+
from torchvision.io import write_video
|
| 14 |
+
except ImportError: # torchvision >=0.23 removed video I/O (moved to torchcodec).
|
| 15 |
+
# Only needed when saving a benchmark mp4 to disk; the ComfyUI staged node
|
| 16 |
+
# consumes the returned tensors directly. (Rebels local patch)
|
| 17 |
+
write_video = None
|
| 18 |
+
from torchvision.transforms import functional as TVF
|
| 19 |
+
|
| 20 |
+
from ltx_distillation.inference.memory_multishot import (
|
| 21 |
+
audio_waveform_stats,
|
| 22 |
+
normalize_audio_waveform_for_media,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def compute_latent_shapes(
|
| 27 |
+
*,
|
| 28 |
+
num_frames: int,
|
| 29 |
+
video_height: int,
|
| 30 |
+
video_width: int,
|
| 31 |
+
batch_size: int = 1,
|
| 32 |
+
latent_channels: int = 128,
|
| 33 |
+
vae_temporal_compression: int = 8,
|
| 34 |
+
vae_spatial_compression: int = 32,
|
| 35 |
+
video_fps: float = 24.0,
|
| 36 |
+
audio_sample_rate: int = 16000,
|
| 37 |
+
audio_hop_length: int = 160,
|
| 38 |
+
audio_latent_downsample: int = 4,
|
| 39 |
+
) -> tuple[list[int], list[int]]:
|
| 40 |
+
if (num_frames - 1) % vae_temporal_compression != 0:
|
| 41 |
+
raise ValueError(f"num_frames must be 1 + 8*k, got {num_frames}")
|
| 42 |
+
|
| 43 |
+
latent_frames = 1 + (num_frames - 1) // vae_temporal_compression
|
| 44 |
+
latent_h = video_height // vae_spatial_compression
|
| 45 |
+
latent_w = video_width // vae_spatial_compression
|
| 46 |
+
|
| 47 |
+
video_duration = float(num_frames) / float(video_fps)
|
| 48 |
+
audio_latent_fps = float(audio_sample_rate) / float(audio_hop_length) / float(audio_latent_downsample)
|
| 49 |
+
audio_frames = round(video_duration * audio_latent_fps)
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
[batch_size, latent_frames, latent_channels, latent_h, latent_w],
|
| 53 |
+
[batch_size, audio_frames, latent_channels],
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def add_noise(original: torch.Tensor, noise: torch.Tensor, sigma: torch.Tensor) -> torch.Tensor:
|
| 58 |
+
sigma = sigma.to(device=original.device, dtype=original.dtype)
|
| 59 |
+
if sigma.dim() == 1:
|
| 60 |
+
sigma = sigma.reshape(-1, *[1] * (original.dim() - 1))
|
| 61 |
+
elif sigma.dim() == 2:
|
| 62 |
+
sigma = sigma.reshape(*sigma.shape, *[1] * (original.dim() - 2))
|
| 63 |
+
return (1 - sigma) * original + sigma * noise
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def frames_to_video_tensor(frames, target_h: int, target_w: int) -> torch.Tensor:
|
| 67 |
+
tensors = []
|
| 68 |
+
for idx, image in enumerate(frames):
|
| 69 |
+
if image.size != (target_w, target_h):
|
| 70 |
+
raise ValueError(
|
| 71 |
+
f"Frame size mismatch at index {idx}: got={image.size}, expected={(target_w, target_h)}"
|
| 72 |
+
)
|
| 73 |
+
tensor = TVF.to_tensor(image)
|
| 74 |
+
tensors.append(tensor * 2.0 - 1.0)
|
| 75 |
+
return torch.stack(tensors, dim=1).contiguous()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@torch.no_grad()
|
| 79 |
+
def encode_memory_frames_batch(
|
| 80 |
+
*,
|
| 81 |
+
video_vae,
|
| 82 |
+
batch_memory_frames,
|
| 83 |
+
target_h: int,
|
| 84 |
+
target_w: int,
|
| 85 |
+
device: torch.device,
|
| 86 |
+
dtype: torch.dtype,
|
| 87 |
+
) -> torch.Tensor:
|
| 88 |
+
if getattr(video_vae, "encoder", None) is None:
|
| 89 |
+
raise RuntimeError("video VAE encoder is not initialized for memory encoding")
|
| 90 |
+
|
| 91 |
+
latents = []
|
| 92 |
+
for memory_frames in batch_memory_frames:
|
| 93 |
+
if not memory_frames:
|
| 94 |
+
raise ValueError("memory_frames cannot be empty when encoding memory video")
|
| 95 |
+
per_frame_latents = []
|
| 96 |
+
for memory_item in memory_frames:
|
| 97 |
+
is_clip_memory = isinstance(memory_item, list)
|
| 98 |
+
frame_video = frames_to_video_tensor(
|
| 99 |
+
memory_item if is_clip_memory else [memory_item],
|
| 100 |
+
target_h,
|
| 101 |
+
target_w,
|
| 102 |
+
).unsqueeze(0).to(device=device, dtype=dtype)
|
| 103 |
+
latent = video_vae.encode(frame_video)
|
| 104 |
+
del frame_video
|
| 105 |
+
latent = latent.permute(0, 2, 1, 3, 4).to(dtype=dtype)
|
| 106 |
+
if is_clip_memory:
|
| 107 |
+
latent = latent[:, -1:, :, :, :].contiguous()
|
| 108 |
+
per_frame_latents.append(latent)
|
| 109 |
+
latents.append(torch.cat(per_frame_latents, dim=1))
|
| 110 |
+
del per_frame_latents
|
| 111 |
+
return torch.cat(latents, dim=0)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@torch.no_grad()
|
| 115 |
+
def _decode_video_tiled_uint8(video_vae, video_latent, tiling_config) -> torch.Tensor:
|
| 116 |
+
"""Decode a video latent in temporal/spatial tiles via the official
|
| 117 |
+
tiled_decode path, streaming each chunk to CPU so peak GPU memory stays
|
| 118 |
+
bounded by one tile instead of the full clip."""
|
| 119 |
+
from ltx_core.model.video_vae.video_vae import decode_video
|
| 120 |
+
|
| 121 |
+
decoder = video_vae.decoder
|
| 122 |
+
latent = video_latent
|
| 123 |
+
# Same layout fix as VideoVAEWrapper.decode: DMD stores [B, F, C, H, W].
|
| 124 |
+
if latent.dim() == 5 and latent.shape[2] == 128:
|
| 125 |
+
latent = latent.permute(0, 2, 1, 3, 4)
|
| 126 |
+
dec_param = next(decoder.parameters())
|
| 127 |
+
latent = latent.to(device=dec_param.device, dtype=dec_param.dtype)
|
| 128 |
+
|
| 129 |
+
chunks = []
|
| 130 |
+
for chunk in decode_video(latent, decoder, tiling_config): # yields [f, h, w, c] uint8
|
| 131 |
+
chunks.append(chunk.cpu())
|
| 132 |
+
del chunk
|
| 133 |
+
if torch.cuda.is_available():
|
| 134 |
+
torch.cuda.empty_cache()
|
| 135 |
+
return torch.cat(chunks, dim=0).contiguous()
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@torch.no_grad()
|
| 139 |
+
def decode_benchmark_sample(video_vae, audio_vae, video_latent, audio_latent,
|
| 140 |
+
video_tiling_config=None):
|
| 141 |
+
if video_tiling_config is not None:
|
| 142 |
+
video_uint8 = _decode_video_tiled_uint8(video_vae, video_latent, video_tiling_config)
|
| 143 |
+
else:
|
| 144 |
+
video_pixel = video_vae.decode_to_pixel(video_latent)
|
| 145 |
+
video_uint8 = video_pixel[0]
|
| 146 |
+
if video_uint8.shape[0] == 3:
|
| 147 |
+
video_uint8 = video_uint8.permute(1, 0, 2, 3)
|
| 148 |
+
video_uint8 = video_uint8.permute(0, 2, 3, 1)
|
| 149 |
+
video_uint8 = (video_uint8.clamp(0, 1) * 255).cpu().to(torch.uint8).contiguous()
|
| 150 |
+
|
| 151 |
+
audio_waveform = audio_vae.decode_to_waveform(audio_latent) if audio_latent is not None else None
|
| 152 |
+
audio_float = normalize_audio_waveform_for_media(audio_waveform)
|
| 153 |
+
return video_uint8, audio_float
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def write_benchmark_media(
|
| 157 |
+
*,
|
| 158 |
+
output_path: Path,
|
| 159 |
+
video_uint8: torch.Tensor,
|
| 160 |
+
audio_waveform: Optional[torch.Tensor],
|
| 161 |
+
fps: int,
|
| 162 |
+
audio_sr: int,
|
| 163 |
+
) -> dict[str, Any]:
|
| 164 |
+
if write_video is None:
|
| 165 |
+
raise RuntimeError(
|
| 166 |
+
"torchvision.io.write_video is unavailable (removed in torchvision >=0.23); "
|
| 167 |
+
"write_benchmark_media cannot save an mp4 on this install. The ComfyUI staged "
|
| 168 |
+
"pipeline does not need this function - only standalone benchmark saving does."
|
| 169 |
+
)
|
| 170 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 171 |
+
audio_waveform = normalize_audio_waveform_for_media(audio_waveform)
|
| 172 |
+
stats = audio_waveform_stats(audio_waveform)
|
| 173 |
+
|
| 174 |
+
wrote_with_audio = False
|
| 175 |
+
wrote_sidecar_wav = False
|
| 176 |
+
if audio_waveform is not None:
|
| 177 |
+
try:
|
| 178 |
+
write_video(
|
| 179 |
+
str(output_path),
|
| 180 |
+
video_uint8,
|
| 181 |
+
fps=fps,
|
| 182 |
+
audio_array=audio_waveform,
|
| 183 |
+
audio_fps=audio_sr,
|
| 184 |
+
audio_codec="aac",
|
| 185 |
+
)
|
| 186 |
+
wrote_with_audio = True
|
| 187 |
+
except Exception as exc:
|
| 188 |
+
print(f"[warn] write_video with audio failed for {output_path}: {exc}; audio_stats={stats}", flush=True)
|
| 189 |
+
|
| 190 |
+
if not wrote_with_audio:
|
| 191 |
+
write_video(str(output_path), video_uint8, fps=fps)
|
| 192 |
+
if audio_waveform is not None:
|
| 193 |
+
try:
|
| 194 |
+
torchaudio.save(str(output_path.with_suffix(".wav")), audio_waveform, audio_sr)
|
| 195 |
+
wrote_sidecar_wav = True
|
| 196 |
+
except Exception as exc:
|
| 197 |
+
print(f"[warn] torchaudio.save failed for {output_path}: {exc}; audio_stats={stats}", flush=True)
|
| 198 |
+
|
| 199 |
+
return {
|
| 200 |
+
"wrote_audio_in_mp4": wrote_with_audio,
|
| 201 |
+
"wrote_sidecar_wav": wrote_sidecar_wav,
|
| 202 |
+
"audio_stats": stats,
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def save_memory_bank_frames(memory_frames: list[Any], save_dir: Path) -> None:
|
| 207 |
+
save_dir.mkdir(parents=True, exist_ok=True)
|
| 208 |
+
for old_file in save_dir.glob("*.jpg"):
|
| 209 |
+
old_file.unlink()
|
| 210 |
+
for idx, frame in enumerate(memory_frames):
|
| 211 |
+
if isinstance(frame, list):
|
| 212 |
+
frame = frame[len(frame) // 2]
|
| 213 |
+
frame.convert("RGB").save(save_dir / f"memory_{idx:03d}.jpg")
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def concat_shot_videos(shot_paths: list[Path], output_path: Path) -> None:
|
| 217 |
+
if not shot_paths:
|
| 218 |
+
raise ValueError("No shot videos provided for concatenation")
|
| 219 |
+
|
| 220 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 221 |
+
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") as fp:
|
| 222 |
+
concat_file = Path(fp.name)
|
| 223 |
+
for shot_path in shot_paths:
|
| 224 |
+
fp.write(f"file '{shot_path.resolve().as_posix()}'\n")
|
| 225 |
+
|
| 226 |
+
try:
|
| 227 |
+
cmd = [
|
| 228 |
+
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
| 229 |
+
"-i", str(concat_file), "-c", "copy", str(output_path),
|
| 230 |
+
]
|
| 231 |
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
| 232 |
+
if result.returncode != 0:
|
| 233 |
+
fallback_cmd = [
|
| 234 |
+
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
| 235 |
+
"-i", str(concat_file),
|
| 236 |
+
"-c:v", "libx264", "-preset", "medium", "-crf", "18",
|
| 237 |
+
"-c:a", "aac", "-b:a", "192k",
|
| 238 |
+
str(output_path),
|
| 239 |
+
]
|
| 240 |
+
fallback_result = subprocess.run(fallback_cmd, capture_output=True, text=True)
|
| 241 |
+
if fallback_result.returncode != 0:
|
| 242 |
+
raise RuntimeError(
|
| 243 |
+
"Failed to concatenate shot videos with ffmpeg.\n"
|
| 244 |
+
f"copy stderr:\n{result.stderr}\n"
|
| 245 |
+
f"reencode stderr:\n{fallback_result.stderr}"
|
| 246 |
+
)
|
| 247 |
+
finally:
|
| 248 |
+
concat_file.unlink(missing_ok=True)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def concat_shot_audios(audios: list[torch.Tensor]) -> Optional[torch.Tensor]:
|
| 252 |
+
if not audios:
|
| 253 |
+
return None
|
| 254 |
+
audio = audios[0]
|
| 255 |
+
if audio.ndim == 1:
|
| 256 |
+
sample_dim = 0
|
| 257 |
+
elif audio.ndim == 2:
|
| 258 |
+
sample_dim = 1 if audio.shape[0] <= audio.shape[1] else 0
|
| 259 |
+
else:
|
| 260 |
+
raise ValueError(f"Expected audio tensor with 1 or 2 dims, got shape={tuple(audio.shape)}")
|
| 261 |
+
return torch.cat([a.contiguous() for a in audios], dim=sample_dim).contiguous()
|
nodes.py
ADDED
|
@@ -0,0 +1,1757 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JoyAI-Echo ComfyUI node implementations.
|
| 2 |
+
|
| 3 |
+
Six nodes faithful to the official inference.py:
|
| 4 |
+
1. JoyEcho_ModelLoader — load text encoder + DiT + VAEs (bf16)
|
| 5 |
+
2. JoyEcho_TextEncode — encode prompts, auto-release text encoder
|
| 6 |
+
3. JoyEcho_Generate — multi-shot denoise + decode with memory bank
|
| 7 |
+
4. JoyEcho_SingleShotGenerate — single-shot with per-shot text box and memory chaining
|
| 8 |
+
5. JoyEcho_PromptFormat — get system prompt for LLM-based prompt enhancement
|
| 9 |
+
6. JoyEcho_LLMEnhance — call LLM API to generate shot prompts from a story idea
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import gc
|
| 15 |
+
import json
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
DENOISING_SIGMAS = [1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _empty_cache():
|
| 26 |
+
if torch.cuda.is_available():
|
| 27 |
+
torch.cuda.empty_cache()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _move(module, device):
|
| 31 |
+
if module is not None:
|
| 32 |
+
module.to(device)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class SequentialOffloader:
|
| 36 |
+
"""Layer-by-layer GPU offloading for the DiT transformer blocks.
|
| 37 |
+
|
| 38 |
+
Hooks into each transformer block so that only the currently-executing block
|
| 39 |
+
resides on GPU. All other blocks stay on CPU/pinned memory.
|
| 40 |
+
Peak VRAM for the generator drops from ~30GB to ~2-3GB (1 block + activations).
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def __init__(self, generator, device: torch.device, pin_memory: bool = True):
|
| 44 |
+
self._generator = generator
|
| 45 |
+
self._device = device
|
| 46 |
+
self._hooks: list[torch.utils.hooks.RemovableHook] = []
|
| 47 |
+
self._pin_memory = pin_memory
|
| 48 |
+
self._installed = False
|
| 49 |
+
|
| 50 |
+
def install(self):
|
| 51 |
+
"""Install forward hooks on transformer blocks and move them to CPU."""
|
| 52 |
+
if self._installed:
|
| 53 |
+
return
|
| 54 |
+
self._installed = True
|
| 55 |
+
|
| 56 |
+
velocity_model = self._generator.model.velocity_model
|
| 57 |
+
blocks = velocity_model.transformer_blocks
|
| 58 |
+
|
| 59 |
+
# Keep pre/post processing layers on GPU (small footprint)
|
| 60 |
+
for name, param in velocity_model.named_parameters():
|
| 61 |
+
if "transformer_blocks" not in name:
|
| 62 |
+
param.data = param.data.to(self._device)
|
| 63 |
+
for name, buf in velocity_model.named_buffers():
|
| 64 |
+
if "transformer_blocks" not in name:
|
| 65 |
+
buf.data = buf.data.to(self._device)
|
| 66 |
+
|
| 67 |
+
# Move all blocks to CPU (optionally pinned)
|
| 68 |
+
for block in blocks:
|
| 69 |
+
block.to("cpu")
|
| 70 |
+
if self._pin_memory and torch.cuda.is_available():
|
| 71 |
+
for param in block.parameters():
|
| 72 |
+
param.data = param.data.pin_memory()
|
| 73 |
+
for buf in block.buffers():
|
| 74 |
+
buf.data = buf.data.pin_memory()
|
| 75 |
+
|
| 76 |
+
# Also keep the wrapper's patchifiers and X0Model's non-block params on GPU
|
| 77 |
+
for name, param in self._generator.named_parameters():
|
| 78 |
+
if "velocity_model.transformer_blocks" not in name and "velocity_model" not in name:
|
| 79 |
+
param.data = param.data.to(self._device)
|
| 80 |
+
for name, buf in self._generator.named_buffers():
|
| 81 |
+
if "velocity_model.transformer_blocks" not in name and "velocity_model" not in name:
|
| 82 |
+
buf.data = buf.data.to(self._device)
|
| 83 |
+
|
| 84 |
+
def make_pre_hook(block_module):
|
| 85 |
+
def hook(module, args):
|
| 86 |
+
block_module.to(self._device, non_blocking=True)
|
| 87 |
+
if torch.cuda.is_available():
|
| 88 |
+
torch.cuda.current_stream().synchronize()
|
| 89 |
+
return hook
|
| 90 |
+
|
| 91 |
+
def make_post_hook(block_module):
|
| 92 |
+
def hook(module, args, output):
|
| 93 |
+
block_module.to("cpu", non_blocking=True)
|
| 94 |
+
return hook
|
| 95 |
+
|
| 96 |
+
for block in blocks:
|
| 97 |
+
h1 = block.register_forward_pre_hook(make_pre_hook(block))
|
| 98 |
+
h2 = block.register_forward_hook(make_post_hook(block))
|
| 99 |
+
self._hooks.extend([h1, h2])
|
| 100 |
+
|
| 101 |
+
print(f"[JoyEcho] Sequential offloading installed: {len(blocks)} blocks", flush=True)
|
| 102 |
+
|
| 103 |
+
def remove(self):
|
| 104 |
+
"""Remove all hooks and move entire generator back to CPU."""
|
| 105 |
+
for h in self._hooks:
|
| 106 |
+
h.remove()
|
| 107 |
+
self._hooks.clear()
|
| 108 |
+
self._installed = False
|
| 109 |
+
self._generator.to("cpu")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
_MODEL_FILE_MANUAL = "(use checkpoint_path)"
|
| 113 |
+
_MODEL_FILE_CATS = ("checkpoints", "diffusion_models", "unet")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _list_model_files() -> list:
|
| 117 |
+
"""Every *.safetensors / *.gguf under the ComfyUI model dirs, as
|
| 118 |
+
'category: relative/path' combo entries. Dirs shared between categories
|
| 119 |
+
(unet is an alias of diffusion_models on newer ComfyUI) are deduped."""
|
| 120 |
+
try:
|
| 121 |
+
import folder_paths
|
| 122 |
+
except ImportError:
|
| 123 |
+
return [_MODEL_FILE_MANUAL]
|
| 124 |
+
out, seen_dirs, seen = [], set(), set()
|
| 125 |
+
for cat in _MODEL_FILE_CATS:
|
| 126 |
+
try:
|
| 127 |
+
roots = folder_paths.get_folder_paths(cat)
|
| 128 |
+
except Exception:
|
| 129 |
+
continue
|
| 130 |
+
for root in roots:
|
| 131 |
+
try:
|
| 132 |
+
rp = Path(root).resolve()
|
| 133 |
+
except OSError:
|
| 134 |
+
continue
|
| 135 |
+
if not rp.is_dir() or rp in seen_dirs:
|
| 136 |
+
continue
|
| 137 |
+
seen_dirs.add(rp)
|
| 138 |
+
for ext in ("*.safetensors", "*.gguf"):
|
| 139 |
+
for f in rp.rglob(ext):
|
| 140 |
+
label = f"{cat}: {f.relative_to(rp).as_posix()}"
|
| 141 |
+
if label not in seen:
|
| 142 |
+
seen.add(label)
|
| 143 |
+
out.append(label)
|
| 144 |
+
return [_MODEL_FILE_MANUAL] + sorted(out)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _resolve_model_file(choice: str) -> str:
|
| 148 |
+
import folder_paths
|
| 149 |
+
cat, _, rel = choice.partition(": ")
|
| 150 |
+
if cat in _MODEL_FILE_CATS and rel:
|
| 151 |
+
for root in folder_paths.get_folder_paths(cat):
|
| 152 |
+
p = Path(root) / rel
|
| 153 |
+
if p.is_file():
|
| 154 |
+
return str(p)
|
| 155 |
+
raise FileNotFoundError(
|
| 156 |
+
f"model_file {choice!r} no longer exists on disk. Refresh the node "
|
| 157 |
+
f"list (R) and re-pick, or use {_MODEL_FILE_MANUAL} + checkpoint_path.")
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class JoyEcho_ModelLoader:
|
| 161 |
+
"""Load JoyAI-Echo model components: text encoder, DiT generator, and VAEs."""
|
| 162 |
+
|
| 163 |
+
@classmethod
|
| 164 |
+
def INPUT_TYPES(cls):
|
| 165 |
+
return {
|
| 166 |
+
"required": {
|
| 167 |
+
"checkpoint_path": ("STRING", {
|
| 168 |
+
"default": "",
|
| 169 |
+
"tooltip": "Path to echo-longvideo-release.safetensors",
|
| 170 |
+
}),
|
| 171 |
+
"gemma_path": ("STRING", {
|
| 172 |
+
"default": "",
|
| 173 |
+
"tooltip": "Path to gemma-3-12b-it directory (bf16 safetensors)",
|
| 174 |
+
}),
|
| 175 |
+
},
|
| 176 |
+
"optional": {
|
| 177 |
+
"lora_path": ("STRING", {"default": ""}),
|
| 178 |
+
"lora_strength": ("FLOAT", {
|
| 179 |
+
"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05,
|
| 180 |
+
}),
|
| 181 |
+
"low_vram": ("BOOLEAN", {
|
| 182 |
+
"default": False,
|
| 183 |
+
"tooltip": "Load text encoder on CPU for 24GB GPUs. "
|
| 184 |
+
"Encoding will be slower but uses no GPU memory.",
|
| 185 |
+
}),
|
| 186 |
+
"fp8_transformer": ("BOOLEAN", {
|
| 187 |
+
"default": False,
|
| 188 |
+
"tooltip": "Quantize the DiT's attention/FF linear weights to "
|
| 189 |
+
"float8_e4m3fn at load (upcast per-layer during inference). "
|
| 190 |
+
"Roughly halves transformer weight memory - works from the "
|
| 191 |
+
"normal bf16 checkpoint, keeping JoyAI's memory training and "
|
| 192 |
+
"projection tensors intact. Slight quality cost; VAEs, text "
|
| 193 |
+
"encoder and non-linear layers stay bf16. Ignored when a "
|
| 194 |
+
"GGUF is picked in model_file (already quantized).",
|
| 195 |
+
}),
|
| 196 |
+
"model_file": (_list_model_files(), {
|
| 197 |
+
"default": _MODEL_FILE_MANUAL,
|
| 198 |
+
"tooltip": "Pick the model instead of typing checkpoint_path. "
|
| 199 |
+
"A .safetensors = FULL checkpoint (replaces checkpoint_path "
|
| 200 |
+
"entirely: DiT + VAEs + vocoder + text connectors from that "
|
| 201 |
+
"file). A .gguf = DiT ONLY - checkpoint_path must still point "
|
| 202 |
+
"at a full safetensors (e.g. the JoyAI release) to supply the "
|
| 203 |
+
"VAEs/vocoder/connectors. Refresh the node list (R) after "
|
| 204 |
+
"adding files.",
|
| 205 |
+
}),
|
| 206 |
+
},
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
RETURN_TYPES = ("JOYECHO_MODEL",)
|
| 210 |
+
RETURN_NAMES = ("model",)
|
| 211 |
+
FUNCTION = "load_model"
|
| 212 |
+
CATEGORY = "JoyAI-Echo"
|
| 213 |
+
|
| 214 |
+
def load_model(self, checkpoint_path: str, gemma_path: str,
|
| 215 |
+
lora_path: str = "", lora_strength: float = 1.0,
|
| 216 |
+
low_vram: bool = False, fp8_transformer: bool = False,
|
| 217 |
+
model_file: str = _MODEL_FILE_MANUAL):
|
| 218 |
+
from ltx_core.loader import LTXV_LORA_COMFY_RENAMING_MAP, LoraPathStrengthAndSDOps
|
| 219 |
+
from ltx_core.quantization import QuantizationPolicy
|
| 220 |
+
from ltx_distillation.models.ltx_wrapper import create_ltx2_wrapper
|
| 221 |
+
from ltx_distillation.models.text_encoder_wrapper import create_text_encoder_wrapper
|
| 222 |
+
from ltx_distillation.models.vae_wrapper import create_vae_wrappers
|
| 223 |
+
|
| 224 |
+
gguf_dit_path = None
|
| 225 |
+
if model_file and model_file != _MODEL_FILE_MANUAL:
|
| 226 |
+
_resolved = _resolve_model_file(model_file)
|
| 227 |
+
if _resolved.lower().endswith(".gguf"):
|
| 228 |
+
gguf_dit_path = _resolved
|
| 229 |
+
print(f"[JoyEcho] model_file: DiT from GGUF {_resolved}; VAEs/vocoder/"
|
| 230 |
+
f"connectors from checkpoint_path.", flush=True)
|
| 231 |
+
else:
|
| 232 |
+
checkpoint_path = _resolved
|
| 233 |
+
print(f"[JoyEcho] model_file: full checkpoint {_resolved}.", flush=True)
|
| 234 |
+
|
| 235 |
+
if not str(checkpoint_path).strip():
|
| 236 |
+
raise ValueError(
|
| 237 |
+
"checkpoint_path is empty. It must point at a FULL safetensors checkpoint"
|
| 238 |
+
+ (" - with a GGUF picked in model_file it still supplies the VAEs, "
|
| 239 |
+
"vocoder and text connectors (e.g. echo-longvideo-release.safetensors)."
|
| 240 |
+
if gguf_dit_path else
|
| 241 |
+
" (or pick a .safetensors in model_file)."))
|
| 242 |
+
|
| 243 |
+
checkpoint_path = str(Path(checkpoint_path).expanduser().resolve())
|
| 244 |
+
gemma_path = str(Path(gemma_path).expanduser().resolve())
|
| 245 |
+
|
| 246 |
+
# gemma_path must be the HF gemma-3-12b-it DIRECTORY (model*.safetensors +
|
| 247 |
+
# tokenizer.model), not a GGUF or single file. This loader is the
|
| 248 |
+
# safetensors-native path; a GGUF text encoder only works through the
|
| 249 |
+
# Rebels discrete TextEncoder node (which has the GGUF weight-swap).
|
| 250 |
+
# Fail early with a readable message instead of a deep tokenizer.model
|
| 251 |
+
# traceback repeated once per queued item.
|
| 252 |
+
_gp = Path(gemma_path)
|
| 253 |
+
if _gp.suffix.lower() == ".gguf" or _gp.is_file():
|
| 254 |
+
raise ValueError(
|
| 255 |
+
f"gemma_path points at a file ({_gp.name}). It must be the "
|
| 256 |
+
f"gemma-3-12b-it FOLDER (containing model-0000x-of-*.safetensors "
|
| 257 |
+
f"and tokenizer.model), not a .gguf or single file. A GGUF text "
|
| 258 |
+
f"encoder is only supported by the Rebels discrete TextEncoder node, "
|
| 259 |
+
f"not this loader.")
|
| 260 |
+
if not (_gp / "tokenizer.model").is_file():
|
| 261 |
+
raise ValueError(
|
| 262 |
+
f"gemma_path {_gp} is not a valid Gemma root: no tokenizer.model "
|
| 263 |
+
f"inside. Point it at a full gemma-3-12b-it folder.")
|
| 264 |
+
|
| 265 |
+
device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
|
| 266 |
+
dtype = torch.bfloat16
|
| 267 |
+
|
| 268 |
+
# Load text encoder
|
| 269 |
+
text_encoder_device = torch.device("cpu") if low_vram else device
|
| 270 |
+
print(f"[JoyEcho] Loading text encoder (bf16) on {text_encoder_device}...", flush=True)
|
| 271 |
+
text_encoder = create_text_encoder_wrapper(
|
| 272 |
+
checkpoint_path=checkpoint_path,
|
| 273 |
+
gemma_path=gemma_path,
|
| 274 |
+
device=text_encoder_device,
|
| 275 |
+
dtype=dtype,
|
| 276 |
+
)
|
| 277 |
+
text_encoder.eval()
|
| 278 |
+
|
| 279 |
+
# Load generator
|
| 280 |
+
print("[JoyEcho] Loading DiT generator...", flush=True)
|
| 281 |
+
loras = ()
|
| 282 |
+
if lora_path and lora_path.strip():
|
| 283 |
+
loras = (
|
| 284 |
+
LoraPathStrengthAndSDOps(
|
| 285 |
+
str(Path(lora_path).expanduser()),
|
| 286 |
+
float(lora_strength),
|
| 287 |
+
LTXV_LORA_COMFY_RENAMING_MAP,
|
| 288 |
+
),
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
if gguf_dit_path is not None:
|
| 292 |
+
# DiT from GGUF via the Rebels loader machinery; the wrapper class
|
| 293 |
+
# is identical to create_ltx2_wrapper's, so Generate can't tell.
|
| 294 |
+
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as _Builder
|
| 295 |
+
from ltx_core.model.transformer import LTXModelConfigurator, X0Model
|
| 296 |
+
from ltx_distillation.models.ltx_wrapper import LTX2DiffusionWrapper
|
| 297 |
+
|
| 298 |
+
from .rebels_loaders import (
|
| 299 |
+
_LOADER_CFG,
|
| 300 |
+
_SWAP_MAP,
|
| 301 |
+
_dit_module_ops,
|
| 302 |
+
_full_config,
|
| 303 |
+
_GGUFDiTLoader,
|
| 304 |
+
_gguf_entries,
|
| 305 |
+
_materialize_meta,
|
| 306 |
+
_rebind_swapped,
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
if fp8_transformer:
|
| 310 |
+
print("[JoyEcho] fp8_transformer ignored: GGUF DiT is already quantized.",
|
| 311 |
+
flush=True)
|
| 312 |
+
if loras:
|
| 313 |
+
print("[JoyEcho] WARNING: lora_path is ignored on the GGUF DiT path.",
|
| 314 |
+
flush=True)
|
| 315 |
+
try:
|
| 316 |
+
_cfg = _full_config(checkpoint_path)
|
| 317 |
+
except Exception:
|
| 318 |
+
_cfg = _full_config(_LOADER_CFG)
|
| 319 |
+
|
| 320 |
+
_SWAP_MAP.clear()
|
| 321 |
+
_entries = _gguf_entries(gguf_dit_path)
|
| 322 |
+
_consumed = set()
|
| 323 |
+
_builder = _Builder(
|
| 324 |
+
model_class_configurator=LTXModelConfigurator,
|
| 325 |
+
model_path=gguf_dit_path,
|
| 326 |
+
model_sd_ops=None,
|
| 327 |
+
module_ops=_dit_module_ops(_entries, _consumed, dtype),
|
| 328 |
+
model_loader=_GGUFDiTLoader(_cfg, _entries, _consumed, dtype),
|
| 329 |
+
)
|
| 330 |
+
_transformer = _builder.build(device=torch.device("cpu"), dtype=dtype)
|
| 331 |
+
generator = LTX2DiffusionWrapper(
|
| 332 |
+
model=X0Model(_transformer), video_height=736, video_width=1280)
|
| 333 |
+
generator.eval()
|
| 334 |
+
_materialize_meta(generator, _entries, _consumed, dtype)
|
| 335 |
+
_rebind_swapped(generator)
|
| 336 |
+
_SWAP_MAP.clear()
|
| 337 |
+
else:
|
| 338 |
+
quantization = None
|
| 339 |
+
if fp8_transformer:
|
| 340 |
+
quantization = QuantizationPolicy.fp8_cast()
|
| 341 |
+
print("[JoyEcho] fp8_transformer ON: quantizing DiT linear weights to "
|
| 342 |
+
"float8_e4m3fn (upcast per-layer at inference).", flush=True)
|
| 343 |
+
|
| 344 |
+
generator = create_ltx2_wrapper(
|
| 345 |
+
checkpoint_path=checkpoint_path,
|
| 346 |
+
gemma_path=gemma_path,
|
| 347 |
+
device=torch.device("cpu"),
|
| 348 |
+
dtype=dtype,
|
| 349 |
+
video_height=736,
|
| 350 |
+
video_width=1280,
|
| 351 |
+
loras=loras,
|
| 352 |
+
quantization=quantization,
|
| 353 |
+
)
|
| 354 |
+
generator.eval()
|
| 355 |
+
|
| 356 |
+
# Load VAEs to CPU
|
| 357 |
+
print("[JoyEcho] Loading VAEs...", flush=True)
|
| 358 |
+
video_vae, audio_vae = create_vae_wrappers(
|
| 359 |
+
checkpoint_path=checkpoint_path,
|
| 360 |
+
device=torch.device("cpu"),
|
| 361 |
+
dtype=dtype,
|
| 362 |
+
with_video_encoder=True,
|
| 363 |
+
with_audio_encoder=True,
|
| 364 |
+
decoder_device=torch.device("cpu"),
|
| 365 |
+
)
|
| 366 |
+
video_vae.eval()
|
| 367 |
+
audio_vae.eval()
|
| 368 |
+
|
| 369 |
+
audio_sample_rate = audio_vae.get_output_sample_rate() or 24000
|
| 370 |
+
|
| 371 |
+
model = {
|
| 372 |
+
"text_encoder": text_encoder,
|
| 373 |
+
"generator": generator,
|
| 374 |
+
"video_vae": video_vae,
|
| 375 |
+
"audio_vae": audio_vae,
|
| 376 |
+
"audio_sample_rate": audio_sample_rate,
|
| 377 |
+
"device": device,
|
| 378 |
+
"dtype": dtype,
|
| 379 |
+
"checkpoint_path": checkpoint_path,
|
| 380 |
+
"gemma_path": gemma_path,
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
print(f"[JoyEcho] Model loaded. Audio sample rate: {audio_sample_rate}", flush=True)
|
| 384 |
+
return (model,)
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# Default negative for the DMD (no-CFG) pipeline: steers each shot's conditioning
|
| 388 |
+
# away from these in embedding space. Covers BOTH failure modes seen on the
|
| 389 |
+
# multishot path: burned-in captions/subtitles (video context) and invented
|
| 390 |
+
# sung/musical audio from the Hat Man etc. (audio context). Kept as the FUNCTION
|
| 391 |
+
# default too, so it still fires when a stale graph node lacks the new widget.
|
| 392 |
+
_DEFAULT_JOYECHO_NEGATIVE = (
|
| 393 |
+
"subtitles, captions, closed captions, on-screen text, text characters, glyphs, "
|
| 394 |
+
"letters, words, writing, garbled text, lyrics, signatures, watermark, timestamp, "
|
| 395 |
+
"logo, music, singing, song, humming, melody, chanting, vocalizing, score, "
|
| 396 |
+
"soundtrack, musical, instrumental"
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
# Split per-domain defaults. The encoder emits SEPARATE video_context /
|
| 400 |
+
# audio_context tensors, so each domain gets its own negative text + scale:
|
| 401 |
+
# - video: burned-in captions live here -> can be pushed hard
|
| 402 |
+
# - audio: music lives here, but SPEECH does too ("subtitles" also correlates
|
| 403 |
+
# with speech in training data) -> push gently, music tokens ONLY, no
|
| 404 |
+
# voice-adjacent words (humming/chanting/vocalizing strangle whispers).
|
| 405 |
+
_DEFAULT_JOYECHO_NEGATIVE_VIDEO = (
|
| 406 |
+
"subtitles, captions, closed captions, on-screen text, text characters, glyphs, "
|
| 407 |
+
"letters, words, writing, garbled text, lyrics, signatures, watermark, timestamp, logo"
|
| 408 |
+
)
|
| 409 |
+
_DEFAULT_JOYECHO_NEGATIVE_AUDIO = (
|
| 410 |
+
"music, singing, song, melody, score, soundtrack, musical, instrumental, "
|
| 411 |
+
"background music"
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
class JoyEcho_TextEncode:
|
| 416 |
+
"""Encode text prompts using Gemma-3-12b.
|
| 417 |
+
|
| 418 |
+
Supports:
|
| 419 |
+
- One prompt per line (multi-line text, each line = one shot)
|
| 420 |
+
- JSON format: {"prompts": ["shot1", "shot2", ...]} (official format)
|
| 421 |
+
- JSON file path (*.json)
|
| 422 |
+
|
| 423 |
+
After encoding, the text encoder is released from GPU to free ~24GB VRAM.
|
| 424 |
+
"""
|
| 425 |
+
|
| 426 |
+
@classmethod
|
| 427 |
+
def INPUT_TYPES(cls):
|
| 428 |
+
return {
|
| 429 |
+
"required": {
|
| 430 |
+
"model": ("JOYECHO_MODEL",),
|
| 431 |
+
"prompts": ("STRING", {
|
| 432 |
+
"multiline": True,
|
| 433 |
+
"default": "",
|
| 434 |
+
"tooltip": "One prompt per line, JSON object, or path to .json file",
|
| 435 |
+
}),
|
| 436 |
+
},
|
| 437 |
+
"optional": {
|
| 438 |
+
"negative_prompt_video": ("STRING", {
|
| 439 |
+
"multiline": True,
|
| 440 |
+
"default": _DEFAULT_JOYECHO_NEGATIVE_VIDEO,
|
| 441 |
+
"tooltip": "Steered away from in VIDEO context only (burned-in captions/subtitles/text). Safe to push hard - does not touch the audio lane. Empty or scale 0 disables.",
|
| 442 |
+
}),
|
| 443 |
+
"negative_scale_video": ("FLOAT", {
|
| 444 |
+
"default": 0.8, "min": 0.0, "max": 3.0, "step": 0.05,
|
| 445 |
+
"tooltip": "Video-context steering strength. Renormalized, so higher values no longer degrade the image the way the old shared lever did.",
|
| 446 |
+
}),
|
| 447 |
+
"negative_prompt_audio": ("STRING", {
|
| 448 |
+
"multiline": True,
|
| 449 |
+
"default": _DEFAULT_JOYECHO_NEGATIVE_AUDIO,
|
| 450 |
+
"tooltip": "Steered away from in AUDIO context only. Music tokens ONLY - do NOT add caption words (captions correlate with speech; steering audio away from them kills dialogue). Empty or scale 0 disables.",
|
| 451 |
+
}),
|
| 452 |
+
"negative_scale_audio": ("FLOAT", {
|
| 453 |
+
"default": 0.3, "min": 0.0, "max": 3.0, "step": 0.05,
|
| 454 |
+
"tooltip": "Audio-context steering strength. Keep LOW (~0.2-0.4) or dialogue suffers.",
|
| 455 |
+
}),
|
| 456 |
+
"release_text_encoder": ("BOOLEAN", {"default": True}),
|
| 457 |
+
},
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
RETURN_TYPES = ("JOYECHO_MODEL", "JOYECHO_COND",)
|
| 461 |
+
RETURN_NAMES = ("model", "conditioning",)
|
| 462 |
+
FUNCTION = "encode"
|
| 463 |
+
CATEGORY = "JoyAI-Echo"
|
| 464 |
+
|
| 465 |
+
@staticmethod
|
| 466 |
+
def _parse_prompts(prompts: str) -> list[str]:
|
| 467 |
+
"""Parse prompts from text, JSON string, or JSON file path."""
|
| 468 |
+
text = prompts.strip()
|
| 469 |
+
|
| 470 |
+
# Check if it's a file path to a .json
|
| 471 |
+
if text.endswith(".json") and not text.startswith("{"):
|
| 472 |
+
p = Path(text).expanduser()
|
| 473 |
+
if not p.is_absolute():
|
| 474 |
+
p = Path(__file__).resolve().parent / p
|
| 475 |
+
p = p.resolve()
|
| 476 |
+
if p.exists():
|
| 477 |
+
with open(p, "r", encoding="utf-8") as f:
|
| 478 |
+
data = json.load(f)
|
| 479 |
+
return JoyEcho_TextEncode._extract_from_json(data)
|
| 480 |
+
|
| 481 |
+
# Check if it's a JSON object
|
| 482 |
+
if text.startswith("{"):
|
| 483 |
+
try:
|
| 484 |
+
data = json.loads(text)
|
| 485 |
+
return JoyEcho_TextEncode._extract_from_json(data)
|
| 486 |
+
except json.JSONDecodeError:
|
| 487 |
+
pass
|
| 488 |
+
|
| 489 |
+
# Fall back to one-prompt-per-line
|
| 490 |
+
return [line.strip() for line in text.split("\n") if line.strip()]
|
| 491 |
+
|
| 492 |
+
@staticmethod
|
| 493 |
+
def _extract_from_json(data: dict) -> list[str]:
|
| 494 |
+
"""Extract prompt list from JSON (supports 'prompts' or 'shots' key)."""
|
| 495 |
+
if isinstance(data.get("prompts"), list):
|
| 496 |
+
return [str(p).strip() for p in data["prompts"] if str(p).strip()]
|
| 497 |
+
if isinstance(data.get("shots"), list):
|
| 498 |
+
return [str(p).strip() for p in data["shots"] if str(p).strip()]
|
| 499 |
+
raise ValueError("JSON must contain a 'prompts' or 'shots' array.")
|
| 500 |
+
|
| 501 |
+
def encode(self, model: dict, prompts: str, negative_prompt: str = _DEFAULT_JOYECHO_NEGATIVE,
|
| 502 |
+
negative_scale: float = 0.5, release_text_encoder: bool = True,
|
| 503 |
+
negative_prompt_video: str = None, negative_scale_video: float = None,
|
| 504 |
+
negative_prompt_audio: str = None, negative_scale_audio: float = None):
|
| 505 |
+
text_encoder = model.get("text_encoder")
|
| 506 |
+
if text_encoder is None:
|
| 507 |
+
raise RuntimeError(
|
| 508 |
+
"Text encoder not available. It may have been released already. "
|
| 509 |
+
"Reload the model to encode new prompts."
|
| 510 |
+
)
|
| 511 |
+
|
| 512 |
+
prompt_list = self._parse_prompts(prompts)
|
| 513 |
+
if not prompt_list:
|
| 514 |
+
raise ValueError("No prompts provided. Enter text, JSON, or a .json file path.")
|
| 515 |
+
|
| 516 |
+
device = model["device"]
|
| 517 |
+
print(f"[JoyEcho] Encoding {len(prompt_list)} prompt(s)...", flush=True)
|
| 518 |
+
|
| 519 |
+
# PER-DOMAIN NEGATIVE STEERING: the DMD-distilled pipeline has no CFG, so
|
| 520 |
+
# we extrapolate conditioning away from a negative in embedding space:
|
| 521 |
+
# cond' = cond + scale * (cond - neg) (then renormalized, below)
|
| 522 |
+
# The encoder emits SEPARATE video_context / audio_context tensors, so
|
| 523 |
+
# each domain gets its own negative text and scale:
|
| 524 |
+
# - video_context: burned-in captions live here -> push hard
|
| 525 |
+
# - audio_context: music lives here, but speech does too -> push gently
|
| 526 |
+
# The old SHARED lever coupled the two: raising it past ~0.5 to kill
|
| 527 |
+
# subtitles also strangled dialogue (captions correlate with speech in
|
| 528 |
+
# training data) and drove the audio context off-manifold (the hum).
|
| 529 |
+
# RENORM: raw extrapolation grows the context norm by ~(1+scale); the DiT
|
| 530 |
+
# never saw conditioning at that magnitude -> audio hum / image drift.
|
| 531 |
+
# Restoring the original per-token norm keeps only the DIRECTION change.
|
| 532 |
+
if negative_prompt_video is None and negative_prompt_audio is None:
|
| 533 |
+
# Stale graph still carrying the old single-lever widgets: preserve
|
| 534 |
+
# the old behavior (same negative, same scale, both domains).
|
| 535 |
+
negative_prompt_video = negative_prompt
|
| 536 |
+
negative_prompt_audio = negative_prompt
|
| 537 |
+
negative_scale_video = negative_scale
|
| 538 |
+
negative_scale_audio = negative_scale
|
| 539 |
+
|
| 540 |
+
domains = [] # (context_key, negative_text, scale)
|
| 541 |
+
for key, txt, sc in (("video_context", negative_prompt_video, negative_scale_video),
|
| 542 |
+
("audio_context", negative_prompt_audio, negative_scale_audio)):
|
| 543 |
+
try:
|
| 544 |
+
sc = float(sc)
|
| 545 |
+
except (TypeError, ValueError):
|
| 546 |
+
sc = 0.0
|
| 547 |
+
txt = str(txt).strip() if txt is not None else ""
|
| 548 |
+
if sc > 0.0 and txt:
|
| 549 |
+
domains.append((key, txt, sc))
|
| 550 |
+
|
| 551 |
+
neg_ctx = {} # context_key -> (neg_tensor, scale)
|
| 552 |
+
if domains:
|
| 553 |
+
encoded = {} # one encoder pass per DISTINCT negative text
|
| 554 |
+
for key, txt, sc in domains:
|
| 555 |
+
if txt not in encoded:
|
| 556 |
+
_nc = text_encoder([txt])
|
| 557 |
+
encoded[txt] = {k: (t.detach() if isinstance(t, torch.Tensor) else t)
|
| 558 |
+
for k, t in _nc.items()}
|
| 559 |
+
del _nc
|
| 560 |
+
nv = encoded[txt].get(key)
|
| 561 |
+
if isinstance(nv, torch.Tensor) and nv.is_floating_point():
|
| 562 |
+
neg_ctx[key] = (nv, sc)
|
| 563 |
+
print(f"[JoyEcho] Negative for {key}: scale={sc}.", flush=True)
|
| 564 |
+
|
| 565 |
+
def _steer(v, nv, scale):
|
| 566 |
+
out = v + scale * (v - nv.to(v.device))
|
| 567 |
+
# Norm-preserving rescale (same idea as RescaleCFG): keep the
|
| 568 |
+
# direction change, restore the original per-token magnitude.
|
| 569 |
+
norm_in = v.norm(dim=-1, keepdim=True)
|
| 570 |
+
norm_out = out.norm(dim=-1, keepdim=True).clamp_min(1e-6)
|
| 571 |
+
return out * (norm_in / norm_out)
|
| 572 |
+
|
| 573 |
+
cached_conds = []
|
| 574 |
+
for i, prompt in enumerate(prompt_list):
|
| 575 |
+
cond = text_encoder([prompt])
|
| 576 |
+
if neg_ctx:
|
| 577 |
+
cond = dict(cond)
|
| 578 |
+
for key, (nv, sc) in neg_ctx.items():
|
| 579 |
+
v = cond.get(key)
|
| 580 |
+
if (isinstance(v, torch.Tensor) and v.is_floating_point()
|
| 581 |
+
and v.shape == nv.shape):
|
| 582 |
+
cond[key] = _steer(v, nv, sc)
|
| 583 |
+
elif i == 0:
|
| 584 |
+
print(f"[JoyEcho] WARNING: negative SKIPPED for {key} "
|
| 585 |
+
f"(shape {getattr(v, 'shape', None)} vs {tuple(nv.shape)}).",
|
| 586 |
+
flush=True)
|
| 587 |
+
if i == 0:
|
| 588 |
+
applied = ", ".join(f"{k}@{sc}" for k, (_, sc) in neg_ctx.items())
|
| 589 |
+
print(f"[JoyEcho] negative applied per-domain: {applied}.", flush=True)
|
| 590 |
+
cached_conds.append(
|
| 591 |
+
{k: (v.detach().cpu() if isinstance(v, torch.Tensor) else v)
|
| 592 |
+
for k, v in cond.items()}
|
| 593 |
+
)
|
| 594 |
+
del cond
|
| 595 |
+
print(f"[JoyEcho] Encoded shot {i+1}/{len(prompt_list)}", flush=True)
|
| 596 |
+
|
| 597 |
+
if neg_ctx:
|
| 598 |
+
neg_ctx.clear()
|
| 599 |
+
|
| 600 |
+
if release_text_encoder:
|
| 601 |
+
print("[JoyEcho] Releasing text encoder to free VRAM...", flush=True)
|
| 602 |
+
del text_encoder
|
| 603 |
+
model["text_encoder"] = None
|
| 604 |
+
gc.collect()
|
| 605 |
+
_empty_cache()
|
| 606 |
+
|
| 607 |
+
return (model, cached_conds,)
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
class JoyEcho_Generate:
|
| 611 |
+
"""Generate multi-shot video + audio using DMD few-step denoising with memory bank.
|
| 612 |
+
|
| 613 |
+
Implements the same hot-swap memory management as official inference.py:
|
| 614 |
+
- Denoise phase: generator on GPU, VAE on CPU
|
| 615 |
+
- Decode phase: generator on CPU, VAE on GPU
|
| 616 |
+
"""
|
| 617 |
+
|
| 618 |
+
@classmethod
|
| 619 |
+
def INPUT_TYPES(cls):
|
| 620 |
+
return {
|
| 621 |
+
"required": {
|
| 622 |
+
"model": ("JOYECHO_MODEL",),
|
| 623 |
+
"conditioning": ("JOYECHO_COND",),
|
| 624 |
+
"seed": ("INT", {"default": 12345, "min": 0, "max": 2**31 - 1}),
|
| 625 |
+
"num_frames": ("INT", {"default": 241, "min": 9, "max": 481, "step": 8,
|
| 626 |
+
"tooltip": "Must be 1 + 8*k (e.g. 121, 241, 361)"}),
|
| 627 |
+
"video_height": ("INT", {"default": 736, "min": 256, "max": 1088, "step": 32}),
|
| 628 |
+
"video_width": ("INT", {"default": 1280, "min": 256, "max": 1920, "step": 32}),
|
| 629 |
+
},
|
| 630 |
+
"optional": {
|
| 631 |
+
"video_fps": ("INT", {"default": 25, "min": 1, "max": 60}),
|
| 632 |
+
"v2a_grad_scale": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 10.0, "step": 0.1}),
|
| 633 |
+
"memory_max_size": ("INT", {"default": 7, "min": 0, "max": 20}),
|
| 634 |
+
"num_fix_frames": ("INT", {"default": 3, "min": 0, "max": 10}),
|
| 635 |
+
"enable_audio_memory": ("BOOLEAN", {"default": True}),
|
| 636 |
+
"audio_memory_window_size": ("INT", {"default": 96, "min": 16, "max": 256}),
|
| 637 |
+
"sequential_offload": ("BOOLEAN", {
|
| 638 |
+
"default": False,
|
| 639 |
+
"tooltip": "Enable layer-by-layer GPU offloading for DiT. "
|
| 640 |
+
"Reduces VRAM from ~30GB to ~3GB at the cost of slower inference.",
|
| 641 |
+
}),
|
| 642 |
+
"output_prefix": ("STRING", {
|
| 643 |
+
"default": "joyecho/shot",
|
| 644 |
+
"tooltip": "Prefix for per-shot video files saved immediately after each shot completes.",
|
| 645 |
+
}),
|
| 646 |
+
"reference_image": ("IMAGE", {
|
| 647 |
+
"tooltip": "Optional identity reference (e.g. a Z-Image render). Pre-seeds the "
|
| 648 |
+
"cross-shot memory bank as a permanent anchor slot, so every shot is "
|
| 649 |
+
"conditioned on this face/look - reference-driven I2V. Uses one of the "
|
| 650 |
+
"num_fix_frames anchor slots.",
|
| 651 |
+
}),
|
| 652 |
+
"transition": (["cut", "dissolve", "vhs_glitch"], {
|
| 653 |
+
"default": "cut",
|
| 654 |
+
"tooltip": "Shot-boundary treatment. cut = hard cuts (original). dissolve = "
|
| 655 |
+
"overlap cross-dissolve + equal-power audio crossfade (shortens total "
|
| 656 |
+
"by transition_frames per boundary). vhs_glitch = analog static burst "
|
| 657 |
+
"at each cut: snow, tearing bands, dropout lines + a tape-noise audio "
|
| 658 |
+
"hit (length unchanged).",
|
| 659 |
+
}),
|
| 660 |
+
"transition_frames": ("INT", {
|
| 661 |
+
"default": 8, "min": 1, "max": 48,
|
| 662 |
+
"tooltip": "Length of the transition in frames. Dissolve: 8-12 is natural. "
|
| 663 |
+
"VHS glitch: 3-6 reads as a head-switch stutter, 8-12 as a violent burst.",
|
| 664 |
+
}),
|
| 665 |
+
"glitch_intensity": ("FLOAT", {
|
| 666 |
+
"default": 0.7, "min": 0.1, "max": 1.0, "step": 0.05,
|
| 667 |
+
"tooltip": "vhs_glitch only: how hard the burst hits (snow mix, tear count, "
|
| 668 |
+
"audio static level).",
|
| 669 |
+
}),
|
| 670 |
+
"head_trim_frames": ("INT", {
|
| 671 |
+
"default": 0, "min": 0, "max": 24,
|
| 672 |
+
"tooltip": "Trim this many frames (plus matching audio) from the START of every "
|
| 673 |
+
"shot. The model's first frames morph out of the reference/memory "
|
| 674 |
+
"content - a split-second flash of the reference image. 0 = auto: "
|
| 675 |
+
"trims 8 when a reference_image is wired, none otherwise.",
|
| 676 |
+
}),
|
| 677 |
+
"decode_tiling": (["auto", "on", "off"], {
|
| 678 |
+
"default": "auto",
|
| 679 |
+
"tooltip": "Temporal-chunked VAE decode (64-frame chunks, 24-frame blended "
|
| 680 |
+
"overlap, no spatial tiles = no spatial seams). Caps decode peak "
|
| 681 |
+
"memory at ~one chunk instead of the whole shot - fixes the hard "
|
| 682 |
+
"crash decoding 241f at 1280x736. auto = only when "
|
| 683 |
+
"height*width*frames exceeds the known-safe budget; small renders "
|
| 684 |
+
"keep the original single-pass decode.",
|
| 685 |
+
}),
|
| 686 |
+
},
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
RETURN_TYPES = ("IMAGE", "AUDIO",)
|
| 690 |
+
RETURN_NAMES = ("images", "audio",)
|
| 691 |
+
FUNCTION = "generate"
|
| 692 |
+
CATEGORY = "JoyAI-Echo"
|
| 693 |
+
OUTPUT_NODE = True
|
| 694 |
+
|
| 695 |
+
def generate(
|
| 696 |
+
self,
|
| 697 |
+
model: dict,
|
| 698 |
+
conditioning: list,
|
| 699 |
+
seed: int = 12345,
|
| 700 |
+
num_frames: int = 241,
|
| 701 |
+
video_height: int = 736,
|
| 702 |
+
video_width: int = 1280,
|
| 703 |
+
video_fps: int = 25,
|
| 704 |
+
v2a_grad_scale: float = 2.0,
|
| 705 |
+
memory_max_size: int = 7,
|
| 706 |
+
num_fix_frames: int = 3,
|
| 707 |
+
enable_audio_memory: bool = True,
|
| 708 |
+
audio_memory_window_size: int = 96,
|
| 709 |
+
sequential_offload: bool = False,
|
| 710 |
+
output_prefix: str = "joyecho/shot",
|
| 711 |
+
reference_image=None,
|
| 712 |
+
transition: str = "cut",
|
| 713 |
+
transition_frames: int = 8,
|
| 714 |
+
glitch_intensity: float = 0.7,
|
| 715 |
+
head_trim_frames: int = 0,
|
| 716 |
+
decode_tiling: str = "auto",
|
| 717 |
+
):
|
| 718 |
+
from ltx_distillation.inference.bidirectional_pipeline import BidirectionalAVInferencePipeline
|
| 719 |
+
from ltx_distillation.inference.memory_bidirectional_pipeline import BidirectionalMemoryAVInferencePipeline
|
| 720 |
+
from ltx_distillation.inference.memory_multishot import (
|
| 721 |
+
PairedAudioVideoMemoryBank,
|
| 722 |
+
build_paired_audio_memory_kwargs,
|
| 723 |
+
video_uint8_to_pil_frames,
|
| 724 |
+
)
|
| 725 |
+
from ltx_distillation.utils import (
|
| 726 |
+
add_noise,
|
| 727 |
+
compute_latent_shapes,
|
| 728 |
+
decode_benchmark_sample,
|
| 729 |
+
encode_memory_frames_batch,
|
| 730 |
+
)
|
| 731 |
+
|
| 732 |
+
generator = model["generator"]
|
| 733 |
+
video_vae = model["video_vae"]
|
| 734 |
+
audio_vae = model["audio_vae"]
|
| 735 |
+
audio_sample_rate = model["audio_sample_rate"]
|
| 736 |
+
device = model["device"]
|
| 737 |
+
dtype = model["dtype"]
|
| 738 |
+
|
| 739 |
+
# Validate num_frames
|
| 740 |
+
if (num_frames - 1) % 8 != 0:
|
| 741 |
+
num_frames = 1 + ((num_frames - 1) // 8) * 8
|
| 742 |
+
print(f"[JoyEcho] Adjusted num_frames to {num_frames} (must be 1 + 8*k)", flush=True)
|
| 743 |
+
|
| 744 |
+
# Update generator resolution if changed
|
| 745 |
+
generator.video_height = video_height
|
| 746 |
+
generator.video_width = video_width
|
| 747 |
+
generator.latent_height = video_height // 32
|
| 748 |
+
generator.latent_width = video_width // 32
|
| 749 |
+
generator.video_frame_seqlen = generator.latent_height * generator.latent_width
|
| 750 |
+
|
| 751 |
+
# Compute latent shapes
|
| 752 |
+
video_shape, audio_shape = compute_latent_shapes(
|
| 753 |
+
num_frames=num_frames,
|
| 754 |
+
video_height=video_height,
|
| 755 |
+
video_width=video_width,
|
| 756 |
+
batch_size=1,
|
| 757 |
+
video_fps=float(video_fps),
|
| 758 |
+
)
|
| 759 |
+
|
| 760 |
+
# Build pipelines
|
| 761 |
+
denoising_sigmas = torch.tensor(DENOISING_SIGMAS, device=device, dtype=torch.float32)
|
| 762 |
+
base_pipeline = BidirectionalAVInferencePipeline(
|
| 763 |
+
generator=generator,
|
| 764 |
+
add_noise_fn=add_noise,
|
| 765 |
+
denoising_sigmas=denoising_sigmas,
|
| 766 |
+
)
|
| 767 |
+
memory_pipeline = BidirectionalMemoryAVInferencePipeline(
|
| 768 |
+
generator=generator,
|
| 769 |
+
add_noise_fn=add_noise,
|
| 770 |
+
denoising_sigmas=denoising_sigmas,
|
| 771 |
+
memory_downscale_factor=1,
|
| 772 |
+
)
|
| 773 |
+
|
| 774 |
+
# Memory bank
|
| 775 |
+
memory_bank = PairedAudioVideoMemoryBank(
|
| 776 |
+
max_size=memory_max_size,
|
| 777 |
+
save_mode="random_every_shot_frame",
|
| 778 |
+
num_fix_frames=num_fix_frames,
|
| 779 |
+
)
|
| 780 |
+
|
| 781 |
+
# REFERENCE CONDITIONING (I2V-as-reference): references are VIDEO-ONLY
|
| 782 |
+
# conditioning clips kept OUT of the paired audio-video memory bank.
|
| 783 |
+
# (The earlier design seeded them into the bank with zero-filled audio
|
| 784 |
+
# latents; with 2+ refs those silent paired slots audibly polluted the
|
| 785 |
+
# audio lane. The bank now holds only real generated shots; refs are
|
| 786 |
+
# prepended at the video-memory encode step, invisible to all audio
|
| 787 |
+
# machinery, and persist for the whole run.)
|
| 788 |
+
_ref_clips = []
|
| 789 |
+
if reference_image is not None and memory_max_size > 0:
|
| 790 |
+
import numpy as np
|
| 791 |
+
from PIL import Image as _PILImage
|
| 792 |
+
# Dedupe identical frames in the batch (wiring quirks can deliver
|
| 793 |
+
# the same picked image 2-3x).
|
| 794 |
+
_uniq_idx = []
|
| 795 |
+
_seen = []
|
| 796 |
+
for _i in range(int(reference_image.shape[0])):
|
| 797 |
+
_t = reference_image[_i]
|
| 798 |
+
if not any(_t.shape == _u.shape and torch.equal(_t, _u) for _u in _seen):
|
| 799 |
+
_seen.append(_t)
|
| 800 |
+
_uniq_idx.append(_i)
|
| 801 |
+
if len(_uniq_idx) < int(reference_image.shape[0]):
|
| 802 |
+
print(f"[JoyEcho] Reference batch: {int(reference_image.shape[0])} images, "
|
| 803 |
+
f"{len(_uniq_idx)} unique after dedupe.", flush=True)
|
| 804 |
+
_tw, _th = int(video_width), int(video_height)
|
| 805 |
+
for _ri in _uniq_idx[:4]:
|
| 806 |
+
_arr = reference_image[_ri].detach().cpu().numpy()
|
| 807 |
+
_arr = (np.clip(_arr, 0.0, 1.0) * 255.0).astype(np.uint8)
|
| 808 |
+
_ref_pil = _PILImage.fromarray(_arr)
|
| 809 |
+
# The memory encoder requires frames at EXACTLY the render size
|
| 810 |
+
# (frames_to_video_tensor raises on mismatch). Cover-fit: scale
|
| 811 |
+
# to fill, then crop with a mild top bias so faces survive.
|
| 812 |
+
if _ref_pil.size != (_tw, _th):
|
| 813 |
+
_scale = max(_tw / _ref_pil.width, _th / _ref_pil.height)
|
| 814 |
+
_rw, _rh = max(_tw, int(round(_ref_pil.width * _scale))), max(_th, int(round(_ref_pil.height * _scale)))
|
| 815 |
+
_ref_pil = _ref_pil.resize((_rw, _rh), _PILImage.LANCZOS)
|
| 816 |
+
_left = (_rw - _tw) // 2
|
| 817 |
+
_top = int((_rh - _th) * 0.25) # bias crop toward the top (faces)
|
| 818 |
+
_ref_pil = _ref_pil.crop((_left, _top, _left + _tw, _top + _th))
|
| 819 |
+
_ref_clips.append([_ref_pil] * 9)
|
| 820 |
+
print(f"[JoyEcho] {len(_ref_clips)} reference image(s) prepared as VIDEO-ONLY "
|
| 821 |
+
f"conditioning clips ({_tw}x{_th}); audio lane untouched.", flush=True)
|
| 822 |
+
|
| 823 |
+
all_video_frames = []
|
| 824 |
+
all_audio_waveforms = []
|
| 825 |
+
|
| 826 |
+
num_shots = len(conditioning)
|
| 827 |
+
offloader = None
|
| 828 |
+
if sequential_offload:
|
| 829 |
+
offloader = SequentialOffloader(generator, device)
|
| 830 |
+
|
| 831 |
+
# Temporal-chunked VAE decode: 241f at 1280x736 decoded in ONE pass
|
| 832 |
+
# hard-crashes a 32GB card (cuDNN abort mid-conv); 361f at 544x960
|
| 833 |
+
# (~189M pixels*frames) is render-proven safe, so auto kicks in just
|
| 834 |
+
# above that. Temporal-only tiling = no spatial seams; 24-frame
|
| 835 |
+
# blended overlap.
|
| 836 |
+
_decode_tiling_config = None
|
| 837 |
+
if decode_tiling == "on" or (
|
| 838 |
+
decode_tiling == "auto"
|
| 839 |
+
and video_height * video_width * num_frames > 195_000_000
|
| 840 |
+
):
|
| 841 |
+
from ltx_core.model.video_vae import TemporalTilingConfig, TilingConfig
|
| 842 |
+
_decode_tiling_config = TilingConfig(
|
| 843 |
+
spatial_config=None,
|
| 844 |
+
temporal_config=TemporalTilingConfig(
|
| 845 |
+
tile_size_in_frames=64, tile_overlap_in_frames=24),
|
| 846 |
+
)
|
| 847 |
+
print("[JoyEcho] Tiled VAE decode ON (temporal 64f chunks, 24f overlap).",
|
| 848 |
+
flush=True)
|
| 849 |
+
|
| 850 |
+
print(f"[JoyEcho] Generating {num_shots} shot(s) at {video_width}x{video_height}, "
|
| 851 |
+
f"{num_frames} frames{' [sequential offload]' if sequential_offload else ''}...",
|
| 852 |
+
flush=True)
|
| 853 |
+
|
| 854 |
+
for shot_idx in range(num_shots):
|
| 855 |
+
prompt_seed = seed + shot_idx
|
| 856 |
+
conditional_dict = {
|
| 857 |
+
k: (v.to(device) if isinstance(v, torch.Tensor) else v)
|
| 858 |
+
for k, v in conditioning[shot_idx].items()
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
print(f"[JoyEcho] Shot {shot_idx+1}/{num_shots}, seed={prompt_seed}, "
|
| 862 |
+
f"memory_size={len(memory_bank)}", flush=True)
|
| 863 |
+
|
| 864 |
+
# --- Phase A: Denoise (generator on GPU, VAE on CPU) ---
|
| 865 |
+
_move(video_vae.encoder, "cpu")
|
| 866 |
+
_move(video_vae.decoder, "cpu")
|
| 867 |
+
_move(audio_vae.encoder, "cpu")
|
| 868 |
+
_move(audio_vae.decoder, "cpu")
|
| 869 |
+
_move(audio_vae.vocoder, "cpu")
|
| 870 |
+
if sequential_offload:
|
| 871 |
+
offloader.install()
|
| 872 |
+
else:
|
| 873 |
+
_move(generator, device)
|
| 874 |
+
_empty_cache()
|
| 875 |
+
|
| 876 |
+
with torch.random.fork_rng(devices=[device] if device.type == "cuda" else []):
|
| 877 |
+
torch.manual_seed(prompt_seed)
|
| 878 |
+
if device.type == "cuda":
|
| 879 |
+
torch.cuda.manual_seed(prompt_seed)
|
| 880 |
+
|
| 881 |
+
if _ref_clips or len(memory_bank) > 0:
|
| 882 |
+
# Encode memory frames (briefly bring video encoder to GPU).
|
| 883 |
+
# References prepend as pure video conditioning; the bank
|
| 884 |
+
# contributes only real generated shots.
|
| 885 |
+
_mem_frames = list(_ref_clips) + (memory_bank.get_memory_frames()
|
| 886 |
+
if len(memory_bank) > 0 else [])
|
| 887 |
+
_move(video_vae.encoder, device)
|
| 888 |
+
memory_video = encode_memory_frames_batch(
|
| 889 |
+
video_vae=video_vae,
|
| 890 |
+
batch_memory_frames=[_mem_frames],
|
| 891 |
+
target_h=video_height,
|
| 892 |
+
target_w=video_width,
|
| 893 |
+
device=device,
|
| 894 |
+
dtype=dtype,
|
| 895 |
+
)
|
| 896 |
+
_move(video_vae.encoder, "cpu")
|
| 897 |
+
_empty_cache()
|
| 898 |
+
|
| 899 |
+
# Audio memory kwargs come from the BANK ONLY (never refs);
|
| 900 |
+
# an empty bank means no audio-memory kwargs at all.
|
| 901 |
+
memory_audio_kwargs = {}
|
| 902 |
+
if len(memory_bank) > 0:
|
| 903 |
+
memory_audio_kwargs = build_paired_audio_memory_kwargs(
|
| 904 |
+
memory_bank,
|
| 905 |
+
enable_audio_memory=enable_audio_memory,
|
| 906 |
+
v2a_grad_scale=v2a_grad_scale,
|
| 907 |
+
memory_position_mode="reference",
|
| 908 |
+
)
|
| 909 |
+
if _ref_clips and memory_audio_kwargs:
|
| 910 |
+
print("[JoyEcho] WARNING: reference clips + enable_audio_memory=True gives "
|
| 911 |
+
f"{len(_mem_frames)} video slots vs {len(memory_bank)} audio slots; "
|
| 912 |
+
"if slot pairing errors, set enable_audio_memory=False.", flush=True)
|
| 913 |
+
|
| 914 |
+
video_latent, audio_latent = memory_pipeline.generate(
|
| 915 |
+
video_shape=tuple(video_shape),
|
| 916 |
+
audio_shape=tuple(audio_shape),
|
| 917 |
+
conditional_dict=conditional_dict,
|
| 918 |
+
memory_video=memory_video,
|
| 919 |
+
seed=prompt_seed,
|
| 920 |
+
**memory_audio_kwargs,
|
| 921 |
+
)
|
| 922 |
+
del memory_video
|
| 923 |
+
else:
|
| 924 |
+
video_latent, audio_latent = base_pipeline.generate(
|
| 925 |
+
video_shape=tuple(video_shape),
|
| 926 |
+
audio_shape=tuple(audio_shape),
|
| 927 |
+
conditional_dict=conditional_dict,
|
| 928 |
+
seed=prompt_seed,
|
| 929 |
+
)
|
| 930 |
+
|
| 931 |
+
if device.type == "cuda":
|
| 932 |
+
torch.cuda.synchronize()
|
| 933 |
+
|
| 934 |
+
del conditional_dict
|
| 935 |
+
_empty_cache()
|
| 936 |
+
|
| 937 |
+
# Save audio latent for memory before decode moves things around.
|
| 938 |
+
# NOTE: storage is deliberately NOT gated on enable_audio_memory —
|
| 939 |
+
# the paired bank needs an audio slot to save the VIDEO slot, and
|
| 940 |
+
# skipping the save silently disabled ALL cross-shot identity memory
|
| 941 |
+
# whenever audio memory was off (diagnosed 2026-07-14: memory_size=0
|
| 942 |
+
# every shot). enable_audio_memory still gates the INJECTION path
|
| 943 |
+
# (build_paired_audio_memory_kwargs), which is where the wrong-rate
|
| 944 |
+
# drone bug lived, so storing here reintroduces no audio artifacts.
|
| 945 |
+
audio_memory_latent = (
|
| 946 |
+
audio_latent.detach().cpu().contiguous()
|
| 947 |
+
if audio_latent is not None
|
| 948 |
+
else None
|
| 949 |
+
)
|
| 950 |
+
|
| 951 |
+
# --- Phase B: Decode (generator off GPU, VAE on GPU) ---
|
| 952 |
+
if sequential_offload:
|
| 953 |
+
offloader.remove()
|
| 954 |
+
_move(generator, "cpu")
|
| 955 |
+
_empty_cache()
|
| 956 |
+
_move(video_vae.decoder, device)
|
| 957 |
+
_move(audio_vae.decoder, device)
|
| 958 |
+
_move(audio_vae.vocoder, device)
|
| 959 |
+
|
| 960 |
+
video_uint8, audio_waveform = decode_benchmark_sample(
|
| 961 |
+
video_vae, audio_vae, video_latent, audio_latent,
|
| 962 |
+
video_tiling_config=_decode_tiling_config,
|
| 963 |
+
)
|
| 964 |
+
|
| 965 |
+
if device.type == "cuda":
|
| 966 |
+
torch.cuda.synchronize()
|
| 967 |
+
|
| 968 |
+
# Move VAE back to CPU
|
| 969 |
+
_move(video_vae.decoder, "cpu")
|
| 970 |
+
_move(audio_vae.decoder, "cpu")
|
| 971 |
+
_move(audio_vae.vocoder, "cpu")
|
| 972 |
+
_empty_cache()
|
| 973 |
+
|
| 974 |
+
# Update memory bank
|
| 975 |
+
memory_frames_pil = video_uint8_to_pil_frames(video_uint8)
|
| 976 |
+
if audio_memory_latent is not None:
|
| 977 |
+
memory_bank.save_memory_slot(
|
| 978 |
+
memory_frames_pil,
|
| 979 |
+
audio_memory_latent,
|
| 980 |
+
audio_window_size=audio_memory_window_size,
|
| 981 |
+
video_clip_num_frames=9,
|
| 982 |
+
audio_waveform=audio_waveform,
|
| 983 |
+
audio_sample_rate=16000,
|
| 984 |
+
video_fps=float(video_fps),
|
| 985 |
+
audio_window_selection_mode="max_response",
|
| 986 |
+
video_frame_selection_mode="center",
|
| 987 |
+
audio_memory_mel_bins=128,
|
| 988 |
+
audio_memory_mel_hop_length=160,
|
| 989 |
+
audio_memory_n_fft=1024,
|
| 990 |
+
audio_memory_downsample_factor=4,
|
| 991 |
+
audio_memory_is_causal=True,
|
| 992 |
+
)
|
| 993 |
+
|
| 994 |
+
# Collect outputs
|
| 995 |
+
# video_uint8: [F, H, W, 3] uint8 -> [F, H, W, 3] float32 [0, 1]
|
| 996 |
+
video_float = video_uint8.float() / 255.0
|
| 997 |
+
|
| 998 |
+
# HEAD TRIM: each shot's first frames morph out of the memory /
|
| 999 |
+
# reference content (a split-second flash of the reference image).
|
| 1000 |
+
# Trim them, with matching audio samples so A/V stay aligned.
|
| 1001 |
+
_trim = max(0, int(head_trim_frames))
|
| 1002 |
+
if _trim == 0 and _ref_clips:
|
| 1003 |
+
_trim = 8 # auto when references are wired
|
| 1004 |
+
if _trim > 0 and video_float.shape[0] > _trim + 16:
|
| 1005 |
+
video_float = video_float[_trim:]
|
| 1006 |
+
else:
|
| 1007 |
+
_trim = 0
|
| 1008 |
+
all_video_frames.append(video_float)
|
| 1009 |
+
|
| 1010 |
+
if audio_waveform is not None:
|
| 1011 |
+
from ltx_distillation.inference.memory_multishot import normalize_audio_waveform_for_media
|
| 1012 |
+
audio_norm = normalize_audio_waveform_for_media(audio_waveform)
|
| 1013 |
+
if _trim > 0:
|
| 1014 |
+
_cut = int(round(_trim / float(video_fps) * audio_sample_rate))
|
| 1015 |
+
if audio_norm.shape[-1] > _cut:
|
| 1016 |
+
audio_norm = audio_norm[..., _cut:]
|
| 1017 |
+
all_audio_waveforms.append(audio_norm)
|
| 1018 |
+
|
| 1019 |
+
# Save per-shot video immediately for real-time preview
|
| 1020 |
+
self._save_shot_video(
|
| 1021 |
+
video_uint8, audio_waveform, shot_idx,
|
| 1022 |
+
video_fps, audio_sample_rate, output_prefix
|
| 1023 |
+
)
|
| 1024 |
+
|
| 1025 |
+
del video_latent, audio_latent, audio_memory_latent, video_uint8, audio_waveform
|
| 1026 |
+
_empty_cache()
|
| 1027 |
+
|
| 1028 |
+
print(f"[JoyEcho] Shot {shot_idx+1}/{num_shots} done.", flush=True)
|
| 1029 |
+
|
| 1030 |
+
# Concatenate all shots with the selected boundary treatment.
|
| 1031 |
+
xf = max(1, int(transition_frames)) if transition == "dissolve" else 0
|
| 1032 |
+
paired_audio = bool(all_audio_waveforms) and len(all_audio_waveforms) == len(all_video_frames)
|
| 1033 |
+
if xf > 0 and len(all_video_frames) > 1:
|
| 1034 |
+
vids = all_video_frames
|
| 1035 |
+
auds = all_audio_waveforms if paired_audio else None
|
| 1036 |
+
out_v = vids[0]
|
| 1037 |
+
out_a = auds[0] if auds else None
|
| 1038 |
+
for i in range(1, len(vids)):
|
| 1039 |
+
b_v = vids[i]
|
| 1040 |
+
n = min(xf, out_v.shape[0], b_v.shape[0])
|
| 1041 |
+
if n <= 0:
|
| 1042 |
+
out_v = torch.cat([out_v, b_v], dim=0)
|
| 1043 |
+
if auds is not None:
|
| 1044 |
+
out_a = torch.cat([out_a, auds[i]], dim=-1)
|
| 1045 |
+
continue
|
| 1046 |
+
w = torch.linspace(0.0, 1.0, n, dtype=out_v.dtype).view(n, 1, 1, 1)
|
| 1047 |
+
blend = out_v[-n:] * (1.0 - w) + b_v[:n] * w
|
| 1048 |
+
out_v = torch.cat([out_v[:-n], blend, b_v[n:]], dim=0)
|
| 1049 |
+
if auds is not None:
|
| 1050 |
+
b_a = auds[i]
|
| 1051 |
+
n_s = min(int(round(n / float(video_fps) * audio_sample_rate)),
|
| 1052 |
+
out_a.shape[-1], b_a.shape[-1])
|
| 1053 |
+
if n_s > 0:
|
| 1054 |
+
t = torch.linspace(0.0, 1.0, n_s, dtype=out_a.dtype)
|
| 1055 |
+
fade_out = torch.cos(t * torch.pi / 2.0)
|
| 1056 |
+
fade_in = torch.sin(t * torch.pi / 2.0)
|
| 1057 |
+
a_blend = out_a[..., -n_s:] * fade_out + b_a[..., :n_s] * fade_in
|
| 1058 |
+
out_a = torch.cat([out_a[..., :-n_s], a_blend, b_a[..., n_s:]], dim=-1)
|
| 1059 |
+
else:
|
| 1060 |
+
out_a = torch.cat([out_a, b_a], dim=-1)
|
| 1061 |
+
images = out_v
|
| 1062 |
+
print(f"[JoyEcho] Crossfaded {len(vids)-1} shot boundaries ({xf} frames each).", flush=True)
|
| 1063 |
+
audio_out = None
|
| 1064 |
+
if paired_audio:
|
| 1065 |
+
audio_out = {"waveform": out_a.unsqueeze(0), "sample_rate": audio_sample_rate}
|
| 1066 |
+
elif all_audio_waveforms:
|
| 1067 |
+
combined_waveform = torch.cat(all_audio_waveforms, dim=-1)
|
| 1068 |
+
audio_out = {"waveform": combined_waveform.unsqueeze(0), "sample_rate": audio_sample_rate}
|
| 1069 |
+
else:
|
| 1070 |
+
images = torch.cat(all_video_frames, dim=0) # [total_frames, H, W, 3]
|
| 1071 |
+
audio_out = None
|
| 1072 |
+
if all_audio_waveforms:
|
| 1073 |
+
combined_waveform = torch.cat(all_audio_waveforms, dim=-1) # [2, total_samples]
|
| 1074 |
+
audio_out = {
|
| 1075 |
+
"waveform": combined_waveform.unsqueeze(0), # [1, 2, samples]
|
| 1076 |
+
"sample_rate": audio_sample_rate,
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
# VHS GLITCH transition: corrupt the frames AROUND each boundary in
|
| 1080 |
+
# place (snow, tearing bands, dropout lines) + a tape-static audio
|
| 1081 |
+
# hit. Total length unchanged; deterministic per seed+boundary.
|
| 1082 |
+
if transition == "vhs_glitch" and len(all_video_frames) > 1:
|
| 1083 |
+
n = max(1, int(transition_frames))
|
| 1084 |
+
amt_base = float(max(0.1, min(1.0, glitch_intensity)))
|
| 1085 |
+
boundaries = []
|
| 1086 |
+
acc = 0
|
| 1087 |
+
for v in all_video_frames[:-1]:
|
| 1088 |
+
acc += v.shape[0]
|
| 1089 |
+
boundaries.append(acc) # first frame index of the NEXT shot
|
| 1090 |
+
total_f = images.shape[0]
|
| 1091 |
+
H, W = images.shape[1], images.shape[2]
|
| 1092 |
+
for bi, b in enumerate(boundaries):
|
| 1093 |
+
g = torch.Generator().manual_seed(int(seed) * 1009 + bi)
|
| 1094 |
+
start = max(0, b - n // 2)
|
| 1095 |
+
end = min(total_f, start + n)
|
| 1096 |
+
span = max(1, end - start - 1)
|
| 1097 |
+
for k, fidx in enumerate(range(start, end)):
|
| 1098 |
+
env = 1.0 - abs((k - span / 2.0) / (span / 2.0 or 1.0))
|
| 1099 |
+
amt = amt_base * (0.35 + 0.65 * max(0.0, env))
|
| 1100 |
+
f = images[fidx]
|
| 1101 |
+
# snow (monochrome noise mix)
|
| 1102 |
+
snow = torch.rand((H, W, 1), generator=g).expand(H, W, 3)
|
| 1103 |
+
f = f * (1.0 - amt * 0.8) + snow * (amt * 0.8)
|
| 1104 |
+
# horizontal tearing bands
|
| 1105 |
+
for _ in range(int(1 + amt * 6)):
|
| 1106 |
+
y0 = int(torch.randint(0, max(1, H - 8), (1,), generator=g))
|
| 1107 |
+
bh = int(torch.randint(2, max(3, H // 20), (1,), generator=g))
|
| 1108 |
+
dx = int(torch.randint(-W // 6, W // 6 + 1, (1,), generator=g))
|
| 1109 |
+
f[y0:y0 + bh] = torch.roll(f[y0:y0 + bh], shifts=dx, dims=1)
|
| 1110 |
+
# dropout scanlines
|
| 1111 |
+
for _ in range(int(amt * 4)):
|
| 1112 |
+
y = int(torch.randint(0, H, (1,), generator=g))
|
| 1113 |
+
f[y:y + 1] = float(torch.rand((1,), generator=g))
|
| 1114 |
+
images[fidx] = f.clamp(0.0, 1.0)
|
| 1115 |
+
# audio: tape-static bed over a WIDER window than the video
|
| 1116 |
+
# burst - JoyAI's per-shot room tone fades out at shot edges,
|
| 1117 |
+
# so the static must SPAN that dead seam (>=1.2s), not just
|
| 1118 |
+
# the few glitched frames, or the cut reads as burst->dead
|
| 1119 |
+
# air->tone. Quieter hit with a smooth raised-cosine envelope
|
| 1120 |
+
# (the old short triangular hit at 0.22 was sharp and loud).
|
| 1121 |
+
if audio_out is not None:
|
| 1122 |
+
wav = audio_out["waveform"][0] # [2, samples]
|
| 1123 |
+
c = int(round(b / float(video_fps) * audio_sample_rate))
|
| 1124 |
+
n_s = max(int(round(n / float(video_fps) * audio_sample_rate)),
|
| 1125 |
+
int(round(1.2 * audio_sample_rate)))
|
| 1126 |
+
s0 = max(0, c - n_s // 2)
|
| 1127 |
+
s1 = min(wav.shape[-1], s0 + n_s)
|
| 1128 |
+
if s1 > s0:
|
| 1129 |
+
ln = s1 - s0
|
| 1130 |
+
t = torch.linspace(0.0, 1.0, ln)
|
| 1131 |
+
env_a = 0.5 - 0.5 * torch.cos(t * 2.0 * torch.pi) # raised cosine
|
| 1132 |
+
noise = (torch.rand((wav.shape[0], ln), generator=g) * 2.0 - 1.0)
|
| 1133 |
+
wav[..., s0:s1] = (wav[..., s0:s1] * (1.0 - 0.35 * amt_base * env_a)
|
| 1134 |
+
+ noise * (0.10 * amt_base) * env_a).clamp(-1.0, 1.0)
|
| 1135 |
+
print(f"[JoyEcho] VHS glitch applied at {len(boundaries)} boundaries "
|
| 1136 |
+
f"({n} frames, intensity {amt_base}).", flush=True)
|
| 1137 |
+
|
| 1138 |
+
print(f"[JoyEcho] Generation complete. {images.shape[0]} frames, "
|
| 1139 |
+
f"{num_shots} shot(s).", flush=True)
|
| 1140 |
+
|
| 1141 |
+
return (images, audio_out,)
|
| 1142 |
+
|
| 1143 |
+
@staticmethod
|
| 1144 |
+
def _save_shot_video(video_uint8, audio_waveform, shot_idx, fps, audio_sr, prefix):
|
| 1145 |
+
"""Save a single shot as mp4 immediately after generation."""
|
| 1146 |
+
import av
|
| 1147 |
+
import numpy as np
|
| 1148 |
+
|
| 1149 |
+
try:
|
| 1150 |
+
import folder_paths
|
| 1151 |
+
output_dir = folder_paths.get_output_directory()
|
| 1152 |
+
except Exception:
|
| 1153 |
+
output_dir = Path("/root/ComfyUI/output")
|
| 1154 |
+
|
| 1155 |
+
# Build output path
|
| 1156 |
+
parts = prefix.rsplit("/", 1)
|
| 1157 |
+
if len(parts) == 2:
|
| 1158 |
+
sub_dir = Path(output_dir) / parts[0]
|
| 1159 |
+
name_prefix = parts[1]
|
| 1160 |
+
else:
|
| 1161 |
+
sub_dir = Path(output_dir)
|
| 1162 |
+
name_prefix = prefix
|
| 1163 |
+
|
| 1164 |
+
sub_dir.mkdir(parents=True, exist_ok=True)
|
| 1165 |
+
out_path = sub_dir / f"{name_prefix}_{shot_idx:03d}.mp4"
|
| 1166 |
+
|
| 1167 |
+
frames_np = video_uint8.cpu().numpy() if isinstance(video_uint8, torch.Tensor) else video_uint8
|
| 1168 |
+
|
| 1169 |
+
container = av.open(str(out_path), mode="w")
|
| 1170 |
+
stream = container.add_stream("h264", rate=fps)
|
| 1171 |
+
stream.height = frames_np.shape[1]
|
| 1172 |
+
stream.width = frames_np.shape[2]
|
| 1173 |
+
stream.pix_fmt = "yuv420p"
|
| 1174 |
+
stream.options = {"crf": "18", "preset": "fast"}
|
| 1175 |
+
|
| 1176 |
+
for frame_data in frames_np:
|
| 1177 |
+
frame = av.VideoFrame.from_ndarray(frame_data, format="rgb24")
|
| 1178 |
+
for packet in stream.encode(frame):
|
| 1179 |
+
container.mux(packet)
|
| 1180 |
+
for packet in stream.encode():
|
| 1181 |
+
container.mux(packet)
|
| 1182 |
+
container.close()
|
| 1183 |
+
|
| 1184 |
+
# Save audio sidecar
|
| 1185 |
+
if audio_waveform is not None:
|
| 1186 |
+
import torchaudio
|
| 1187 |
+
wav_path = sub_dir / f"{name_prefix}_{shot_idx:03d}.wav"
|
| 1188 |
+
waveform = audio_waveform.cpu()
|
| 1189 |
+
if waveform.dim() == 1:
|
| 1190 |
+
waveform = waveform.unsqueeze(0)
|
| 1191 |
+
torchaudio.save(str(wav_path), waveform, sample_rate=audio_sr)
|
| 1192 |
+
|
| 1193 |
+
print(f"[JoyEcho] Shot {shot_idx} saved → {out_path}", flush=True)
|
| 1194 |
+
|
| 1195 |
+
|
| 1196 |
+
class JoyEcho_SingleShotGenerate:
|
| 1197 |
+
"""Generate a single shot with memory bank input/output for chaining.
|
| 1198 |
+
|
| 1199 |
+
Each instance has its own editable prompt text box and outputs video frames
|
| 1200 |
+
that can be previewed immediately via CreateVideo → SaveVideo.
|
| 1201 |
+
Chain multiple instances via the memory output → next shot's memory input.
|
| 1202 |
+
"""
|
| 1203 |
+
|
| 1204 |
+
@classmethod
|
| 1205 |
+
def INPUT_TYPES(cls):
|
| 1206 |
+
return {
|
| 1207 |
+
"required": {
|
| 1208 |
+
"model": ("JOYECHO_MODEL",),
|
| 1209 |
+
"prompt": ("STRING", {
|
| 1210 |
+
"multiline": True,
|
| 1211 |
+
"default": "",
|
| 1212 |
+
"tooltip": "Single shot prompt text",
|
| 1213 |
+
}),
|
| 1214 |
+
"seed": ("INT", {"default": 12345, "min": 0, "max": 2**31 - 1}),
|
| 1215 |
+
"num_frames": ("INT", {"default": 241, "min": 9, "max": 481, "step": 8,
|
| 1216 |
+
"tooltip": "Must be 1 + 8*k (e.g. 121, 241, 361)"}),
|
| 1217 |
+
"video_height": ("INT", {"default": 736, "min": 256, "max": 1088, "step": 32}),
|
| 1218 |
+
"video_width": ("INT", {"default": 1280, "min": 256, "max": 1920, "step": 32}),
|
| 1219 |
+
},
|
| 1220 |
+
"optional": {
|
| 1221 |
+
"memory": ("JOYECHO_MEMORY",),
|
| 1222 |
+
"video_fps": ("INT", {"default": 25, "min": 1, "max": 60}),
|
| 1223 |
+
"v2a_grad_scale": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 10.0, "step": 0.1}),
|
| 1224 |
+
"memory_max_size": ("INT", {"default": 7, "min": 0, "max": 20}),
|
| 1225 |
+
"num_fix_frames": ("INT", {"default": 3, "min": 0, "max": 10}),
|
| 1226 |
+
"enable_audio_memory": ("BOOLEAN", {"default": True}),
|
| 1227 |
+
"audio_memory_window_size": ("INT", {"default": 96, "min": 16, "max": 256}),
|
| 1228 |
+
"sequential_offload": ("BOOLEAN", {
|
| 1229 |
+
"default": False,
|
| 1230 |
+
"tooltip": "Enable layer-by-layer GPU offloading for DiT.",
|
| 1231 |
+
}),
|
| 1232 |
+
},
|
| 1233 |
+
}
|
| 1234 |
+
|
| 1235 |
+
RETURN_TYPES = ("IMAGE", "AUDIO", "JOYECHO_MEMORY", "JOYECHO_MODEL",)
|
| 1236 |
+
RETURN_NAMES = ("images", "audio", "memory", "model",)
|
| 1237 |
+
FUNCTION = "generate_shot"
|
| 1238 |
+
CATEGORY = "JoyAI-Echo"
|
| 1239 |
+
|
| 1240 |
+
def generate_shot(
|
| 1241 |
+
self,
|
| 1242 |
+
model: dict,
|
| 1243 |
+
prompt: str,
|
| 1244 |
+
seed: int = 12345,
|
| 1245 |
+
num_frames: int = 241,
|
| 1246 |
+
video_height: int = 736,
|
| 1247 |
+
video_width: int = 1280,
|
| 1248 |
+
memory: dict | None = None,
|
| 1249 |
+
video_fps: int = 25,
|
| 1250 |
+
v2a_grad_scale: float = 2.0,
|
| 1251 |
+
memory_max_size: int = 7,
|
| 1252 |
+
num_fix_frames: int = 3,
|
| 1253 |
+
enable_audio_memory: bool = True,
|
| 1254 |
+
audio_memory_window_size: int = 96,
|
| 1255 |
+
sequential_offload: bool = False,
|
| 1256 |
+
):
|
| 1257 |
+
from ltx_distillation.inference.bidirectional_pipeline import BidirectionalAVInferencePipeline
|
| 1258 |
+
from ltx_distillation.inference.memory_bidirectional_pipeline import BidirectionalMemoryAVInferencePipeline
|
| 1259 |
+
from ltx_distillation.inference.memory_multishot import (
|
| 1260 |
+
PairedAudioVideoMemoryBank,
|
| 1261 |
+
build_paired_audio_memory_kwargs,
|
| 1262 |
+
video_uint8_to_pil_frames,
|
| 1263 |
+
)
|
| 1264 |
+
from ltx_distillation.utils import (
|
| 1265 |
+
add_noise,
|
| 1266 |
+
compute_latent_shapes,
|
| 1267 |
+
decode_benchmark_sample,
|
| 1268 |
+
encode_memory_frames_batch,
|
| 1269 |
+
)
|
| 1270 |
+
|
| 1271 |
+
if not prompt.strip():
|
| 1272 |
+
raise ValueError("Prompt is empty. Enter a shot description.")
|
| 1273 |
+
|
| 1274 |
+
text_encoder = model.get("text_encoder")
|
| 1275 |
+
if text_encoder is None:
|
| 1276 |
+
raise RuntimeError(
|
| 1277 |
+
"Text encoder not available. It may have been released by a previous shot. "
|
| 1278 |
+
"Set release_text_encoder=False on earlier shots."
|
| 1279 |
+
)
|
| 1280 |
+
|
| 1281 |
+
generator = model["generator"]
|
| 1282 |
+
video_vae = model["video_vae"]
|
| 1283 |
+
audio_vae = model["audio_vae"]
|
| 1284 |
+
audio_sample_rate = model["audio_sample_rate"]
|
| 1285 |
+
device = model["device"]
|
| 1286 |
+
dtype = model["dtype"]
|
| 1287 |
+
|
| 1288 |
+
# Validate num_frames
|
| 1289 |
+
if (num_frames - 1) % 8 != 0:
|
| 1290 |
+
num_frames = 1 + ((num_frames - 1) // 8) * 8
|
| 1291 |
+
|
| 1292 |
+
# Update generator resolution
|
| 1293 |
+
generator.video_height = video_height
|
| 1294 |
+
generator.video_width = video_width
|
| 1295 |
+
generator.latent_height = video_height // 32
|
| 1296 |
+
generator.latent_width = video_width // 32
|
| 1297 |
+
generator.video_frame_seqlen = generator.latent_height * generator.latent_width
|
| 1298 |
+
|
| 1299 |
+
# Compute latent shapes
|
| 1300 |
+
video_shape, audio_shape = compute_latent_shapes(
|
| 1301 |
+
num_frames=num_frames,
|
| 1302 |
+
video_height=video_height,
|
| 1303 |
+
video_width=video_width,
|
| 1304 |
+
batch_size=1,
|
| 1305 |
+
video_fps=float(video_fps),
|
| 1306 |
+
)
|
| 1307 |
+
|
| 1308 |
+
# Get or create memory bank
|
| 1309 |
+
if memory is not None:
|
| 1310 |
+
memory_bank = memory["bank"]
|
| 1311 |
+
else:
|
| 1312 |
+
memory_bank = PairedAudioVideoMemoryBank(
|
| 1313 |
+
max_size=memory_max_size,
|
| 1314 |
+
save_mode="random_every_shot_frame",
|
| 1315 |
+
num_fix_frames=num_fix_frames,
|
| 1316 |
+
)
|
| 1317 |
+
|
| 1318 |
+
print(f"[JoyEcho] SingleShot: encoding prompt, seed={seed}, "
|
| 1319 |
+
f"memory_size={len(memory_bank)}", flush=True)
|
| 1320 |
+
|
| 1321 |
+
# --- Phase 0: Encode (text encoder on GPU, everything else off) ---
|
| 1322 |
+
_move(generator, "cpu")
|
| 1323 |
+
_move(video_vae.encoder, "cpu")
|
| 1324 |
+
_move(video_vae.decoder, "cpu")
|
| 1325 |
+
_move(audio_vae.encoder, "cpu")
|
| 1326 |
+
_move(audio_vae.decoder, "cpu")
|
| 1327 |
+
_move(audio_vae.vocoder, "cpu")
|
| 1328 |
+
_move(text_encoder, device)
|
| 1329 |
+
_empty_cache()
|
| 1330 |
+
|
| 1331 |
+
cond = text_encoder([prompt.strip()])
|
| 1332 |
+
conditional_dict = {
|
| 1333 |
+
k: (v.to(device) if isinstance(v, torch.Tensor) else v)
|
| 1334 |
+
for k, v in cond.items()
|
| 1335 |
+
}
|
| 1336 |
+
del cond
|
| 1337 |
+
|
| 1338 |
+
# Offload text encoder immediately after encoding
|
| 1339 |
+
_move(text_encoder, "cpu")
|
| 1340 |
+
_empty_cache()
|
| 1341 |
+
|
| 1342 |
+
# Build pipelines
|
| 1343 |
+
denoising_sigmas = torch.tensor(DENOISING_SIGMAS, device=device, dtype=torch.float32)
|
| 1344 |
+
base_pipeline = BidirectionalAVInferencePipeline(
|
| 1345 |
+
generator=generator,
|
| 1346 |
+
add_noise_fn=add_noise,
|
| 1347 |
+
denoising_sigmas=denoising_sigmas,
|
| 1348 |
+
)
|
| 1349 |
+
memory_pipeline = BidirectionalMemoryAVInferencePipeline(
|
| 1350 |
+
generator=generator,
|
| 1351 |
+
add_noise_fn=add_noise,
|
| 1352 |
+
denoising_sigmas=denoising_sigmas,
|
| 1353 |
+
memory_downscale_factor=1,
|
| 1354 |
+
)
|
| 1355 |
+
|
| 1356 |
+
offloader = None
|
| 1357 |
+
if sequential_offload:
|
| 1358 |
+
offloader = SequentialOffloader(generator, device)
|
| 1359 |
+
|
| 1360 |
+
# --- Phase A: Denoise (generator on GPU, everything else off) ---
|
| 1361 |
+
if sequential_offload:
|
| 1362 |
+
offloader.install()
|
| 1363 |
+
else:
|
| 1364 |
+
_move(generator, device)
|
| 1365 |
+
_empty_cache()
|
| 1366 |
+
|
| 1367 |
+
with torch.random.fork_rng(devices=[device] if device.type == "cuda" else []):
|
| 1368 |
+
torch.manual_seed(seed)
|
| 1369 |
+
if device.type == "cuda":
|
| 1370 |
+
torch.cuda.manual_seed(seed)
|
| 1371 |
+
|
| 1372 |
+
if len(memory_bank) > 0:
|
| 1373 |
+
_move(video_vae.encoder, device)
|
| 1374 |
+
memory_video = encode_memory_frames_batch(
|
| 1375 |
+
video_vae=video_vae,
|
| 1376 |
+
batch_memory_frames=[memory_bank.get_memory_frames()],
|
| 1377 |
+
target_h=video_height,
|
| 1378 |
+
target_w=video_width,
|
| 1379 |
+
device=device,
|
| 1380 |
+
dtype=dtype,
|
| 1381 |
+
)
|
| 1382 |
+
_move(video_vae.encoder, "cpu")
|
| 1383 |
+
_empty_cache()
|
| 1384 |
+
|
| 1385 |
+
memory_audio_kwargs = build_paired_audio_memory_kwargs(
|
| 1386 |
+
memory_bank,
|
| 1387 |
+
enable_audio_memory=enable_audio_memory,
|
| 1388 |
+
v2a_grad_scale=v2a_grad_scale,
|
| 1389 |
+
memory_position_mode="reference",
|
| 1390 |
+
)
|
| 1391 |
+
|
| 1392 |
+
video_latent, audio_latent = memory_pipeline.generate(
|
| 1393 |
+
video_shape=tuple(video_shape),
|
| 1394 |
+
audio_shape=tuple(audio_shape),
|
| 1395 |
+
conditional_dict=conditional_dict,
|
| 1396 |
+
memory_video=memory_video,
|
| 1397 |
+
seed=seed,
|
| 1398 |
+
**memory_audio_kwargs,
|
| 1399 |
+
)
|
| 1400 |
+
del memory_video
|
| 1401 |
+
else:
|
| 1402 |
+
video_latent, audio_latent = base_pipeline.generate(
|
| 1403 |
+
video_shape=tuple(video_shape),
|
| 1404 |
+
audio_shape=tuple(audio_shape),
|
| 1405 |
+
conditional_dict=conditional_dict,
|
| 1406 |
+
seed=seed,
|
| 1407 |
+
)
|
| 1408 |
+
|
| 1409 |
+
if device.type == "cuda":
|
| 1410 |
+
torch.cuda.synchronize()
|
| 1411 |
+
|
| 1412 |
+
del conditional_dict
|
| 1413 |
+
_empty_cache()
|
| 1414 |
+
|
| 1415 |
+
# Storage not gated on enable_audio_memory (same memory-bank fix as the
|
| 1416 |
+
# multishot node above): the flag gates INJECTION only.
|
| 1417 |
+
audio_memory_latent = (
|
| 1418 |
+
audio_latent.detach().cpu().contiguous()
|
| 1419 |
+
if audio_latent is not None
|
| 1420 |
+
else None
|
| 1421 |
+
)
|
| 1422 |
+
|
| 1423 |
+
# --- Phase B: Decode ---
|
| 1424 |
+
if sequential_offload:
|
| 1425 |
+
offloader.remove()
|
| 1426 |
+
_move(generator, "cpu")
|
| 1427 |
+
_empty_cache()
|
| 1428 |
+
_move(video_vae.decoder, device)
|
| 1429 |
+
_move(audio_vae.decoder, device)
|
| 1430 |
+
_move(audio_vae.vocoder, device)
|
| 1431 |
+
|
| 1432 |
+
video_uint8, audio_waveform = decode_benchmark_sample(
|
| 1433 |
+
video_vae, audio_vae, video_latent, audio_latent
|
| 1434 |
+
)
|
| 1435 |
+
|
| 1436 |
+
if device.type == "cuda":
|
| 1437 |
+
torch.cuda.synchronize()
|
| 1438 |
+
|
| 1439 |
+
_move(video_vae.decoder, "cpu")
|
| 1440 |
+
_move(audio_vae.decoder, "cpu")
|
| 1441 |
+
_move(audio_vae.vocoder, "cpu")
|
| 1442 |
+
_empty_cache()
|
| 1443 |
+
|
| 1444 |
+
# Update memory bank
|
| 1445 |
+
memory_frames_pil = video_uint8_to_pil_frames(video_uint8)
|
| 1446 |
+
if audio_memory_latent is not None:
|
| 1447 |
+
memory_bank.save_memory_slot(
|
| 1448 |
+
memory_frames_pil,
|
| 1449 |
+
audio_memory_latent,
|
| 1450 |
+
audio_window_size=audio_memory_window_size,
|
| 1451 |
+
video_clip_num_frames=9,
|
| 1452 |
+
audio_waveform=audio_waveform,
|
| 1453 |
+
audio_sample_rate=16000,
|
| 1454 |
+
video_fps=float(video_fps),
|
| 1455 |
+
audio_window_selection_mode="max_response",
|
| 1456 |
+
video_frame_selection_mode="center",
|
| 1457 |
+
audio_memory_mel_bins=128,
|
| 1458 |
+
audio_memory_mel_hop_length=160,
|
| 1459 |
+
audio_memory_n_fft=1024,
|
| 1460 |
+
audio_memory_downsample_factor=4,
|
| 1461 |
+
audio_memory_is_causal=True,
|
| 1462 |
+
)
|
| 1463 |
+
|
| 1464 |
+
# Build outputs
|
| 1465 |
+
images = video_uint8.float() / 255.0 # [F, H, W, 3]
|
| 1466 |
+
|
| 1467 |
+
audio_out = None
|
| 1468 |
+
if audio_waveform is not None:
|
| 1469 |
+
from ltx_distillation.inference.memory_multishot import normalize_audio_waveform_for_media
|
| 1470 |
+
audio_norm = normalize_audio_waveform_for_media(audio_waveform)
|
| 1471 |
+
audio_out = {
|
| 1472 |
+
"waveform": audio_norm.unsqueeze(0), # [1, C, samples]
|
| 1473 |
+
"sample_rate": audio_sample_rate,
|
| 1474 |
+
}
|
| 1475 |
+
|
| 1476 |
+
memory_out = {"bank": memory_bank}
|
| 1477 |
+
|
| 1478 |
+
del video_latent, audio_latent, audio_memory_latent, video_uint8, audio_waveform
|
| 1479 |
+
_empty_cache()
|
| 1480 |
+
|
| 1481 |
+
print(f"[JoyEcho] SingleShot done. {images.shape[0]} frames.", flush=True)
|
| 1482 |
+
|
| 1483 |
+
return (images, audio_out, memory_out, model,)
|
| 1484 |
+
|
| 1485 |
+
|
| 1486 |
+
_PROMPTS_DIR = Path(__file__).resolve().parent / "prompts"
|
| 1487 |
+
|
| 1488 |
+
_DEFAULT_LONG_STORY_SYSTEM_PROMPT = ""
|
| 1489 |
+
_long_sp_path = _PROMPTS_DIR / "long_story_writer_system_prompt.md"
|
| 1490 |
+
if _long_sp_path.exists():
|
| 1491 |
+
_DEFAULT_LONG_STORY_SYSTEM_PROMPT = _long_sp_path.read_text(encoding="utf-8").strip()
|
| 1492 |
+
|
| 1493 |
+
|
| 1494 |
+
def _load_system_prompt(mode: str) -> str:
|
| 1495 |
+
"""Load the full system prompt from the bundled markdown file."""
|
| 1496 |
+
if "long" in mode:
|
| 1497 |
+
fp = _PROMPTS_DIR / "long_story_writer_system_prompt.md"
|
| 1498 |
+
else:
|
| 1499 |
+
fp = _PROMPTS_DIR / "short_story_writer_system_prompt.md"
|
| 1500 |
+
if fp.exists():
|
| 1501 |
+
return fp.read_text(encoding="utf-8").strip()
|
| 1502 |
+
raise FileNotFoundError(f"System prompt not found: {fp}")
|
| 1503 |
+
|
| 1504 |
+
|
| 1505 |
+
class JoyEcho_PromptFormat:
|
| 1506 |
+
"""Helper node providing the official prompt writing system prompts.
|
| 1507 |
+
|
| 1508 |
+
Use this with any LLM node in ComfyUI to generate properly formatted
|
| 1509 |
+
shot prompts from a short story description.
|
| 1510 |
+
|
| 1511 |
+
The output can be fed directly into JoyEcho_TextEncode.
|
| 1512 |
+
"""
|
| 1513 |
+
|
| 1514 |
+
@classmethod
|
| 1515 |
+
def INPUT_TYPES(cls):
|
| 1516 |
+
return {
|
| 1517 |
+
"required": {
|
| 1518 |
+
"mode": (["long_story (multi-shot)", "short_story (single-shot)"],),
|
| 1519 |
+
},
|
| 1520 |
+
}
|
| 1521 |
+
|
| 1522 |
+
RETURN_TYPES = ("STRING",)
|
| 1523 |
+
RETURN_NAMES = ("system_prompt",)
|
| 1524 |
+
FUNCTION = "get_prompt"
|
| 1525 |
+
CATEGORY = "JoyAI-Echo"
|
| 1526 |
+
|
| 1527 |
+
def get_prompt(self, mode: str):
|
| 1528 |
+
return (_load_system_prompt(mode),)
|
| 1529 |
+
|
| 1530 |
+
|
| 1531 |
+
class JoyEcho_LLMEnhance:
|
| 1532 |
+
"""Call a cloud LLM API to expand a short story idea into JoyAI-Echo shot prompts.
|
| 1533 |
+
|
| 1534 |
+
Supports OpenAI-compatible APIs (OpenAI, DeepSeek, etc.).
|
| 1535 |
+
The output JSON can be fed directly into JoyEcho_TextEncode or split via JoyEcho_PromptAtIndex.
|
| 1536 |
+
Uses only cloud API calls — zero local GPU memory.
|
| 1537 |
+
"""
|
| 1538 |
+
|
| 1539 |
+
@classmethod
|
| 1540 |
+
def INPUT_TYPES(cls):
|
| 1541 |
+
return {
|
| 1542 |
+
"required": {
|
| 1543 |
+
"story_idea": ("STRING", {
|
| 1544 |
+
"multiline": True,
|
| 1545 |
+
"default": "A young woman records a quiet evening vlog in her cozy room, reflecting on life and finding warmth in small things.",
|
| 1546 |
+
"tooltip": "Describe your story or scene idea in a few sentences.",
|
| 1547 |
+
}),
|
| 1548 |
+
"mode": (["long_story (multi-shot)", "short_story (single-shot)", "passthrough (raw JSON, skip LLM)"],),
|
| 1549 |
+
"api_key": ("STRING", {
|
| 1550 |
+
"default": "",
|
| 1551 |
+
"tooltip": "Your API key (OpenAI, DeepSeek, etc.). Not needed in passthrough mode.",
|
| 1552 |
+
}),
|
| 1553 |
+
"system_prompt": ("STRING", {
|
| 1554 |
+
"multiline": True,
|
| 1555 |
+
"default": _DEFAULT_LONG_STORY_SYSTEM_PROMPT,
|
| 1556 |
+
"tooltip": "System prompt for the LLM. Edit to customize prompt generation style.",
|
| 1557 |
+
}),
|
| 1558 |
+
},
|
| 1559 |
+
"optional": {
|
| 1560 |
+
"base_url": ("STRING", {
|
| 1561 |
+
"default": "https://api.openai.com/v1",
|
| 1562 |
+
"tooltip": "API base URL. Use https://api.deepseek.com/v1 for DeepSeek, etc.",
|
| 1563 |
+
}),
|
| 1564 |
+
"model_name": ("STRING", {
|
| 1565 |
+
"default": "gpt-4o",
|
| 1566 |
+
"tooltip": "Model name (gpt-4o, deepseek-chat, claude-3-5-sonnet, etc.)",
|
| 1567 |
+
}),
|
| 1568 |
+
"num_shots": ("INT", {
|
| 1569 |
+
"default": 0, "min": 0, "max": 30,
|
| 1570 |
+
"tooltip": "Number of shots to generate (0 = let LLM decide, default 15 for long story).",
|
| 1571 |
+
}),
|
| 1572 |
+
"temperature": ("FLOAT", {
|
| 1573 |
+
"default": 0.7, "min": 0.0, "max": 2.0, "step": 0.05,
|
| 1574 |
+
}),
|
| 1575 |
+
},
|
| 1576 |
+
}
|
| 1577 |
+
|
| 1578 |
+
RETURN_TYPES = ("STRING",)
|
| 1579 |
+
RETURN_NAMES = ("prompts_json",)
|
| 1580 |
+
FUNCTION = "enhance"
|
| 1581 |
+
CATEGORY = "JoyAI-Echo"
|
| 1582 |
+
|
| 1583 |
+
def enhance(
|
| 1584 |
+
self,
|
| 1585 |
+
story_idea: str,
|
| 1586 |
+
mode: str,
|
| 1587 |
+
api_key: str,
|
| 1588 |
+
system_prompt: str,
|
| 1589 |
+
base_url: str = "https://api.openai.com/v1",
|
| 1590 |
+
model_name: str = "gpt-4o",
|
| 1591 |
+
num_shots: int = 0,
|
| 1592 |
+
temperature: float = 0.7,
|
| 1593 |
+
):
|
| 1594 |
+
import urllib.request
|
| 1595 |
+
import urllib.error
|
| 1596 |
+
|
| 1597 |
+
# AUTO-DETECT: if story_idea is already a valid {"prompts":[...]} payload,
|
| 1598 |
+
# pass it through regardless of the mode widget - so one wiring serves both
|
| 1599 |
+
# briefs (LLM-enhanced) and finished scripts without flipping the mode.
|
| 1600 |
+
_looks_json = story_idea.strip().startswith("{")
|
| 1601 |
+
if _looks_json and "passthrough" not in mode.lower():
|
| 1602 |
+
try:
|
| 1603 |
+
_probe = json.loads(story_idea.strip())
|
| 1604 |
+
if isinstance(_probe.get("prompts") or _probe.get("shots"), list):
|
| 1605 |
+
print("[JoyEcho] LLMEnhance: story_idea is a finished prompts JSON - "
|
| 1606 |
+
"auto-passthrough (mode widget ignored).", flush=True)
|
| 1607 |
+
mode = "passthrough (auto)"
|
| 1608 |
+
except (json.JSONDecodeError, AttributeError):
|
| 1609 |
+
pass
|
| 1610 |
+
|
| 1611 |
+
# PASSTHROUGH: feed straight {"prompts":[...]} JSON in story_idea and skip the
|
| 1612 |
+
# LLM entirely. Lets the same node/wiring accept either an enhanced brief or a
|
| 1613 |
+
# finished script (e.g. from the Script Picker) via the mode toggle.
|
| 1614 |
+
if "passthrough" in mode.lower():
|
| 1615 |
+
text = story_idea.strip()
|
| 1616 |
+
try:
|
| 1617 |
+
data = json.loads(text)
|
| 1618 |
+
except json.JSONDecodeError as e:
|
| 1619 |
+
raise ValueError(
|
| 1620 |
+
f"Passthrough mode expects raw JSON in story_idea, but it did not parse: {e}"
|
| 1621 |
+
)
|
| 1622 |
+
arr = data.get("prompts") if isinstance(data, dict) else None
|
| 1623 |
+
if arr is None and isinstance(data, dict):
|
| 1624 |
+
arr = data.get("shots")
|
| 1625 |
+
if not isinstance(arr, list) or not arr:
|
| 1626 |
+
raise ValueError(
|
| 1627 |
+
'Passthrough mode expects {"prompts": [...]} JSON (non-empty array) in story_idea.'
|
| 1628 |
+
)
|
| 1629 |
+
print(f"[JoyEcho] LLMEnhance PASSTHROUGH: {len(arr)} shots, no LLM call.", flush=True)
|
| 1630 |
+
return (text,)
|
| 1631 |
+
|
| 1632 |
+
if not api_key.strip():
|
| 1633 |
+
raise ValueError("API key is required. Enter your OpenAI/DeepSeek/etc. API key.")
|
| 1634 |
+
|
| 1635 |
+
if system_prompt.strip():
|
| 1636 |
+
sys_prompt = system_prompt.strip()
|
| 1637 |
+
else:
|
| 1638 |
+
sys_prompt = _load_system_prompt(mode)
|
| 1639 |
+
|
| 1640 |
+
user_msg = story_idea.strip()
|
| 1641 |
+
if num_shots > 0:
|
| 1642 |
+
user_msg += f"\n\nGenerate exactly {num_shots} shots."
|
| 1643 |
+
|
| 1644 |
+
url = base_url.rstrip("/") + "/chat/completions"
|
| 1645 |
+
payload = json.dumps({
|
| 1646 |
+
"model": model_name,
|
| 1647 |
+
"messages": [
|
| 1648 |
+
{"role": "system", "content": sys_prompt},
|
| 1649 |
+
{"role": "user", "content": user_msg},
|
| 1650 |
+
],
|
| 1651 |
+
"temperature": temperature,
|
| 1652 |
+
"max_tokens": 16384,
|
| 1653 |
+
}).encode("utf-8")
|
| 1654 |
+
|
| 1655 |
+
headers = {
|
| 1656 |
+
"Content-Type": "application/json",
|
| 1657 |
+
"Authorization": f"Bearer {api_key.strip()}",
|
| 1658 |
+
}
|
| 1659 |
+
|
| 1660 |
+
print(f"[JoyEcho] Calling LLM ({model_name}) to enhance prompt...", flush=True)
|
| 1661 |
+
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
| 1662 |
+
try:
|
| 1663 |
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
| 1664 |
+
result = json.loads(resp.read().decode("utf-8"))
|
| 1665 |
+
except urllib.error.HTTPError as e:
|
| 1666 |
+
body = e.read().decode("utf-8", errors="replace")
|
| 1667 |
+
raise RuntimeError(f"LLM API error {e.code}: {body}")
|
| 1668 |
+
|
| 1669 |
+
content = result["choices"][0]["message"]["content"].strip()
|
| 1670 |
+
|
| 1671 |
+
# Strip markdown code fences if present
|
| 1672 |
+
if content.startswith("```"):
|
| 1673 |
+
lines = content.split("\n")
|
| 1674 |
+
lines = [l for l in lines if not l.strip().startswith("```")]
|
| 1675 |
+
content = "\n".join(lines).strip()
|
| 1676 |
+
|
| 1677 |
+
# Validate JSON
|
| 1678 |
+
try:
|
| 1679 |
+
data = json.loads(content)
|
| 1680 |
+
if "prompts" not in data or not isinstance(data["prompts"], list):
|
| 1681 |
+
raise ValueError("LLM output missing 'prompts' array")
|
| 1682 |
+
num = len(data["prompts"])
|
| 1683 |
+
except (json.JSONDecodeError, ValueError) as e:
|
| 1684 |
+
raise RuntimeError(
|
| 1685 |
+
f"LLM returned invalid JSON: {e}\n\nRaw output:\n{content[:500]}"
|
| 1686 |
+
)
|
| 1687 |
+
|
| 1688 |
+
print(f"[JoyEcho] LLM generated {num} shot prompt(s).", flush=True)
|
| 1689 |
+
|
| 1690 |
+
# Persist + echo the generated prompts so you can inspect exactly what
|
| 1691 |
+
# the enhancer produced (this JSON is what feeds JoyEcho_TextEncode).
|
| 1692 |
+
try:
|
| 1693 |
+
import os
|
| 1694 |
+
import folder_paths
|
| 1695 |
+
_outdir = os.path.join(folder_paths.get_output_directory(), "joyecho")
|
| 1696 |
+
os.makedirs(_outdir, exist_ok=True)
|
| 1697 |
+
_dump = os.path.join(_outdir, "enhanced_prompts_latest.json")
|
| 1698 |
+
with open(_dump, "w", encoding="utf-8") as _f:
|
| 1699 |
+
_f.write(content)
|
| 1700 |
+
print(f"[JoyEcho] enhancer output written to: {_dump}", flush=True)
|
| 1701 |
+
except Exception as _e:
|
| 1702 |
+
print(f"[JoyEcho] could not write enhancer output file: {_e}", flush=True)
|
| 1703 |
+
print("[JoyEcho] ---------- enhancer output (prompts) ----------", flush=True)
|
| 1704 |
+
print(content, flush=True)
|
| 1705 |
+
print("[JoyEcho] ---------- end enhancer output ----------", flush=True)
|
| 1706 |
+
return (content,)
|
| 1707 |
+
|
| 1708 |
+
|
| 1709 |
+
class JoyEcho_PromptAtIndex:
|
| 1710 |
+
"""Extract a single prompt from a JSON prompts array by index.
|
| 1711 |
+
|
| 1712 |
+
Connect the output to a SingleShotGenerate node's prompt input to override
|
| 1713 |
+
the text box with LLM-generated content. This is optional — if not connected,
|
| 1714 |
+
the SingleShot node uses its own text box.
|
| 1715 |
+
"""
|
| 1716 |
+
|
| 1717 |
+
@classmethod
|
| 1718 |
+
def INPUT_TYPES(cls):
|
| 1719 |
+
return {
|
| 1720 |
+
"required": {
|
| 1721 |
+
"prompts_json": ("STRING", {
|
| 1722 |
+
"multiline": True,
|
| 1723 |
+
"default": "",
|
| 1724 |
+
"tooltip": "JSON string with 'prompts' array (from LLM Enhance or file)",
|
| 1725 |
+
}),
|
| 1726 |
+
"index": ("INT", {
|
| 1727 |
+
"default": 0, "min": 0, "max": 29,
|
| 1728 |
+
"tooltip": "0-based shot index to extract",
|
| 1729 |
+
}),
|
| 1730 |
+
},
|
| 1731 |
+
}
|
| 1732 |
+
|
| 1733 |
+
RETURN_TYPES = ("STRING",)
|
| 1734 |
+
RETURN_NAMES = ("prompt",)
|
| 1735 |
+
FUNCTION = "extract"
|
| 1736 |
+
CATEGORY = "JoyAI-Echo"
|
| 1737 |
+
|
| 1738 |
+
def extract(self, prompts_json: str, index: int):
|
| 1739 |
+
text = prompts_json.strip()
|
| 1740 |
+
if not text:
|
| 1741 |
+
raise ValueError("No prompts JSON provided.")
|
| 1742 |
+
|
| 1743 |
+
try:
|
| 1744 |
+
data = json.loads(text)
|
| 1745 |
+
except json.JSONDecodeError as e:
|
| 1746 |
+
raise ValueError(f"Invalid JSON: {e}")
|
| 1747 |
+
|
| 1748 |
+
prompt_list = data.get("prompts") or data.get("shots") or []
|
| 1749 |
+
if not prompt_list:
|
| 1750 |
+
raise ValueError("JSON must contain a 'prompts' or 'shots' array.")
|
| 1751 |
+
|
| 1752 |
+
if index >= len(prompt_list):
|
| 1753 |
+
raise ValueError(
|
| 1754 |
+
f"Index {index} out of range (only {len(prompt_list)} prompts available)."
|
| 1755 |
+
)
|
| 1756 |
+
|
| 1757 |
+
return (str(prompt_list[index]).strip(),)
|
prompts/long_story_writer_system_prompt.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are a professional shot-prompt writer for a joint audio-video generation model. Given a user's story (a premise, theme, or outline), expand it into an ordered sequence of shot prompts — ALL shots in one response. Each shot is one ~10-second clip the model renders with synchronized video and audio.
|
| 2 |
+
|
| 3 |
+
## STRICT OUTPUT FORMAT (MUST FOLLOW EXACTLY)
|
| 4 |
+
- Output MUST be a single valid JSON object and NOTHING else:
|
| 5 |
+
{"prompts": ["<shot 1 prompt>", "<shot 2 prompt>", ...]}
|
| 6 |
+
- No text before or after the JSON. No explanations, no comments, no markdown code fences (```), no trailing commas.
|
| 7 |
+
- "prompts" is a JSON array of STRINGS. Each string = exactly one shot.
|
| 8 |
+
- Each string is ONE single continuous English paragraph. Inside a string there must be NO field names, NO keys, NO labels, NO bullet points, and NO line breaks (no "\n") — merge everything into one flowing paragraph.
|
| 9 |
+
- The spoken line, when present, is embedded inside the paragraph with escaped double quotes: ... ID_A says, \"...\" ...
|
| 10 |
+
- The number of array elements equals the number of shots. Everything is written in English.
|
| 11 |
+
|
| 12 |
+
## SPEECH IS OPTIONAL PER SHOT (IMPORTANT)
|
| 13 |
+
- Not every shot has spoken dialogue. Decide per shot whether characters speak, and how many.
|
| 14 |
+
- Use NON-SPEAKING shots for establishing, mood, reaction, object-detail, or transition beats — this varies the rhythm and strengthens the dramatic arc.
|
| 15 |
+
- A shot may have one speaker, two speakers exchanging lines, or no speaker at all.
|
| 16 |
+
- Only when a character speaks do you add that character's voice sentence, a lip-sync note, and the spoken line. For a non-speaking shot, omit all three and let the action and environmental sound carry it.
|
| 17 |
+
|
| 18 |
+
## CHARACTER CONSISTENCY (CRITICAL — DO NOT LET IDENTITY DRIFT)
|
| 19 |
+
- Give each recurring character a stable ID (ID_A, ID_B, ...). Reserve IDs for PEOPLE only; never label an object with an ID.
|
| 20 |
+
- For each recurring visible character, repeat the EXACT SAME base identity sentence and clothing sentence in every shot where the character appears (and, when the character speaks, the EXACT SAME voice sentence). Copy these sentences verbatim — do not paraphrase, reorder, or change a single word between shots.
|
| 21 |
+
- The base identity sentence describes only stable appearance (age, build, hair, face). It must NOT contain expression or mood.
|
| 22 |
+
- Expression, gaze, posture, and emotional state may vary ONLY AFTER the base identity sentence, written as a separate sentence. The fixed identity/clothing/voice wording itself never changes, so each generated person stays identical across the whole story.
|
| 23 |
+
|
| 24 |
+
## CHARACTER AGE (STATE IT — THE MODEL DEFAULTS TO ADULTS)
|
| 25 |
+
- Put each character's age band in the base identity sentence; the model renders adults by default, so youth must be asserted or a teen comes out looking like a grown adult. For a teenager, say it plainly (e.g., "a fifteen-year-old teenage girl with a soft youthful round face, clearly a teenager, not an adult") and dress them the way the brief describes — contemporary teen clothing reads as teen and helps age fidelity, while blazers, suit jackets, and professional outerwear read as adult. For a grown character, label them "adult". Never make anyone look younger than a teenager.
|
| 26 |
+
|
| 27 |
+
## KEEP SIMILAR CHARACTERS DISTINCT (PREVENT IDENTITY MERGE)
|
| 28 |
+
- When two or more visible characters could look alike (same age range, same hair color, both wearing glasses, etc.), give each a BOLD, unmistakable distinguishing feature — a strong hair-color or hairstyle contrast, a distinctive accessory, or a clear facial mark — and restate it in every shot. State plainly that they are different people who must never blend, merge, average, or swap faces, hair, glasses, or clothing. Without a bold differentiator the model averages similar-looking people into a single hybrid face.
|
| 29 |
+
|
| 30 |
+
## WHAT EACH SHOT PARAGRAPH CONTAINS (woven as natural prose, in this order)
|
| 31 |
+
ALWAYS, for every visible character:
|
| 32 |
+
1. The character's fixed base identity sentence (verbatim) + fixed clothing sentence (verbatim), then optionally one separate sentence for the current expression/gaze/posture/emotion.
|
| 33 |
+
Then:
|
| 34 |
+
2. Action: begin with "At normal speed, " then the action in temporal order.
|
| 35 |
+
3. Style: visual aesthetic, palette, mood, realistic film look.
|
| 36 |
+
4. Camera: framing and motion (keep speaking faces readable).
|
| 37 |
+
5. Background: setting/location and lighting.
|
| 38 |
+
6. Sound effects: the diegetic environmental sounds that are audible.
|
| 39 |
+
7. Sound only, never music: Every shot's audio is strictly quiet, realistic diegetic sound (room tone, ambience, HVAC hum, footsteps, fabric, breathing). There is NEVER any background music, score, soundtrack, melody, humming, or singing in any shot. Do NOT add music of any kind, and do NOT write the words music, score, soundtrack, melody, BGM, humming, or singing anywhere in your output — simply describe the quiet diegetic sounds in a few plain words and stop.
|
| 40 |
+
FOR EACH CHARACTER WHO SPEAKS IN THE SHOT, also add:
|
| 41 |
+
- that character's fixed voice sentence (verbatim): "ID_X's voice is a ... [register, tone, pacing].";
|
| 42 |
+
- a lip-sync note: the mouth movement is clearly visible in frame and stays naturally synchronized with the spoken line (reads well on slower, emotional phrases); for two speakers, state that both mouths stay synced to their own lines;
|
| 43 |
+
- inside the action, reaffirm that the lip movement aligns closely with the audio;
|
| 44 |
+
- the line itself: In a [voice description], ID_X says, \"<the spoken line>\". For two speakers, order the lines naturally (ID_A speaks, then ID_B answers).
|
| 45 |
+
|
| 46 |
+
## DRAMATIC ARC
|
| 47 |
+
- Build a clear emotional arc: an opening that sets mood, a rising line of realization or tension, a turning point or discovery, a vulnerable low beat, and a resolution. Let the meaning escalate shot by shot. Alternate speaking and non-speaking shots to control pacing and tension.
|
| 48 |
+
|
| 49 |
+
## DIALOGUE (FOR SPEAKING SHOTS ONLY)
|
| 50 |
+
- Each spoken line is short, roughly 10–20 words, natural and reflective, in the character's own voice; each line pushes the emotional arc forward. In a two-speaker shot keep it to one short line each. English only.
|
| 51 |
+
|
| 52 |
+
## MODEL-FRIENDLY (AVOID GENERATION FAILURE)
|
| 53 |
+
- Favor gentle, simple, physically plausible actions (standing, sitting, slow turning, walking slowly, reaching, holding, small gestures, speaking to camera). Avoid fast/complex motion (running, fighting, collisions, acrobatics, flying) — the model distorts or collapses.
|
| 54 |
+
- Limit how many characters appear together (two is usually the safe maximum in one shot); keep each shot one clear scene with no mid-shot location jumps. Keep the world realistic; avoid on-screen text, UI, or subtitles.
|
| 55 |
+
|
| 56 |
+
## NUMBER OF SHOTS
|
| 57 |
+
- Produce exactly the number of shots the user requests. If the user does NOT specify a number, default to exactly 15 shots.
|
| 58 |
+
|
| 59 |
+
## EXAMPLE OF THE EXACT OUTPUT (two speaking shots and one non-speaking shot; note ID_A's base identity, clothing, and voice sentences are byte-identical across all shots — only the expression sentence and the action change)
|
| 60 |
+
{"prompts": ["ID_A is a young woman in her twenties with shoulder-length dark brown hair and a slim build. ID_A wears a loose light beige knit top and relaxed dark trousers. ID_A's voice is a clear young female voice with a soft mid-high register, gentle breathiness, and intimate vlog-style pacing. Her expression is calm and thoughtful. The mouth movement is clearly visible in the frame and stays naturally synchronized with the spoken line, especially on slower reflective phrases. At normal speed, ID_A steps into the center of the frame, settles her posture, and begins speaking, the lip movement aligning closely with the audio throughout the sentence. In a soft young female voice with reflective warmth, ID_A says, \"I did not plan to record tonight, but this room feels different now.\" The shot uses realistic indoor imagery with soft practical light, neutral warm tones, and a calm introspective domestic mood. A stable medium shot frames ID_A from the waist up, keeping the face and visible lip movement clearly readable while preserving some of the room behind. The background includes a white curtain, soft string lights, part of a small table, and the warm interior of a well-kept room. Very soft indoor room tone, light fabric movement, and subtle foot placement are audible; the sound stays on speech and subtle room ambience only.", "ID_A is a young woman in her twenties with shoulder-length dark brown hair and a slim build. ID_A wears a loose light beige knit top and relaxed dark trousers. ID_A's voice is a clear young female voice with a soft mid-high register, gentle breathiness, and intimate vlog-style pacing. Her expression is quiet and sincere. The mouth movement stays clearly visible and naturally synchronized with the spoken line through the slower delivery. At normal speed, ID_A rests one hand on the notebook on the desk and lets it stay there for a beat before speaking, the lip movement aligning closely with the audio. In a soft young female voice with quiet sincerity, ID_A says, \"This notebook has waited here for months, like a version of me waiting to be answered.\" The shot stays realistic and tactile, with warm desk light and close domestic detail that makes ordinary objects feel emotionally loaded. A close-medium shot keeps one hand on the notebook and part of the face in frame so speech and object interaction stay connected. The background includes the desk surface, the notebook, a soft lamp glow, and the blurred warm curtain lights. Soft contact with the notebook cover, a slight paper shift, and low room ambience are audible; the soundscape stays minimal and speech-centered.", "ID_A is a young woman in her twenties with shoulder-length dark brown hair and a slim build. ID_A wears a loose light beige knit top and relaxed dark trousers. Her gaze is quiet and introspective. At normal speed, ID_A reaches up, grips the curtain gently, and draws it partway, her head turning slightly as the evening settles outside the window. The shot stays naturalistic and warm, emphasizing calm everyday movement inside a softly lit room with a transitional, reflective mood. A medium shot with a slight pan keeps the curtain action and ID_A's face readable while revealing more of the side wall. The background includes the curtain, a faint edge of the window, small warm decorative lights, and a partial glimpse of the table nearby. Curtain fabric sliding, light hand contact with cloth, and low indoor ambience are audible; only faint room tone and soft fabric movement fill the quiet, transitional mood."]}
|
| 61 |
+
|
| 62 |
+
## PROCESS
|
| 63 |
+
- Read the user's story, decide the shot count per the rule above, break it into a coherent, well-paced emotional sequence. Keep each character's base identity, clothing, and voice sentences byte-identical across all their shots; vary only the separate expression sentence. Mix one-speaker, two-speaker, and non-speaking shots. Output ONLY the {"prompts": [...]} JSON in one response.
|
rebels_loaders.py
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
rebels_loaders.py — discrete ComfyUI loader nodes for JoyAI-Echo on low VRAM.
|
| 3 |
+
Patched for Single-File Gemma intake and Key Remapping.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
import os, json, gc
|
| 7 |
+
_LOADER_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 8 |
+
_LOADER_CFG = os.path.join(_LOADER_DIR, "configs", "joyai_echo_config.json")
|
| 9 |
+
import dataclasses
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn as nn
|
| 13 |
+
import torch.nn.functional as F
|
| 14 |
+
import gguf
|
| 15 |
+
from gguf import GGUFReader, GGMLQuantizationType as QT
|
| 16 |
+
|
| 17 |
+
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as Builder
|
| 18 |
+
from ltx_core.loader.sft_loader import SafetensorsModelStateDictLoader
|
| 19 |
+
from ltx_core.loader.primitives import StateDict
|
| 20 |
+
from ltx_core.loader.module_ops import ModuleOps
|
| 21 |
+
from ltx_core.model.transformer import LTXV_MODEL_COMFY_RENAMING_MAP, LTXModelConfigurator, X0Model
|
| 22 |
+
from ltx_core.model.video_vae import (VAE_DECODER_COMFY_KEYS_FILTER, VAE_ENCODER_COMFY_KEYS_FILTER,
|
| 23 |
+
VideoDecoderConfigurator, VideoEncoderConfigurator)
|
| 24 |
+
from ltx_core.model.audio_vae import (AUDIO_VAE_DECODER_COMFY_KEYS_FILTER, AUDIO_VAE_ENCODER_COMFY_KEYS_FILTER,
|
| 25 |
+
VOCODER_COMFY_KEYS_FILTER, AudioDecoderConfigurator,
|
| 26 |
+
AudioEncoderConfigurator, VocoderConfigurator)
|
| 27 |
+
from ltx_core.text_encoders.gemma import (EMBEDDINGS_PROCESSOR_KEY_OPS, EmbeddingsProcessorConfigurator)
|
| 28 |
+
from ltx_distillation.models.ltx_wrapper import LTX2DiffusionWrapper
|
| 29 |
+
from ltx_distillation.models.vae_wrapper import VideoVAEWrapper, AudioVAEWrapper
|
| 30 |
+
from ltx_distillation.models.text_encoder_wrapper import GemmaTextEncoderWrapper
|
| 31 |
+
|
| 32 |
+
CAT = "Rebels/JoyAI-Echo"
|
| 33 |
+
|
| 34 |
+
# ---------------------------------------------------------------- config
|
| 35 |
+
def _full_config(src: str) -> dict:
|
| 36 |
+
src = src.strip().strip('"')
|
| 37 |
+
if src.lower().endswith(".json"):
|
| 38 |
+
with open(src, "r", encoding="utf-8") as f: return json.load(f)
|
| 39 |
+
from safetensors import safe_open
|
| 40 |
+
with safe_open(src, framework="pt") as f:
|
| 41 |
+
meta = f.metadata() or {}
|
| 42 |
+
if "config" not in meta:
|
| 43 |
+
raise ValueError(f"No 'config' in metadata of {src}. Point at the checkpoint or a config.json.")
|
| 44 |
+
return json.loads(meta["config"])
|
| 45 |
+
|
| 46 |
+
_GGUF_MARKER = "__rebels_gguf_no_safetensors__"
|
| 47 |
+
|
| 48 |
+
def _gemma_remap_key(k):
|
| 49 |
+
"""Map raw checkpoint key names onto the wrapper's module tree. Shared by
|
| 50 |
+
the safetensors load path and the GGUF swap so both match identically."""
|
| 51 |
+
nk = k.replace("cond_stage_model.", "").replace("text_model.", "").replace("text_encoder.", "")
|
| 52 |
+
if "embed_tokens" in nk:
|
| 53 |
+
nk = "model.model.language_model.embed_tokens.weight"
|
| 54 |
+
elif "layers" in nk:
|
| 55 |
+
nk = nk.replace("model.layers", "model.model.language_model.layers")
|
| 56 |
+
elif "norm" in nk and "language_model" not in nk:
|
| 57 |
+
nk = nk.replace("model.norm", "model.model.language_model.norm")
|
| 58 |
+
return nk
|
| 59 |
+
|
| 60 |
+
class _CfgLoader(SafetensorsModelStateDictLoader):
|
| 61 |
+
"""Stock safetensors weight load, but metadata() returns the shared config."""
|
| 62 |
+
def __init__(self, config: dict, map_gemma=False, *a, **k):
|
| 63 |
+
super().__init__(*a, **k)
|
| 64 |
+
self._cfg = config
|
| 65 |
+
self._map_gemma = map_gemma
|
| 66 |
+
|
| 67 |
+
def metadata(self, path): return self._cfg
|
| 68 |
+
|
| 69 |
+
def load(self, paths, sd_ops=None, device=None):
|
| 70 |
+
plist = [str(x) for x in (paths if isinstance(paths, (list, tuple)) else [paths])]
|
| 71 |
+
if any(_GGUF_MARKER in x or x.lower().endswith(".gguf") for x in plist):
|
| 72 |
+
import types
|
| 73 |
+
return types.SimpleNamespace(sd={}) # build() only touches .sd
|
| 74 |
+
sd_obj = super().load(paths, sd_ops, device)
|
| 75 |
+
|
| 76 |
+
if self._map_gemma:
|
| 77 |
+
new_sd = {}
|
| 78 |
+
for k, v in sd_obj.sd.items():
|
| 79 |
+
new_sd[_gemma_remap_key(k)] = v
|
| 80 |
+
return dataclasses.replace(sd_obj, sd=new_sd)
|
| 81 |
+
return sd_obj
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------- gguf dit
|
| 84 |
+
# Keep GGUFReaders alive for the process lifetime so their memory-mapped data
|
| 85 |
+
# (which the GGUFLinear weights below reference WITHOUT copying) stays valid.
|
| 86 |
+
_OPEN_GGUF_READERS = []
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _gguf_entries(path):
|
| 90 |
+
r = GGUFReader(path)
|
| 91 |
+
_OPEN_GGUF_READERS.append(r)
|
| 92 |
+
out = {}
|
| 93 |
+
for t in r.tensors:
|
| 94 |
+
out[t.name] = {"data": np.asarray(t.data), "qtype": t.tensor_type,
|
| 95 |
+
"shape": tuple(int(d) for d in reversed(t.shape))}
|
| 96 |
+
return out
|
| 97 |
+
|
| 98 |
+
def _dequant(entry, dtype):
|
| 99 |
+
data = np.asarray(entry["data"])
|
| 100 |
+
q = int(entry["qtype"])
|
| 101 |
+
# Unquantized tensors (norms etc. are stored F32/F16 in the GGUF): skip the
|
| 102 |
+
# old `.astype(np.float32)` which COPIED every one of them into a fresh f32
|
| 103 |
+
# array before making a second bf16 copy. from_numpy on the memmap view is
|
| 104 |
+
# zero-copy; the single .to(dtype) below is the only allocation.
|
| 105 |
+
if q in (int(QT.F32), int(QT.F16)):
|
| 106 |
+
return torch.from_numpy(data).to(dtype)
|
| 107 |
+
# Quantized tensors: prefer city96's pure-torch kernels (they run fine on
|
| 108 |
+
# CPU) dequanting STRAIGHT to the target dtype -- no numpy grouped-rows
|
| 109 |
+
# machinery and no f32 staging copy. This halves peak RAM per tensor vs the
|
| 110 |
+
# old path, which is what was tipping the Windows commit limit during load.
|
| 111 |
+
if _CITY_DEQUANT is not None and _GPU_DEQUANT_OK:
|
| 112 |
+
try:
|
| 113 |
+
raw = torch.from_numpy(data)
|
| 114 |
+
t = _CITY_DEQUANT(raw, QT(q), tuple(entry["shape"]), dtype=dtype)
|
| 115 |
+
return t.to(dtype)
|
| 116 |
+
except Exception:
|
| 117 |
+
pass # fall through to the numpy reference path
|
| 118 |
+
deq = gguf.quants.dequantize(data, QT(q))
|
| 119 |
+
t = torch.from_numpy(deq).to(dtype)
|
| 120 |
+
del deq
|
| 121 |
+
return t
|
| 122 |
+
# NOTE: we deliberately do NOT delete entry["data"] anymore. It is a view
|
| 123 |
+
# into the memory-mapped file (costs no resident RAM), and the post-build
|
| 124 |
+
# meta-materialization sweep needs entries to stay readable.
|
| 125 |
+
|
| 126 |
+
# --- optional GPU dequant kernels, borrowed from city96's ComfyUI-GGUF -------
|
| 127 |
+
# city96 dequantizes packed GGUF weights with pure-torch kernels that run ON THE
|
| 128 |
+
# GPU. That is the single biggest reason his loader is fast and ours was slow:
|
| 129 |
+
# our old path dequantized every weight on the CPU through numpy on EVERY
|
| 130 |
+
# forward. If the user has ComfyUI-GGUF installed (Noah does), import its
|
| 131 |
+
# dequant module and use it; otherwise fall back to the numpy path.
|
| 132 |
+
_CITY_DEQUANT = None
|
| 133 |
+
_GPU_DEQUANT_OK = True # legacy flag (kept for _dequant)
|
| 134 |
+
_GPU_DEQUANT_BAD = set() # qtypes whose GPU kernels failed; per-type, never global
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _patch_gemma3_rope_compat():
|
| 138 |
+
"""transformers >=~4.56 moved per-layer RoPE attrs (rope_local_base_freq)
|
| 139 |
+
into a rope_parameters dict; the LTX/JoyAI libs read the old attribute
|
| 140 |
+
directly. Install a __getattr__ fallback on Gemma3TextConfig that derives
|
| 141 |
+
the value from rope_parameters or returns the Gemma-3 default (10000.0),
|
| 142 |
+
chaining to any pre-existing __getattr__. No-op on transformers versions
|
| 143 |
+
that still have the attribute."""
|
| 144 |
+
try:
|
| 145 |
+
from transformers.models.gemma3.configuration_gemma3 import Gemma3TextConfig
|
| 146 |
+
except Exception:
|
| 147 |
+
return
|
| 148 |
+
if getattr(Gemma3TextConfig, "_rebels_rope_compat", False):
|
| 149 |
+
return
|
| 150 |
+
_orig_ga = Gemma3TextConfig.__getattr__ if "__getattr__" in vars(Gemma3TextConfig) else None
|
| 151 |
+
_base_ga = getattr(super(Gemma3TextConfig, Gemma3TextConfig), "__getattr__", None)
|
| 152 |
+
|
| 153 |
+
def _ga(self, name):
|
| 154 |
+
if name == "rope_local_base_freq":
|
| 155 |
+
rp = self.__dict__.get("rope_parameters")
|
| 156 |
+
if isinstance(rp, dict):
|
| 157 |
+
for key in ("sliding_attention", "local_attention", "local"):
|
| 158 |
+
sub = rp.get(key)
|
| 159 |
+
if isinstance(sub, dict) and sub.get("rope_theta"):
|
| 160 |
+
return sub["rope_theta"]
|
| 161 |
+
return 10000.0
|
| 162 |
+
if _orig_ga is not None:
|
| 163 |
+
return _orig_ga(self, name)
|
| 164 |
+
if _base_ga is not None:
|
| 165 |
+
return _base_ga(self, name)
|
| 166 |
+
raise AttributeError(name)
|
| 167 |
+
|
| 168 |
+
Gemma3TextConfig.__getattr__ = _ga
|
| 169 |
+
Gemma3TextConfig._rebels_rope_compat = True
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
_patch_gemma3_rope_compat()
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def gpu_dequant_supported(qtype_value):
|
| 176 |
+
"""Probe whether city96's GPU kernels can handle this qtype, WITHOUT
|
| 177 |
+
running a real layer. Probes a tiny zeros tensor on CUDA; failures are
|
| 178 |
+
remembered in _GPU_DEQUANT_BAD. Used as a pre-flight so GPU encode/denoise
|
| 179 |
+
never silently grinds on CPU fallback for an unsupported quant type."""
|
| 180 |
+
q = int(qtype_value)
|
| 181 |
+
if q in (int(QT.F32), int(QT.F16)):
|
| 182 |
+
return True
|
| 183 |
+
if _CITY_DEQUANT is None or not torch.cuda.is_available():
|
| 184 |
+
return False
|
| 185 |
+
if q in _GPU_DEQUANT_BAD:
|
| 186 |
+
return False
|
| 187 |
+
try:
|
| 188 |
+
block_size, type_size = gguf.GGML_QUANT_SIZES[QT(q)]
|
| 189 |
+
data = torch.zeros((1, type_size), dtype=torch.uint8, device="cuda")
|
| 190 |
+
_CITY_DEQUANT(data, QT(q), (1, block_size), dtype=torch.bfloat16)
|
| 191 |
+
return True
|
| 192 |
+
except Exception:
|
| 193 |
+
_GPU_DEQUANT_BAD.add(q)
|
| 194 |
+
return False
|
| 195 |
+
try:
|
| 196 |
+
import importlib.util as _ilu
|
| 197 |
+
_cn_dir = os.path.dirname(_LOADER_DIR) # .../custom_nodes
|
| 198 |
+
for _cand in ("ComfyUI-GGUF", "ComfyUI-GGUF-main", "comfyui-gguf"):
|
| 199 |
+
_dq = os.path.join(_cn_dir, _cand, "dequant.py")
|
| 200 |
+
if os.path.isfile(_dq):
|
| 201 |
+
_spec = _ilu.spec_from_file_location("rebels_city96_dequant", _dq)
|
| 202 |
+
_mod = _ilu.module_from_spec(_spec); _spec.loader.exec_module(_mod)
|
| 203 |
+
_CITY_DEQUANT = getattr(_mod, "dequantize", None)
|
| 204 |
+
if _CITY_DEQUANT:
|
| 205 |
+
print(f"[Rebels JE] GPU dequant kernels loaded from {_cand}/dequant.py", flush=True)
|
| 206 |
+
break
|
| 207 |
+
except Exception as _e:
|
| 208 |
+
print(f"[Rebels JE] city96 dequant unavailable ({_e}); using CPU numpy dequant.", flush=True)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class GGUFLinear(nn.Module):
|
| 212 |
+
def __init__(self, entry, bias=None, compute_dtype=torch.bfloat16):
|
| 213 |
+
super().__init__()
|
| 214 |
+
self.qtype_value = int(entry["qtype"]); self.weight_shape = tuple(entry["shape"])
|
| 215 |
+
# The packed weight is kept as a PLAIN attribute (not a registered buffer):
|
| 216 |
+
# - it stays a numpy view into the memory-mapped GGUF file, so it costs
|
| 217 |
+
# ~zero resident RAM (the .copy() that duplicated the whole DiT in RAM
|
| 218 |
+
# is gone for good);
|
| 219 |
+
# - module.to(device) / state_dict / pin_memory all ignore it, so the
|
| 220 |
+
# sequential offloader can shuttle blocks to the GPU without dragging
|
| 221 |
+
# 9GB of packed weights along or pinning them.
|
| 222 |
+
# Each forward streams just this layer's packed bytes to the GPU and
|
| 223 |
+
# dequantizes there (city96 kernels) -- the same per-layer streaming that
|
| 224 |
+
# makes Noah's other big LTX GGUF models run fine on 8GB.
|
| 225 |
+
self._qweight = entry["data"]
|
| 226 |
+
self.bias = nn.Parameter(bias.to(compute_dtype), requires_grad=False) if bias is not None else None
|
| 227 |
+
|
| 228 |
+
def forward(self, x):
|
| 229 |
+
q = self.qtype_value
|
| 230 |
+
# F16 / F32 tensors (llama-quantize leaves some in K-quant files) are
|
| 231 |
+
# not in city96's kernel table -- they don't need kernels at all.
|
| 232 |
+
# Previously ONE of these raised a KeyError that tripped a GLOBAL kill
|
| 233 |
+
# switch, silently dropping every linear in the whole run to CPU numpy
|
| 234 |
+
# dequant (the 40-minute generations). Handle them directly:
|
| 235 |
+
if q in (int(QT.F32), int(QT.F16)):
|
| 236 |
+
w = torch.from_numpy(np.asarray(self._qweight))
|
| 237 |
+
w = w.reshape(self.weight_shape).to(device=x.device, dtype=x.dtype)
|
| 238 |
+
return F.linear(x, w, self.bias.to(x.dtype) if self.bias is not None else None)
|
| 239 |
+
if _CITY_DEQUANT is not None and q not in _GPU_DEQUANT_BAD:
|
| 240 |
+
try:
|
| 241 |
+
data = torch.from_numpy(np.asarray(self._qweight)).to(x.device, non_blocking=True)
|
| 242 |
+
w = _CITY_DEQUANT(data, QT(q), tuple(self.weight_shape), dtype=x.dtype)
|
| 243 |
+
if tuple(w.shape) != tuple(self.weight_shape):
|
| 244 |
+
w = w.reshape(self.weight_shape)
|
| 245 |
+
w = w.to(dtype=x.dtype)
|
| 246 |
+
return F.linear(x, w, self.bias.to(x.dtype) if self.bias is not None else None)
|
| 247 |
+
except Exception as e:
|
| 248 |
+
# disable the GPU path for THIS qtype only -- never globally
|
| 249 |
+
_GPU_DEQUANT_BAD.add(q)
|
| 250 |
+
print(f"[Rebels JE] GPU dequant unavailable for {QT(q).name} "
|
| 251 |
+
f"({type(e).__name__}: {e}); that qtype uses CPU dequant.", flush=True)
|
| 252 |
+
raw = np.asarray(self._qweight)
|
| 253 |
+
w = torch.from_numpy(gguf.quants.dequantize(raw, QT(self.qtype_value)).astype(np.float32))
|
| 254 |
+
w = w.reshape(self.weight_shape).to(device=x.device, dtype=x.dtype)
|
| 255 |
+
return F.linear(x, w, self.bias.to(x.dtype) if self.bias is not None else None)
|
| 256 |
+
|
| 257 |
+
def _set_sub(root, dotted, new):
|
| 258 |
+
*parents, leaf = dotted.split("."); p = root
|
| 259 |
+
for a in parents: p = getattr(p, a)
|
| 260 |
+
setattr(p, leaf, new)
|
| 261 |
+
|
| 262 |
+
class _GGUFDiTLoader(SafetensorsModelStateDictLoader):
|
| 263 |
+
def __init__(self, config, entries, consumed, dtype):
|
| 264 |
+
super().__init__(); self._cfg = config; self._e = entries; self._consumed = consumed; self._dt = dtype
|
| 265 |
+
def metadata(self, path): return self._cfg
|
| 266 |
+
def load(self, paths, sd_ops=None, device=None):
|
| 267 |
+
sd, size, n = {}, 0, 0
|
| 268 |
+
for k, e in self._e.items():
|
| 269 |
+
if k in self._consumed: continue
|
| 270 |
+
t = _dequant(e, self._dt)
|
| 271 |
+
# THE META-TENSOR FIX: leftover GGUF keys still carry JD's checkpoint
|
| 272 |
+
# prefix ("model.diffusion_model."), but the bare LTXModel's module
|
| 273 |
+
# names do not. With model_sd_ops=None nothing strips it, so
|
| 274 |
+
# load_state_dict(strict=False) silently skipped every one of these
|
| 275 |
+
# tensors and they stayed empty 'meta' placeholders -- which is the
|
| 276 |
+
# "Tensor on device meta" crash in patchify_proj. Emit the stripped
|
| 277 |
+
# key alongside the raw one (same tensor object, costs nothing).
|
| 278 |
+
sd[k] = t
|
| 279 |
+
for p in ("model.diffusion_model.", "diffusion_model."):
|
| 280 |
+
if k.startswith(p):
|
| 281 |
+
sd[k[len(p):]] = t
|
| 282 |
+
break
|
| 283 |
+
size += t.numel() * t.element_size()
|
| 284 |
+
n += 1
|
| 285 |
+
if n % 32 == 0:
|
| 286 |
+
# Periodic (not per-tensor) gc: keeps Windows commit pressure
|
| 287 |
+
# down on a 16GB machine without 1700 collector passes.
|
| 288 |
+
gc.collect()
|
| 289 |
+
gc.collect()
|
| 290 |
+
return StateDict(sd=sd, device=device or torch.device("cpu"), size=size, dtype=self._dt)
|
| 291 |
+
|
| 292 |
+
# The bare LTXModel modules are named e.g. "transformer_blocks.0.attn1.to_q",
|
| 293 |
+
# but the GGUF keys keep JD's checkpoint prefix "model.diffusion_model.". The
|
| 294 |
+
# configurator strips that prefix at load time, so we must match across it here
|
| 295 |
+
# or the swap fires on nothing and the whole DiT dequantizes into RAM -> OOM.
|
| 296 |
+
_DIT_PREFIXES = ("", "model.diffusion_model.", "diffusion_model.")
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def _find_entry(entries, base):
|
| 300 |
+
for p in _DIT_PREFIXES:
|
| 301 |
+
k = p + base
|
| 302 |
+
if k in entries:
|
| 303 |
+
return k
|
| 304 |
+
return None
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# Some LTX components (the transformer-args preprocessors) capture DIRECT
|
| 308 |
+
# OBJECT REFERENCES to modules like patchify_proj at construction time, outside
|
| 309 |
+
# the registered module tree. When the mutator swaps those modules for
|
| 310 |
+
# GGUFLinear, the preprocessor keeps pointing at the ORIGINAL meta nn.Linear --
|
| 311 |
+
# invisible to load_state_dict AND to the meta sweep (which is why the sweep
|
| 312 |
+
# reports 0 while the forward still hits a meta tensor). We record every
|
| 313 |
+
# old->new swap and then re-bind stale references across the whole object graph.
|
| 314 |
+
_SWAP_MAP = {}
|
| 315 |
+
|
| 316 |
+
def _rebind_swapped(root, max_objs=50000):
|
| 317 |
+
import types as _types
|
| 318 |
+
if not _SWAP_MAP:
|
| 319 |
+
return 0
|
| 320 |
+
_SKIP = (type, _types.FunctionType, _types.MethodType, _types.BuiltinFunctionType,
|
| 321 |
+
_types.ModuleType, str, bytes, int, float, bool, complex, torch.Tensor,
|
| 322 |
+
np.ndarray)
|
| 323 |
+
seen, queue, fixed = set(), [root], 0
|
| 324 |
+
while queue and len(seen) < max_objs:
|
| 325 |
+
obj = queue.pop()
|
| 326 |
+
oid = id(obj)
|
| 327 |
+
if oid in seen:
|
| 328 |
+
continue
|
| 329 |
+
seen.add(oid)
|
| 330 |
+
containers = []
|
| 331 |
+
d = getattr(obj, "__dict__", None)
|
| 332 |
+
if isinstance(d, dict):
|
| 333 |
+
containers.append(d)
|
| 334 |
+
m = getattr(obj, "_modules", None)
|
| 335 |
+
if isinstance(m, dict) and m is not d:
|
| 336 |
+
containers.append(m)
|
| 337 |
+
for cont in containers:
|
| 338 |
+
for k, v in list(cont.items()):
|
| 339 |
+
nv = _SWAP_MAP.get(id(v))
|
| 340 |
+
if nv is not None and v is not nv:
|
| 341 |
+
cont[k] = nv
|
| 342 |
+
fixed += 1
|
| 343 |
+
continue
|
| 344 |
+
if isinstance(v, (list,)):
|
| 345 |
+
for i, item in enumerate(v):
|
| 346 |
+
nvi = _SWAP_MAP.get(id(item))
|
| 347 |
+
if nvi is not None and item is not nvi:
|
| 348 |
+
v[i] = nvi
|
| 349 |
+
fixed += 1
|
| 350 |
+
elif isinstance(item, nn.Module):
|
| 351 |
+
queue.append(item)
|
| 352 |
+
continue
|
| 353 |
+
if isinstance(v, dict):
|
| 354 |
+
for dk, item in list(v.items()):
|
| 355 |
+
nvi = _SWAP_MAP.get(id(item))
|
| 356 |
+
if nvi is not None and item is not nvi:
|
| 357 |
+
v[dk] = nvi
|
| 358 |
+
fixed += 1
|
| 359 |
+
elif isinstance(item, nn.Module):
|
| 360 |
+
queue.append(item)
|
| 361 |
+
continue
|
| 362 |
+
if isinstance(v, _SKIP) or v is None:
|
| 363 |
+
continue
|
| 364 |
+
if isinstance(v, nn.Module) or hasattr(v, "__dict__"):
|
| 365 |
+
queue.append(v)
|
| 366 |
+
if fixed:
|
| 367 |
+
print(f"[Rebels JE] re-bound {fixed} stale reference(s) to swapped GGUF layers.", flush=True)
|
| 368 |
+
return fixed
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _dit_module_ops(entries, consumed, compute_dtype):
|
| 372 |
+
def mutator(model):
|
| 373 |
+
n_lin = n_hit = 0
|
| 374 |
+
miss = []
|
| 375 |
+
for name, mod in list(model.named_modules()):
|
| 376 |
+
if not isinstance(mod, nn.Linear):
|
| 377 |
+
continue
|
| 378 |
+
n_lin += 1
|
| 379 |
+
wk = _find_entry(entries, name + ".weight")
|
| 380 |
+
if wk is None:
|
| 381 |
+
if len(miss) < 5:
|
| 382 |
+
miss.append(name)
|
| 383 |
+
continue
|
| 384 |
+
bk = _find_entry(entries, name + ".bias")
|
| 385 |
+
bias = _dequant(entries[bk], compute_dtype) if bk else None
|
| 386 |
+
new_mod = GGUFLinear(entries[wk], bias, compute_dtype)
|
| 387 |
+
_SWAP_MAP[id(mod)] = new_mod
|
| 388 |
+
_set_sub(model, name, new_mod)
|
| 389 |
+
consumed.add(wk)
|
| 390 |
+
if bk:
|
| 391 |
+
consumed.add(bk)
|
| 392 |
+
n_hit += 1
|
| 393 |
+
print(f"[Rebels JE] DiT GGUF swap: matched {n_hit}/{n_lin} Linear layers "
|
| 394 |
+
f"({len(consumed)} tensors kept packed).", flush=True)
|
| 395 |
+
if n_hit == 0 and n_lin:
|
| 396 |
+
print(f"[Rebels JE] NO matches -> whole DiT would dequantize. "
|
| 397 |
+
f"sample model Linears={miss}", flush=True)
|
| 398 |
+
print(f"[Rebels JE] sample GGUF keys={list(entries.keys())[:5]}", flush=True)
|
| 399 |
+
return model
|
| 400 |
+
return (ModuleOps("gguf_linear_swap", matcher=lambda m: True, mutator=mutator),)
|
| 401 |
+
|
| 402 |
+
# ---------------------------------------------------------------- gemma fp8
|
| 403 |
+
class Fp8Linear(nn.Module):
|
| 404 |
+
def __init__(self, qweight_u8, shape, scale, bias=None, compute_dtype=torch.bfloat16):
|
| 405 |
+
super().__init__(); self.weight_shape = tuple(shape)
|
| 406 |
+
self.register_buffer("qweight", qweight_u8)
|
| 407 |
+
self.register_buffer("scale_weight", torch.tensor(float(scale), dtype=torch.float32))
|
| 408 |
+
self.bias = nn.Parameter(bias.to(compute_dtype), requires_grad=False) if bias is not None else None
|
| 409 |
+
def forward(self, x):
|
| 410 |
+
w = self.qweight.view(torch.float8_e4m3fn).reshape(self.weight_shape).to(torch.float32) * self.scale_weight
|
| 411 |
+
w = w.to(device=x.device, dtype=x.dtype)
|
| 412 |
+
return F.linear(x, w, self.bias.to(x.dtype) if self.bias is not None else None)
|
| 413 |
+
|
| 414 |
+
def _swap_gemma_gguf(model, gguf_path, compute_dtype):
|
| 415 |
+
"""Load Gemma from OUR quantized GGUF (made by make_gemma_gguf.py, which
|
| 416 |
+
preserves HF key names -- so matching is direct, no llama.cpp renaming).
|
| 417 |
+
Every nn.Linear becomes a packed GGUFLinear backed by the memory-mapped
|
| 418 |
+
file (~zero resident RAM); embeddings/norms are dequanted to compute dtype.
|
| 419 |
+
Resident footprint ~2.5GB vs ~12GB for the fp8 path."""
|
| 420 |
+
raw = _gguf_entries(gguf_path)
|
| 421 |
+
entries = {}
|
| 422 |
+
for k, v in raw.items():
|
| 423 |
+
entries[_gemma_remap_key(k)] = v
|
| 424 |
+
entries.setdefault(k, v) # keep originals too; harmless duplicates
|
| 425 |
+
names = list(entries.keys())
|
| 426 |
+
|
| 427 |
+
def find(base):
|
| 428 |
+
if base in entries:
|
| 429 |
+
return base
|
| 430 |
+
cands = [n for n in names if n.endswith("." + base) or base.endswith("." + n)]
|
| 431 |
+
return cands[0] if len(cands) == 1 else None
|
| 432 |
+
|
| 433 |
+
used = set()
|
| 434 |
+
n_hit = n_lin = 0
|
| 435 |
+
for name, mod in list(model.named_modules()):
|
| 436 |
+
if not isinstance(mod, nn.Linear):
|
| 437 |
+
continue
|
| 438 |
+
n_lin += 1
|
| 439 |
+
wk = find(name + ".weight")
|
| 440 |
+
if wk is None:
|
| 441 |
+
continue
|
| 442 |
+
bk = find(name + ".bias")
|
| 443 |
+
if bk is not None:
|
| 444 |
+
bias = _dequant(entries[bk], compute_dtype)
|
| 445 |
+
used.add(bk)
|
| 446 |
+
elif mod.bias is not None and mod.bias.device.type != "meta":
|
| 447 |
+
bias = mod.bias.detach()
|
| 448 |
+
else:
|
| 449 |
+
bias = None
|
| 450 |
+
_set_sub(model, name, GGUFLinear(entries[wk], bias, compute_dtype))
|
| 451 |
+
used.add(wk)
|
| 452 |
+
n_hit += 1
|
| 453 |
+
print(f"[Rebels JE] Gemma GGUF swap: matched {n_hit}/{n_lin} Linear layers "
|
| 454 |
+
f"(packed, memory-mapped).", flush=True)
|
| 455 |
+
if n_hit == 0 and n_lin:
|
| 456 |
+
print(f"[Rebels JE] sample GGUF keys={names[:5]}", flush=True)
|
| 457 |
+
# Fill everything else (embeddings, norms) from the GGUF. vision_tower /
|
| 458 |
+
# multi_modal_projector / lm_head have no weights anywhere and get stripped
|
| 459 |
+
# by the staged node right after this -- skip them instead of aborting.
|
| 460 |
+
_materialize_meta(model, entries, used, compute_dtype, strict=False,
|
| 461 |
+
skip_substrings=("vision_tower", "multi_modal_projector", "lm_head"))
|
| 462 |
+
return model
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
def _swap_gemma_fp8(model, fp8_dir, compute_dtype):
|
| 466 |
+
from safetensors import safe_open
|
| 467 |
+
from pathlib import Path
|
| 468 |
+
shards = sorted(Path(fp8_dir).glob("model*.safetensors"))
|
| 469 |
+
scales = {}
|
| 470 |
+
for sh in shards:
|
| 471 |
+
with safe_open(str(sh), framework="pt") as f:
|
| 472 |
+
for k in f.keys():
|
| 473 |
+
if k.endswith(".scale_weight"): scales[k[:-len(".scale_weight")]] = float(f.get_tensor(k))
|
| 474 |
+
n = 0
|
| 475 |
+
for name, mod in list(model.named_modules()):
|
| 476 |
+
if isinstance(mod, nn.Linear) and name in scales:
|
| 477 |
+
for sh in shards:
|
| 478 |
+
with safe_open(str(sh), framework="pt") as f:
|
| 479 |
+
if name in f.keys():
|
| 480 |
+
qw = f.get_tensor(name); bias = mod.bias.detach() if mod.bias is not None else None
|
| 481 |
+
_set_sub(model, name, Fp8Linear(qw.view(torch.uint8), qw.shape, scales[name], bias, compute_dtype))
|
| 482 |
+
n += 1; break
|
| 483 |
+
return model
|
| 484 |
+
|
| 485 |
+
# ================================================================ NODES
|
| 486 |
+
def _dev(): return torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
|
| 487 |
+
|
| 488 |
+
class RebelsJE_Config:
|
| 489 |
+
CATEGORY = CAT; RETURN_TYPES = ("JOYECHO_CONFIG",); RETURN_NAMES = ("config",); FUNCTION = "run"
|
| 490 |
+
@classmethod
|
| 491 |
+
def INPUT_TYPES(cls):
|
| 492 |
+
return {"required": {"config_source": ("STRING", {"default": _LOADER_CFG})}}
|
| 493 |
+
def run(self, config_source): return (_full_config(config_source),)
|
| 494 |
+
|
| 495 |
+
def _materialize_meta(root, entries, consumed, dtype, strict=True, skip_substrings=()):
|
| 496 |
+
"""Safety net: find every parameter/buffer still on the 'meta' device after
|
| 497 |
+
build and fill it with real data from the GGUF. Resolves names by trying the
|
| 498 |
+
known checkpoint prefixes first, then by unique longest-suffix match (covers
|
| 499 |
+
modules the configurator registers under different paths, e.g. preprocessor
|
| 500 |
+
wrappers around patchify_proj). Raises a CLEAR error naming any weight it
|
| 501 |
+
cannot find, instead of letting a cryptic 'Tensor on device meta' crash
|
| 502 |
+
happen 40 minutes into a run."""
|
| 503 |
+
leftover = [k for k in entries if k not in consumed]
|
| 504 |
+
fixed, missing = 0, []
|
| 505 |
+
|
| 506 |
+
def resolve(pname):
|
| 507 |
+
k = _find_entry(entries, pname)
|
| 508 |
+
if k is not None and k not in consumed:
|
| 509 |
+
return k
|
| 510 |
+
parts = pname.split(".")
|
| 511 |
+
for i in range(len(parts)):
|
| 512 |
+
suf = ".".join(parts[i:])
|
| 513 |
+
cands = [n for n in leftover if n == suf or n.endswith("." + suf)]
|
| 514 |
+
if len(cands) == 1:
|
| 515 |
+
return cands[0]
|
| 516 |
+
return None
|
| 517 |
+
|
| 518 |
+
items = [(n, p, True) for n, p in root.named_parameters()] \
|
| 519 |
+
+ [(n, b, False) for n, b in root.named_buffers()]
|
| 520 |
+
skipped = 0
|
| 521 |
+
for name, t, is_param in items:
|
| 522 |
+
if t is None or t.device.type != "meta":
|
| 523 |
+
continue
|
| 524 |
+
if any(sub in name for sub in skip_substrings):
|
| 525 |
+
skipped += 1
|
| 526 |
+
continue
|
| 527 |
+
k = resolve(name)
|
| 528 |
+
if k is None:
|
| 529 |
+
missing.append(name)
|
| 530 |
+
continue
|
| 531 |
+
new = _dequant(entries[k], dtype)
|
| 532 |
+
if new.numel() == t.numel() and tuple(new.shape) != tuple(t.shape):
|
| 533 |
+
new = new.reshape(t.shape)
|
| 534 |
+
*path, leaf = name.split(".")
|
| 535 |
+
mod = root
|
| 536 |
+
for a in path:
|
| 537 |
+
mod = getattr(mod, a)
|
| 538 |
+
if is_param:
|
| 539 |
+
mod._parameters[leaf] = nn.Parameter(new, requires_grad=False)
|
| 540 |
+
else:
|
| 541 |
+
mod._buffers[leaf] = new
|
| 542 |
+
fixed += 1
|
| 543 |
+
|
| 544 |
+
print(f"[Rebels JE] materialized {fixed} meta tensors from GGUF"
|
| 545 |
+
+ (f" ({skipped} skipped by filter)" if skipped else "") + ".", flush=True)
|
| 546 |
+
if missing and not strict:
|
| 547 |
+
print(f"[Rebels JE] WARNING: {len(missing)} tensors left unresolved "
|
| 548 |
+
f"(non-strict): {missing[:6]}", flush=True)
|
| 549 |
+
if missing and strict:
|
| 550 |
+
raise RuntimeError(
|
| 551 |
+
f"[Rebels JE] {len(missing)} model weights are still empty (meta) and "
|
| 552 |
+
f"could not be located in the GGUF: {missing[:8]}. The GGUF may be "
|
| 553 |
+
f"missing these tensors -- re-check the quantization export.")
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
class RebelsJE_DiTLoader:
|
| 557 |
+
CATEGORY = CAT; RETURN_TYPES = ("JOYECHO_GENERATOR",); RETURN_NAMES = ("generator",); FUNCTION = "run"
|
| 558 |
+
@classmethod
|
| 559 |
+
def INPUT_TYPES(cls):
|
| 560 |
+
return {"required": {
|
| 561 |
+
"config": ("JOYECHO_CONFIG",),
|
| 562 |
+
"dit_gguf": ("STRING", {"default": ""}),
|
| 563 |
+
"video_height": ("INT", {"default": 736}), "video_width": ("INT", {"default": 1280})}}
|
| 564 |
+
def run(self, config, dit_gguf, video_height, video_width):
|
| 565 |
+
dtype = torch.bfloat16
|
| 566 |
+
_SWAP_MAP.clear() # ids are only valid for THIS build
|
| 567 |
+
entries = _gguf_entries(dit_gguf); consumed = set()
|
| 568 |
+
builder = Builder(
|
| 569 |
+
model_class_configurator=LTXModelConfigurator,
|
| 570 |
+
model_path=dit_gguf,
|
| 571 |
+
model_sd_ops=None,
|
| 572 |
+
module_ops=_dit_module_ops(entries, consumed, dtype),
|
| 573 |
+
model_loader=_GGUFDiTLoader(config, entries, consumed, dtype),
|
| 574 |
+
)
|
| 575 |
+
transformer = builder.build(device=torch.device("cpu"), dtype=dtype)
|
| 576 |
+
gen = LTX2DiffusionWrapper(model=X0Model(transformer), video_height=video_height, video_width=video_width)
|
| 577 |
+
gen.eval()
|
| 578 |
+
# Sweep the FULL wrapper (not just the transformer) so anything the
|
| 579 |
+
# configurator or wrapper registered late gets real weights too.
|
| 580 |
+
_materialize_meta(gen, entries, consumed, dtype)
|
| 581 |
+
# Fix stale direct references (e.g. args-preprocessor patchify_proj)
|
| 582 |
+
# that still point at pre-swap meta modules.
|
| 583 |
+
_rebind_swapped(gen)
|
| 584 |
+
_SWAP_MAP.clear()
|
| 585 |
+
return (gen,)
|
| 586 |
+
|
| 587 |
+
class RebelsJE_TextEncoder:
|
| 588 |
+
CATEGORY = CAT; RETURN_TYPES = ("JOYECHO_TEXTENC",); RETURN_NAMES = ("text_encoder",); FUNCTION = "run"
|
| 589 |
+
@classmethod
|
| 590 |
+
def INPUT_TYPES(cls):
|
| 591 |
+
return {"required": {
|
| 592 |
+
"config": ("JOYECHO_CONFIG",),
|
| 593 |
+
"gemma_path": ("STRING", {"default": ""}),
|
| 594 |
+
"gemma_format": (["our_fp8", "bf16"], {"default": "our_fp8"}),
|
| 595 |
+
"connector_path": ("STRING", {"default": ""}),
|
| 596 |
+
"low_vram": ("BOOLEAN", {"default": True})}}
|
| 597 |
+
def run(self, config, gemma_path, gemma_format, connector_path, low_vram):
|
| 598 |
+
from ltx_core.text_encoders.gemma import GemmaTextEncoderConfigurator, GEMMA_MODEL_OPS, module_ops_from_gemma_root
|
| 599 |
+
from ltx_core.utils import find_matching_file
|
| 600 |
+
from pathlib import Path
|
| 601 |
+
|
| 602 |
+
dtype = torch.bfloat16; dev = torch.device("cpu") if low_vram else _dev()
|
| 603 |
+
|
| 604 |
+
# --- SINGLE FILE INTAKE PATCH START ---
|
| 605 |
+
gemma_path_str = str(gemma_path)
|
| 606 |
+
is_gguf = gemma_path_str.lower().endswith(".gguf")
|
| 607 |
+
if os.path.isfile(gemma_path_str):
|
| 608 |
+
parent_dir = os.path.dirname(gemma_path_str)
|
| 609 |
+
temp_folder = os.path.join(parent_dir, ".gemma_virtual_folder")
|
| 610 |
+
os.makedirs(temp_folder, exist_ok=True)
|
| 611 |
+
|
| 612 |
+
temp_model = os.path.join(temp_folder, "model.safetensors")
|
| 613 |
+
if os.path.exists(temp_model):
|
| 614 |
+
try: os.remove(temp_model)
|
| 615 |
+
except OSError: pass
|
| 616 |
+
|
| 617 |
+
# GGUF gemma: do NOT link the weights as model.safetensors (the
|
| 618 |
+
# builder would try to parse a GGUF as safetensors). The virtual
|
| 619 |
+
# folder only carries the HF sidecars; weights come from the GGUF.
|
| 620 |
+
if not is_gguf:
|
| 621 |
+
try: os.link(gemma_path_str, temp_model)
|
| 622 |
+
except OSError:
|
| 623 |
+
import shutil
|
| 624 |
+
shutil.copyfile(gemma_path_str, temp_model)
|
| 625 |
+
|
| 626 |
+
# Gemma needs its HF sidecar files (tokenizer + config jsons) next to
|
| 627 |
+
# the weights. A single-file fp8 download has none of them, so we search
|
| 628 |
+
# several places, in order: the weights' own folder, a 'gemma_assets' or
|
| 629 |
+
# 'gemma' subfolder beside them, and a 'gemma_assets' folder bundled in
|
| 630 |
+
# this node pack (so they can ship with the pack).
|
| 631 |
+
_node_dir = os.path.dirname(os.path.abspath(__file__))
|
| 632 |
+
sidecar_sources = [
|
| 633 |
+
parent_dir,
|
| 634 |
+
os.path.join(parent_dir, "gemma_assets"),
|
| 635 |
+
os.path.join(parent_dir, "gemma"),
|
| 636 |
+
os.path.join(_node_dir, "gemma_assets"),
|
| 637 |
+
]
|
| 638 |
+
sidecar_files = ["tokenizer.model", "tokenizer_config.json", "config.json",
|
| 639 |
+
"special_tokens_map.json", "preprocessor_config.json"]
|
| 640 |
+
def _normalize_gemma_config(path):
|
| 641 |
+
"""Load ANY Gemma-3 config.json (our bundled text-only one OR the
|
| 642 |
+
multimodal one from google/gemma-3-12b-it that users often
|
| 643 |
+
download) and return the text-only dict this pipeline needs,
|
| 644 |
+
with every field JD's encoder configurator reads guaranteed
|
| 645 |
+
present. Returns None if the file isn't a Gemma-3 config at all."""
|
| 646 |
+
import json as _json
|
| 647 |
+
try:
|
| 648 |
+
with open(path, "r", encoding="utf-8") as fh:
|
| 649 |
+
cj = _json.load(fh)
|
| 650 |
+
except Exception:
|
| 651 |
+
return None
|
| 652 |
+
if "gemma3" not in str(cj.get("model_type", "")) and "text_config" not in cj:
|
| 653 |
+
return None
|
| 654 |
+
# multimodal google layout -> pull the nested text config
|
| 655 |
+
if "text_config" in cj and isinstance(cj["text_config"], dict):
|
| 656 |
+
cj = dict(cj["text_config"])
|
| 657 |
+
cj["model_type"] = "gemma3_text"
|
| 658 |
+
cj.setdefault("architectures", ["Gemma3ForCausalLM"])
|
| 659 |
+
# fields the encoder configurator / rotary init require:
|
| 660 |
+
rs = cj.get("rope_scaling")
|
| 661 |
+
if not isinstance(rs, dict):
|
| 662 |
+
rs = {"rope_type": "linear", "factor": 8.0}
|
| 663 |
+
if "rope_type" not in rs:
|
| 664 |
+
rs["rope_type"] = rs.get("type", "linear")
|
| 665 |
+
cj["rope_scaling"] = rs
|
| 666 |
+
cj.setdefault("rope_local_base_freq", 10000.0)
|
| 667 |
+
cj.setdefault("rope_theta", 1000000.0)
|
| 668 |
+
return cj
|
| 669 |
+
|
| 670 |
+
for t_file in sidecar_files:
|
| 671 |
+
dst = os.path.join(temp_folder, t_file)
|
| 672 |
+
if os.path.exists(dst):
|
| 673 |
+
continue
|
| 674 |
+
for srcdir in sidecar_sources:
|
| 675 |
+
src = os.path.join(srcdir, t_file)
|
| 676 |
+
if os.path.exists(src):
|
| 677 |
+
if t_file == "config.json":
|
| 678 |
+
cj = _normalize_gemma_config(src)
|
| 679 |
+
if cj is None:
|
| 680 |
+
print(f"[Rebels JE] skipping {src}: not a Gemma-3 "
|
| 681 |
+
f"config.", flush=True)
|
| 682 |
+
continue
|
| 683 |
+
import json as _json
|
| 684 |
+
with open(dst, "w", encoding="utf-8") as fh:
|
| 685 |
+
_json.dump(cj, fh, indent=2)
|
| 686 |
+
if "text_config" not in cj:
|
| 687 |
+
pass # already text-only
|
| 688 |
+
print(f"[Rebels JE] config.json normalized from {src} "
|
| 689 |
+
f"(text-only layout, rotary fields ensured).", flush=True)
|
| 690 |
+
break
|
| 691 |
+
try: os.link(src, dst)
|
| 692 |
+
except OSError:
|
| 693 |
+
import shutil
|
| 694 |
+
shutil.copyfile(src, dst)
|
| 695 |
+
break
|
| 696 |
+
|
| 697 |
+
# Fail with a clear, actionable message instead of a cryptic one later.
|
| 698 |
+
missing = [f for f in sidecar_files
|
| 699 |
+
if not os.path.exists(os.path.join(temp_folder, f))]
|
| 700 |
+
if missing:
|
| 701 |
+
raise FileNotFoundError(
|
| 702 |
+
"Gemma sidecar files missing: " + ", ".join(missing) + ".\n"
|
| 703 |
+
"Put them in one of:\n"
|
| 704 |
+
f" - the same folder as your Gemma file ({parent_dir})\n"
|
| 705 |
+
f" - {os.path.join(_node_dir, 'gemma_assets')} (ships with the node pack)\n"
|
| 706 |
+
"These ship inside the node pack's gemma_assets folder -- if they're "
|
| 707 |
+
"missing, re-download the pack from GitHub rather than pulling configs "
|
| 708 |
+
"from the google repo (google's config.json is the multimodal variant "
|
| 709 |
+
"and is NOT compatible with this pipeline)."
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
model_folder = Path(temp_folder)
|
| 713 |
+
gemma_op_path = str(temp_folder)
|
| 714 |
+
else:
|
| 715 |
+
model_folder = find_matching_file(gemma_path, "model*.safetensors").parent
|
| 716 |
+
gemma_op_path = gemma_path
|
| 717 |
+
# --- SINGLE FILE INTAKE PATCH END ---
|
| 718 |
+
|
| 719 |
+
weight_paths = (_GGUF_MARKER,) if is_gguf else tuple(str(p) for p in model_folder.rglob("*.safetensors"))
|
| 720 |
+
te_builder = Builder(
|
| 721 |
+
model_class_configurator=GemmaTextEncoderConfigurator,
|
| 722 |
+
model_path=weight_paths,
|
| 723 |
+
module_ops=(GEMMA_MODEL_OPS, *module_ops_from_gemma_root(gemma_op_path)),
|
| 724 |
+
model_loader=_CfgLoader(config, map_gemma=True),
|
| 725 |
+
)
|
| 726 |
+
text_encoder = te_builder.build(device=dev, dtype=dtype)
|
| 727 |
+
if is_gguf:
|
| 728 |
+
_swap_gemma_gguf(text_encoder, gemma_path_str, dtype)
|
| 729 |
+
elif gemma_format == "our_fp8":
|
| 730 |
+
_swap_gemma_fp8(text_encoder, str(model_folder), dtype)
|
| 731 |
+
|
| 732 |
+
# META-TENSOR FIX: the text-only Gemma file has no vision_tower /
|
| 733 |
+
# multi_modal_projector / lm_head, so those modules stay on the meta
|
| 734 |
+
# device and poison model.device (vision_tower is the first registered
|
| 735 |
+
# param). base_encoder.encode() then builds input_ids/attention_mask on
|
| 736 |
+
# meta and dies with "Cannot copy out of meta tensor". None are used for
|
| 737 |
+
# text encoding -- drop them so the first real parameter (language_model)
|
| 738 |
+
# defines the device. (StagedJE does this after calling .run(); doing it
|
| 739 |
+
# here fixes the discrete multishot path too. Idempotent: StagedJE's own
|
| 740 |
+
# strip becomes a no-op.)
|
| 741 |
+
try:
|
| 742 |
+
gm = getattr(text_encoder, "model", None)
|
| 743 |
+
if gm is not None:
|
| 744 |
+
inner = getattr(gm, "model", None)
|
| 745 |
+
for parent, attr in ((inner, "vision_tower"),
|
| 746 |
+
(inner, "multi_modal_projector"),
|
| 747 |
+
(gm, "lm_head")):
|
| 748 |
+
if parent is not None and getattr(parent, attr, None) is not None:
|
| 749 |
+
try: setattr(parent, attr, None)
|
| 750 |
+
except Exception: pass
|
| 751 |
+
try:
|
| 752 |
+
print(f"[Rebels JE] text-encoder device after meta-strip = "
|
| 753 |
+
f"{next(gm.parameters()).device}", flush=True)
|
| 754 |
+
except StopIteration:
|
| 755 |
+
pass
|
| 756 |
+
except Exception as e:
|
| 757 |
+
print(f"[Rebels JE] meta-strip skipped: {e}", flush=True)
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
ep_builder = Builder(
|
| 761 |
+
model_class_configurator=EmbeddingsProcessorConfigurator,
|
| 762 |
+
model_path=connector_path, model_sd_ops=EMBEDDINGS_PROCESSOR_KEY_OPS,
|
| 763 |
+
model_loader=_CfgLoader(config, map_gemma=False),
|
| 764 |
+
)
|
| 765 |
+
embeddings_processor = ep_builder.build(device=dev, dtype=dtype)
|
| 766 |
+
|
| 767 |
+
# DEVICE UNIFY: the wrapper's forward feeds Gemma hidden_states straight
|
| 768 |
+
# into embeddings_processor with no device move (it never uses
|
| 769 |
+
# self.device). A GGUF-swapped Gemma effectively runs on CPU (packed
|
| 770 |
+
# layers stream per-layer), so hidden_states land on CPU while the
|
| 771 |
+
# connector was built on `dev` (cuda when low_vram=False) -> a
|
| 772 |
+
# cpu-vs-cuda addmm crash in feature_extractor. Pin embeddings_processor
|
| 773 |
+
# to the text encoder's ACTUAL device so both halves match. (StagedJE
|
| 774 |
+
# unifies both halves the same way after calling .run().)
|
| 775 |
+
try:
|
| 776 |
+
_te_dev = next(text_encoder.model.parameters()).device
|
| 777 |
+
embeddings_processor.to(_te_dev)
|
| 778 |
+
print(f"[Rebels JE] embeddings_processor pinned to {_te_dev} "
|
| 779 |
+
f"(matches text encoder).", flush=True)
|
| 780 |
+
except StopIteration:
|
| 781 |
+
_te_dev = _dev()
|
| 782 |
+
|
| 783 |
+
wrapper = GemmaTextEncoderWrapper(text_encoder=text_encoder, embeddings_processor=embeddings_processor,
|
| 784 |
+
device=_te_dev, dtype=dtype)
|
| 785 |
+
return (wrapper,)
|
| 786 |
+
|
| 787 |
+
class RebelsJE_VAELoader:
|
| 788 |
+
CATEGORY = CAT; RETURN_TYPES = ("JOYECHO_VVAE", "JOYECHO_AVAE", "INT"); RETURN_NAMES = ("video_vae", "audio_vae", "audio_sample_rate"); FUNCTION = "run"
|
| 789 |
+
@classmethod
|
| 790 |
+
def INPUT_TYPES(cls):
|
| 791 |
+
return {"required": {
|
| 792 |
+
"config": ("JOYECHO_CONFIG",),
|
| 793 |
+
"video_vae_path": ("STRING", {"default": ""}),
|
| 794 |
+
"audio_vae_path": ("STRING", {"default": ""}),
|
| 795 |
+
"vocoder_path": ("STRING", {"default": ""}),
|
| 796 |
+
"with_encoders": ("BOOLEAN", {"default": True})}}
|
| 797 |
+
def _build(self, cfg, configurator, sd_ops, path):
|
| 798 |
+
return Builder(model_class_configurator=configurator, model_path=path,
|
| 799 |
+
model_sd_ops=sd_ops, model_loader=_CfgLoader(cfg)).build(
|
| 800 |
+
device=torch.device("cpu"), dtype=torch.bfloat16)
|
| 801 |
+
def run(self, config, video_vae_path, audio_vae_path, vocoder_path, with_encoders):
|
| 802 |
+
dtype = torch.bfloat16
|
| 803 |
+
v_dec = self._build(config, VideoDecoderConfigurator, VAE_DECODER_COMFY_KEYS_FILTER, video_vae_path)
|
| 804 |
+
a_dec = self._build(config, AudioDecoderConfigurator, AUDIO_VAE_DECODER_COMFY_KEYS_FILTER, audio_vae_path)
|
| 805 |
+
voc = self._build(config, VocoderConfigurator, VOCODER_COMFY_KEYS_FILTER, vocoder_path)
|
| 806 |
+
v_enc = self._build(config, VideoEncoderConfigurator, VAE_ENCODER_COMFY_KEYS_FILTER, video_vae_path) if with_encoders else None
|
| 807 |
+
a_enc = self._build(config, AudioEncoderConfigurator, AUDIO_VAE_ENCODER_COMFY_KEYS_FILTER, audio_vae_path) if with_encoders else None
|
| 808 |
+
video_vae = VideoVAEWrapper(encoder=v_enc, decoder=v_dec, device=_dev(), dtype=dtype)
|
| 809 |
+
audio_vae = AudioVAEWrapper(encoder=a_enc, decoder=a_dec, vocoder=voc, device=_dev(), dtype=dtype)
|
| 810 |
+
video_vae.eval(); audio_vae.eval()
|
| 811 |
+
sr = audio_vae.get_output_sample_rate() or 24000
|
| 812 |
+
return (video_vae, audio_vae, sr)
|
| 813 |
+
|
| 814 |
+
class RebelsJE_Assemble:
|
| 815 |
+
CATEGORY = CAT; RETURN_TYPES = ("JOYECHO_MODEL",); RETURN_NAMES = ("model",); FUNCTION = "run"
|
| 816 |
+
@classmethod
|
| 817 |
+
def INPUT_TYPES(cls):
|
| 818 |
+
return {"required": {"generator": ("JOYECHO_GENERATOR",), "text_encoder": ("JOYECHO_TEXTENC",),
|
| 819 |
+
"video_vae": ("JOYECHO_VVAE",), "audio_vae": ("JOYECHO_AVAE",),
|
| 820 |
+
"audio_sample_rate": ("INT", {"default": 24000})}}
|
| 821 |
+
def run(self, generator, text_encoder, video_vae, audio_vae, audio_sample_rate):
|
| 822 |
+
model = {"text_encoder": text_encoder, "generator": generator, "video_vae": video_vae,
|
| 823 |
+
"audio_vae": audio_vae, "audio_sample_rate": audio_sample_rate,
|
| 824 |
+
"device": _dev(), "dtype": torch.bfloat16}
|
| 825 |
+
return (model,)
|
| 826 |
+
|
| 827 |
+
NODE_CLASS_MAPPINGS = {
|
| 828 |
+
"RebelsJE_Config": RebelsJE_Config,
|
| 829 |
+
"RebelsJE_DiTLoader": RebelsJE_DiTLoader,
|
| 830 |
+
"RebelsJE_TextEncoder": RebelsJE_TextEncoder,
|
| 831 |
+
"RebelsJE_VAELoader": RebelsJE_VAELoader,
|
| 832 |
+
"RebelsJE_Assemble": RebelsJE_Assemble,
|
| 833 |
+
}
|
| 834 |
+
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 835 |
+
"RebelsJE_Config": "Rebels JE • Config",
|
| 836 |
+
"RebelsJE_DiTLoader": "Rebels JE • DiT GGUF Loader (UNet)",
|
| 837 |
+
"RebelsJE_TextEncoder": "Rebels JE • Text Encoder (Gemma fp8 + Connector)",
|
| 838 |
+
"RebelsJE_VAELoader": "Rebels JE • VAE Loader (video+audio)",
|
| 839 |
+
"RebelsJE_Assemble": "Rebels JE • Assemble Model",
|
| 840 |
+
}
|