Skip to content

Commit 9637c1f

Browse files
committed
Merge mudler/feat/ltx-video PR leejet#1459 - Add LTX-2/LTX-2.3 video generation support
2 parents 8fc81dd + 8363e79 commit 9637c1f

19 files changed

Lines changed: 3775 additions & 48 deletions

‎docs/ltxv.md‎

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# LTX-Video 2.3 support — conditional text-to-video works end-to-end
2+
3+
Branch: `feat/ltx-video` in
4+
<https://github.com/mudler/stable-diffusion.cpp>. Ports Lightricks' LTX-2.3
5+
22B audio-video foundation model (`Lightricks/LTX-2.3`) to
6+
stable-diffusion.cpp, video-only path. **Text conditioning wired via a
7+
native Gemma-3-12B port** so prompts actually steer the output.
8+
9+
## Status — prompts generate the thing you asked for
10+
11+
Validated on an NVIDIA GB10 (Grace Blackwell, CUDA 13, 119 GB unified memory)
12+
with `ltx-2.3-22b-distilled.safetensors` (46 GB BF16) + Gemma-3-12B-it
13+
(24 GB BF16) as text encoder:
14+
15+
| Stage | Result |
16+
|---|---|
17+
| LTX version detection (`model.cpp`) | `VERSION_LTXV2` detected on `audio_scale_shift_table` / `audio_patchify_proj` / `audio_adaln_single` / `av_ca_video_scale_shift_adaln_single` / `video_embeddings_connector` |
18+
| Weight registration | 4444 transformer + 170 VAE + 4 text_embedding_projection tensors registered — **zero missing, zero shape mismatches** vs. the 22B checkpoint |
19+
| Checkpoint load | 46 GB BF16 loads in ~9 s; audio_vae / vocoder ignored (video-only pipeline) |
20+
| Gemma-3-12B text encoder | Loads + runs in 5 s on GB10; 49-layer hidden states match HuggingFace to bf16 precision; `text_embedding_projection.video_aggregate_embed` output: std=6.828 (HF: 6.830) |
21+
| Transformer forward | 48 layers × 32 heads × 128 head-dim (inner_dim 4096), 8 distilled steps in 123 s on GB10 |
22+
| VAE decode | 9-block decoder with per-channel RMS norm + proper 3-D depth-to-space; 16-frame latent → 121-frame video in 16 s |
23+
| End-to-end | 704×480×9 WebP in ~14 s; 768×512×121 WebP in ~140 s on GB10; **prompts generate the described subject** (cat → cat, dragon → dragon, etc.) |
24+
| Quantization | BF16 46 GB → q8_0 28.3 GB via `sd-cli -M convert --type q8_0` in 9.6 s; q8_0 GGUF runs end-to-end |
25+
26+
## What's in the code
27+
28+
**Transformer (`src/ltxv.hpp`)**
29+
- `LTX2VideoTransformer3DModel` — 48 layers; inner 4096 (32×128), cross-attn dim 4096, caption 4096
30+
- `LTXAttention` — qk_norm_across_heads, always-on gated attention (`to_gate_logits` + 2·σ), interleaved and split RoPE variants
31+
- `LTX2VideoTransformerBlock` — per-block `scale_shift_table` (9, dim), `prompt_scale_shift_table` (2, dim), `scale_shift_table_a2v_ca_video/audio` (5, dim/audio_dim), `audio_scale_shift_table` (9, audio_dim), `audio_prompt_scale_shift_table` (2, audio_dim). Forward path runs **only** video self-attn + prompt cross-attn + FF; audio self-attn, a2v/v2a cross-attn and audio FFN are loaded but skipped (isolate_modalities=True).
32+
- `AdaLayerNormSingle` with configurable `num_mod_params`
33+
- `EmbeddingsConnector` — 128 learnable registers + 8 transformer_1d_blocks (gated self-attn + FF) for both video and audio
34+
- Split 3-D RoPE (video-axis F/H/W, dim/6 freqs per axis, vae_scale_factors (8, 32, 32), `causal_offset=1`, fps scaling, pair-swap rotation)
35+
- Stub `LTXV2Conditioner` returning zero embeddings of shape `[1, 128, 4096]`
36+
37+
**VAE (`src/ltxv.hpp`)**
38+
- 9-block encoder: res×4 @128, spatial↓(1,2,2) 128→256, res×6 @256, temporal↓(2,1,1) 256→512, res×4 @512, st↓(2,2,2) 512→1024, res×2 @1024, st↓(2,2,2) 1024→1024, res×2 @1024
39+
- Decoder is the exact mirror
40+
- `VAEResBlock` is the LTX-2.3 simplified shape (two `CausalConv3d` with silu gates, no norms, no timestep modulation)
41+
- `CausalConv3d` uses `conv.weight` / `conv.bias` names, hardcoded F16 dtype so it stays within the CUDA `ggml_cuda_op_im2col_3d` accepted types
42+
- `VAEUpsampler` pixel-shuffle drops the first `st_t − 1` frames after each temporal upsample so `f_out = (f_in − 1) × st_t + 1` composes across all upsamples
43+
44+
**Pipeline wiring (`src/stable-diffusion.cpp` etc.)**
45+
- `VERSION_LTXV2` / `sd_version_is_ltxv2` / `sd_version_is_dit` entry
46+
- VAE factory arm builds `LTXV::LTXVVAERunner`
47+
- FLOW_PRED with `default_flow_shift = 3.0`
48+
- Latent channels 128, VAE scale factor 32, temporal compression 8
49+
- Frame count padded to 8k+1 (LTX-2.3 I/O spec)
50+
- Ignore prefixes: `audio_vae.`, `vocoder.`, `text_embedding_projection.`
51+
52+
## Numerical correctness — resolved
53+
54+
Nine bugs were diagnosed and fixed by working backwards from the VAE output
55+
(and later the text-conditioning path) using graph-level probes. Each one is
56+
noted here because the same mistake is easy to make again porting future
57+
video VAE/DiT stacks:
58+
59+
1. **EmbeddingsConnector pre-norm.** Reference
60+
`_BasicTransformerBlock1D.forward` does `rms_norm(hidden_states)` before
61+
both attn1 and ff (and a final `rms_norm` after the stack). We had
62+
bare `x = x + attn(x); x = x + ff(x)` — residuals compounded across 8
63+
blocks and drove the connector output to std≈1e12, exploding cross-attn
64+
in every transformer block.
65+
66+
2. **Final `norm_out` before the scale/shift + `proj_out`.** Reference
67+
`LTXModel._process_output` is
68+
`x = norm_out(x); x = x * (1 + scale) + shift; x = proj_out(x)`.
69+
Without the LayerNorm the post-block activation (std≈285 after 48
70+
layers) leaked into the predicted velocity and the sampler diverged.
71+
Transformer output std went from 57 → 1.0 after adding `ggml_norm`.
72+
73+
3. **VAE `conv_norm_out` + SiLU before `conv_out`.** The reference decoder
74+
ends with `sample = conv_norm_out(sample); sample = silu(sample);
75+
sample = conv_out(sample)`. We were skipping the PixelNorm+SiLU, so
76+
output pixels were O(1000) instead of O(1).
77+
78+
4. **Latent per-channel normalisation.** `vae.per_channel_statistics.*`
79+
is now materialised to CPU and applied in `diffusion_to_vae_latents`
80+
(`x * std + mean`) / `vae_to_diffusion_latents` (`(x - mean) / std`).
81+
82+
5. **VAE depth-to-space ordering.** `ggml_reshape_4d` alone doesn't
83+
implement einops `b (c p1 p2 p3) f h w -> b c (f p1) (h p2) (w p3)` —
84+
the sub-indices come out in the wrong order. Replaced with a proper
85+
`depth_to_space_3d` helper that decomposes the channel axis through
86+
permute+cont passes so p3 lands inner-of-W, p2 inner-of-H, p1
87+
inner-of-F. Eliminated the visible banding.
88+
89+
6. **Gemma-3 49-layer concat layout.** `ggml_concat(hidden_all[i],
90+
axis=0)` produces a flat axis with layer-slow / hidden-fast ordering,
91+
but HF's `reshape(B, T, D*L)` produces hidden-slow / layer-fast.
92+
`text_embedding_projection.video_aggregate_embed` was trained for the
93+
HF layout — a transposed input made the projection output essentially
94+
noise and all prompts generated the same scene. Fixed by stacking
95+
along axis 2 → permute(2, 0, 1, 3) → reshape to [D*L, T, 1].
96+
97+
7. **EmbeddingsConnector register layout.** Reference
98+
`_replace_padded_with_learnable_registers` produces a **fixed
99+
128-token** output with real text at positions [0..L-1] and
100+
`learnable_registers[L..127]` at [L..127]. We were concatenating
101+
registers+text to 128+L tokens in the wrong order. Rewrote the
102+
connector's register path.
103+
104+
8. **Double attention scaling in Gemma-3.** Gemma-3 uses
105+
`scale = 1/sqrt(query_pre_attn_scalar) = 1/sqrt(head_dim)` for the
106+
12B variant — and `ggml_ext_attention_ext` applies the same
107+
`1/sqrt(d_head)` internally. Applying both multiplied the softmax
108+
temperature by 1/16, collapsing attention to near-uniform and
109+
producing a persistent ~sqrt(D) "attention sink" outlier at the same
110+
hidden dim for every layer. Dropping the explicit Q scale made the
111+
Gemma forward match HF to bf16 precision.
112+
113+
9. **Two different patchify conventions in `ops.py` vs `sampling.py`.**
114+
`DepthToSpaceUpsample` (intermediate upsamplers) uses
115+
`b (c p1 p2 p3) d h w -> b c (d p1) (h p2) (w p3)` — p3 (w-stride)
116+
innermost in the channel axis. `ops.py::unpatchify` (the decoder's
117+
final 4×4 un-patch) uses
118+
`b (c p r q) f h w -> b c (f p) (h q) (w r)` — q (h_patch) innermost.
119+
We were reusing the upsampler helper for the final unpatchify, which
120+
silently transposed every 4×4 output block and left a visible fine-
121+
scale hatching artefact that survived every diffusion step. Added a
122+
dedicated `depth_to_space_3d_patch` that swaps the inner (p_w, p_h)
123+
pair of the channel axis before delegating, matching the reference
124+
layout exactly.
125+
126+
Cross-checked against the 22B checkpoint's embedded config
127+
(`safetensors __metadata__["config"]["vae"]`): `norm_layer=pixel_norm`,
128+
`spatial_padding_mode=zeros`, `timestep_conditioning=false`,
129+
`causal_decoder=false`, patch_size=4, and none of the `compress_all`
130+
decoder blocks sets `residual=True` — so the residual skip from
131+
`DepthToSpaceUpsample` is correctly absent here.
132+
133+
End-to-end result: prompts now actually generate the described content.
134+
Seed 42 with *"a cat walking across a grassy field"* produces exactly
135+
that. Per-layer Gemma hidden states match HF to bf16 noise; the
136+
projected cross-attention features match HF (min/max/std 0.0%/0.2%/0.03%
137+
different).
138+
139+
## Remaining items (future sessions)
140+
141+
1. **Audio branch.** Roughly half of the LTX transformer buffer is
142+
audio-related (`audio_attn1/2`, `audio_to_video_attn`,
143+
`video_to_audio_attn`, `audio_embeddings_connector`,
144+
`audio_scale_shift_table`, etc.). Adding joint audio+video generation
145+
also needs the `audio_vae` (102 tensors), the HiFi-GAN-style
146+
`vocoder` (1227 tensors), and the BWE upsampler. Non-trivial.
147+
148+
2. **Schedule for non-distilled variants.** The 22B non-distilled model
149+
uses LTX2Scheduler (token-count-dependent shift, stretched to a
150+
terminal value). Only the distilled 8-step table is wired up today.
151+
152+
3. **Quantised Gemma.** Gemma-3-12B is 24 GB in BF16. A q8_0 or q4_k
153+
conversion would drop it to ~12 GB / ~7 GB — useful for smaller
154+
hardware. The existing sd-cli `-M convert` path should handle it.
155+
156+
## How to run the e2e test
157+
158+
First, grab the two model artefacts:
159+
160+
```bash
161+
# LTX-2.3 distilled 22B (46 GB BF16 safetensors):
162+
hf download Lightricks/LTX-2.3 ltx-2.3-22b-distilled.safetensors \
163+
--local-dir ltxv-models
164+
165+
# Gemma-3-12B-it (tokenizer.model + 5x safetensors shards, ~24 GB BF16):
166+
hf download google/gemma-3-12b-it --local-dir gemma-3-12b-it
167+
```
168+
169+
Then run with the distilled 8-step schedule (auto-selected when
170+
`--steps 8` is passed on an ltxv2 model):
171+
172+
```bash
173+
./sd-cli -M vid_gen \
174+
-m ltxv-models/ltx-2.3-22b-distilled.safetensors \
175+
--text-encoder gemma-3-12b-it \
176+
-p "a cat walking across a grassy field" \
177+
-W 704 -H 480 --video-frames 9 \
178+
--steps 8 --cfg-scale 1 \
179+
-o /tmp/ltx23.webp --seed 42
180+
181+
# Official distilled shape (768x512, 121 frames, ~140 s on GB10):
182+
./sd-cli -M vid_gen \
183+
-m ltxv-models/ltx-2.3-22b-distilled.safetensors \
184+
--text-encoder gemma-3-12b-it \
185+
-p "a cat walking across a grassy field" \
186+
-W 768 -H 512 --video-frames 121 \
187+
--steps 8 --cfg-scale 1 \
188+
-o /tmp/ltx23.webp --seed 42
189+
190+
# Without --text-encoder: LTX runs unconditionally (zero embeddings),
191+
# pipeline still produces valid frames but ignores the prompt.
192+
193+
# Quantise the LTX DiT to q8_0 GGUF (46 GB -> 28 GB):
194+
./sd-cli -M convert \
195+
-m ltxv-models/ltx-2.3-22b-distilled.safetensors \
196+
-o ltxv-models/ltx-2.3-22b-distilled-q8_0.gguf \
197+
--type q8_0
198+
```
199+
200+
## References
201+
202+
- LTX-2.3 model card: https://huggingface.co/Lightricks/LTX-2.3
203+
- Diffusers LTX-2.0 reference (not an exact match for 2.3):
204+
https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/transformers/transformer_ltx2.py
205+
- Upstream ltx-pipelines (Lightricks):
206+
https://github.com/Lightricks/LTX-2/tree/main/packages/ltx-pipelines

