joeygambino commited on
Commit
38fe5dd
·
verified ·
1 Parent(s): edab639

Upload 11 files

Browse files
README.md CHANGED
@@ -1,3 +1,173 @@
1
  ---
2
- license: apache-2.0
 
 
 
 
 
 
 
 
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
+ }