‎docs/test_ltxv.sh‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/bin/bash
2+
# End-to-end LTX-2.3 test script for DGX.
3+
# Run as: ssh dgx.casa 'bash -s' < /tmp/ltxv_test.sh
4+
5+
set -e
6+
set -o pipefail
7+
8+
SD_CLI=~/ltxv-sd-cpp/build-cuda/bin/sd-cli
9+
MODEL=~/ltxv-models/ltx-2.3-22b-distilled.safetensors
10+
OUT=/tmp/ltx23_out
11+
12+
mkdir -p "$OUT"
13+
echo "=============================================="
14+
echo "[1/3] vid_gen BF16 (no quant) — dry run"
15+
echo "=============================================="
16+
$SD_CLI -M vid_gen \
17+
-m "$MODEL" \
18+
-p "a cat walking across a grassy field" \
19+
-W 704 -H 480 --video-frames 9 \
20+
--steps 1 --cfg-scale 1 \
21+
-o "$OUT/dryrun.webp" \
22+
--seed 42 \
23+
-v 2>&1 | tail -80
24+
25+
echo ""
26+
echo "=============================================="
27+
echo "[2/3] Quantize to q8_0"
28+
echo "=============================================="
29+
$SD_CLI -M convert \
30+
-m "$MODEL" \
31+
-o "$OUT/ltx23_q8_0.gguf" \
32+
--type q8_0 \
33+
-v 2>&1 | tail -30
34+
35+
echo ""
36+
echo "=============================================="
37+
echo "[3/3] vid_gen with q8_0 GGUF"
38+
echo "=============================================="
39+
$SD_CLI -M vid_gen \
40+
-m "$OUT/ltx23_q8_0.gguf" \
41+
-p "a cat walking across a grassy field" \
42+
-W 704 -H 480 --video-frames 9 \
43+
--steps 4 --cfg-scale 1 \
44+
-o "$OUT/q8_output.webp" \
45+
--seed 42 \
46+
-v 2>&1 | tail -80
47+
48+
echo ""
49+
echo "=============================================="
50+
echo "Outputs in $OUT:"
51+
ls -la "$OUT/"

‎examples/CMakeLists.txt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
include_directories(${CMAKE_CURRENT_SOURCE_DIR})
22

33
add_subdirectory(cli)
4-
add_subdirectory(server)
4+
add_subdirectory(server)
5+
add_subdirectory(gemma_test)

‎examples/common/common.cpp‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,12 @@ ArgOptions SDContextParams::get_options() {
332332
"--qwen2vl_vision",
333333
"alias of --llm_vision. Deprecated.",
334334
&llm_vision_path},
335+
{"",
336+
"--text-encoder",
337+
"path to the text encoder directory (e.g. google/gemma-3-12b-it for LTX-2.3). "
338+
"Must contain tokenizer.model plus *.safetensors shards. "
339+
"When unset, LTX-2.3 runs unconditionally.",
340+
&text_encoder_path},
335341
{"",
336342
"--diffusion-model",
337343
"path to the standalone diffusion model",
@@ -744,6 +750,7 @@ sd_ctx_params_t SDContextParams::to_sd_ctx_params_t(bool vae_decode_only, bool f
744750
chroma_use_t5_mask,
745751
chroma_t5_mask_pad,
746752
qwen_image_zero_cond_t,
753+
text_encoder_path.c_str(),
747754
};
748755
return sd_ctx_params;
749756
}

‎examples/common/common.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ struct SDContextParams {
9090
std::string t5xxl_path;
9191
std::string llm_path;
9292
std::string llm_vision_path;
93+
std::string text_encoder_path; // LTX-2.3 Gemma-3 dir
9394
std::string diffusion_model_path;
9495
std::string high_noise_diffusion_model_path;
9596
std::string vae_path;

‎examples/gemma_test/CMakeLists.txt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
set(TARGET gemma3-test)
2+
add_executable(${TARGET} gemma3_test.cpp)
3+
install(TARGETS ${TARGET} RUNTIME)
4+
target_link_libraries(${TARGET} PRIVATE stable-diffusion ${CMAKE_THREAD_LIBS_INIT})
5+
target_compile_features(${TARGET} PUBLIC c_std_11 cxx_std_17)

0 commit comments

Comments
 (0)