Skip to main content

cortiq_engine/
ltxenc.rs

1//! The LTX-2.5 video VAE **encoder** — pixels into the latent space the
2//! transformer denoises in, which is what every conditioned mode needs:
3//! image-to-video, video-to-video, and any of the audio-video pairs that
4//! start from a picture.
5//!
6//! It mirrors the decoder and runs it backwards: `patchify(4)` trades
7//! spatial resolution for channel depth, a causal 3-D convolution lifts it
8//! to 128 channels, then the block ladder from the checkpoint's own
9//! `encoder_blocks` — `res_x` stacks that keep the shape and `compress_all`
10//! convolutions with stride 2 in every axis that do not. PixelNorm and SiLU,
11//! a final convolution to 129 channels (128 means and one shared
12//! log-variance the sampler ignores), and the per-channel statistics
13//! *normalize* the means on the way out.
14//!
15//! Causal in time, like the decoder: the first latent frame sees one pixel
16//! frame and every later one sees eight, which is why a single image encodes
17//! to exactly one latent frame.
18
19use crate::ltxvae::Vol;
20use crate::pool::Pool;
21use cortiq_core::CmfModel;
22use std::sync::Arc;
23
24fn tensor_f32(model: &Arc<CmfModel>, name: &str) -> Result<(Vec<f32>, Vec<usize>), String> {
25    let e = model.tensor(name).ok_or_else(|| format!("missing tensor {name}"))?;
26    let mut out = vec![0.0f32; e.n_elems()];
27    cortiq_core::quant::dequant_tensor(e, model.entry_bytes(e), &mut out)?;
28    Ok((out, e.shape.clone()))
29}
30
31fn silu(x: f32) -> f32 {
32    x / (1.0 + (-x).exp())
33}
34
35fn pixel_norm(x: &mut Vol) {
36    let npos = x.positions();
37    for p in 0..npos {
38        let mut s = 0f32;
39        for c in 0..x.c {
40            let v = x.data[c * npos + p];
41            s += v * v;
42        }
43        let inv = 1.0 / (s / x.c as f32 + 1e-6).sqrt();
44        for c in 0..x.c {
45            x.data[c * npos + p] *= inv;
46        }
47    }
48}
49
50/// A 3×3×3 convolution, causal in time (the kernel reaches backwards only,
51/// with the first frame replicated) and zero-padded in space, at stride 1
52/// or 2 on any axis.
53struct Conv {
54    w: Vec<f32>,
55    b: Vec<f32>,
56    c_out: usize,
57    c_in: usize,
58    stride: (usize, usize, usize),
59}
60
61impl Conv {
62    fn load(model: &Arc<CmfModel>, p: &str, stride: (usize, usize, usize)) -> Result<Conv, String> {
63        let (w, s) = tensor_f32(model, &format!("{p}.conv.weight"))?;
64        let b = match model.tensor(&format!("{p}.conv.bias")) {
65            Some(_) => tensor_f32(model, &format!("{p}.conv.bias"))?.0,
66            None => vec![0.0; s[0]],
67        };
68        Ok(Conv { w, b, c_out: s[0], c_in: s[1], stride })
69    }
70
71    fn forward(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
72        let (sf, sh, sw) = self.stride;
73        let (of, oh, ow) = (x.f.div_ceil(sf), x.h.div_ceil(sh), x.w.div_ceil(sw));
74        let mut out = Vol::zeros(self.c_out, of, oh, ow);
75        let k = self.c_in * 27;
76        let npos = of * oh * ow;
77        const CHUNK: usize = 8192;
78        let mut patches = vec![0f32; CHUNK.min(npos) * k];
79        let mut ys = vec![0f32; CHUNK.min(npos) * self.c_out];
80        let mut p0 = 0usize;
81        while p0 < npos {
82            let n = CHUNK.min(npos - p0);
83            patches[..n * k].fill(0.0);
84            for i in 0..n {
85                let p = p0 + i;
86                let (pw, rest) = (p % ow, p / ow);
87                let (ph, pf) = (rest % oh, rest / oh);
88                let (bf, bh, bw) = (pf * sf, ph * sh, pw * sw);
89                let row = &mut patches[i * k..(i + 1) * k];
90                for ci in 0..self.c_in {
91                    for kf in 0..3usize {
92                        // causal in time: taps reach back, the first frame
93                        // stands in for anything before it
94                        let src_f = (bf + kf).saturating_sub(2).min(x.f - 1);
95                        for kh in 0..3usize {
96                            let s = bh + kh;
97                            if s == 0 || s > x.h {
98                                continue;
99                            }
100                            let src_h = s - 1;
101                            for kw in 0..3usize {
102                                let s = bw + kw;
103                                if s == 0 || s > x.w {
104                                    continue;
105                                }
106                                row[(ci * 3 + kf) * 9 + kh * 3 + kw] = x.at(ci, src_f, src_h, s - 1);
107                            }
108                        }
109                    }
110                }
111            }
112            crate::fcd_ops::gemm_nt(
113                &patches[..n * k],
114                &self.w,
115                &mut ys[..n * self.c_out],
116                n,
117                k,
118                self.c_out,
119                pool,
120            );
121            for i in 0..n {
122                for co in 0..self.c_out {
123                    out.data[co * npos + p0 + i] = ys[i * self.c_out + co] + self.b[co];
124                }
125            }
126            p0 += n;
127        }
128        out
129    }
130}
131
132struct Res {
133    c1: Conv,
134    c2: Conv,
135}
136
137impl Res {
138    fn forward(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
139        let mut h = x.clone();
140        pixel_norm(&mut h);
141        h.data.iter_mut().for_each(|v| *v = silu(*v));
142        let mut h = self.c1.forward(&h, pool);
143        pixel_norm(&mut h);
144        h.data.iter_mut().for_each(|v| *v = silu(*v));
145        let mut h = self.c2.forward(&h, pool);
146        for (v, &r) in h.data.iter_mut().zip(&x.data) {
147            *v += r;
148        }
149        h
150    }
151}
152
153/// `SpaceToDepthDownsample`: a stride-1 convolution whose output is folded
154/// into the channel axis, plus a skip that folds the *input* the same way
155/// and averages each group of channels down to the same width. The temporal
156/// stride prepends a copy of the first frame, which is what keeps a
157/// `1 + 8k` frame count landing on `1 + k` latent frames.
158struct Down {
159    conv: Conv,
160    stride: (usize, usize, usize),
161    out_channels: usize,
162}
163
164impl Down {
165    fn load(
166        model: &Arc<CmfModel>,
167        p: &str,
168        stride: (usize, usize, usize),
169        multiplier: usize,
170    ) -> Result<Down, String> {
171        let conv = Conv::load(model, &format!("{p}.conv"), (1, 1, 1))?;
172        let out_channels = conv.c_in * multiplier;
173        Ok(Down { conv, stride, out_channels })
174    }
175
176    fn forward(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
177        let (sf, sh, sw) = self.stride;
178        let padded;
179        let src = if sf == 2 {
180            let mut v = Vol::zeros(x.c, x.f + 1, x.h, x.w);
181            for c in 0..x.c {
182                for f in 0..=x.f {
183                    let from = f.saturating_sub(1);
184                    for y in 0..x.h {
185                        for z in 0..x.w {
186                            v.data[((c * (x.f + 1) + f) * x.h + y) * x.w + z] = x.at(c, from, y, z);
187                        }
188                    }
189                }
190            }
191            padded = v;
192            &padded
193        } else {
194            x
195        };
196        let folded = space_to_depth(src, self.stride);
197        // the skip: average each consecutive group of folded channels down
198        let group = folded.c / self.out_channels;
199        let np = folded.positions();
200        let mut skip = Vol::zeros(self.out_channels, folded.f, folded.h, folded.w);
201        for c in 0..self.out_channels {
202            for g in 0..group {
203                let sc = c * group + g;
204                for i in 0..np {
205                    skip.data[c * np + i] += folded.data[sc * np + i];
206                }
207            }
208            let inv = 1.0 / group as f32;
209            for i in 0..np {
210                skip.data[c * np + i] *= inv;
211            }
212        }
213        let mut out = space_to_depth(&self.conv.forward(src, pool), self.stride);
214        for (v, &r) in out.data.iter_mut().zip(&skip.data) {
215            *v += r;
216        }
217        out
218    }
219}
220
221/// `b c (d p1) (h p2) (w p3) -> b (c p1 p2 p3) d h w`.
222fn space_to_depth(x: &Vol, stride: (usize, usize, usize)) -> Vol {
223    let (p1, p2, p3) = stride;
224    let (df, dh, dw) = (x.f / p1, x.h / p2, x.w / p3);
225    let mut out = Vol::zeros(x.c * p1 * p2 * p3, df, dh, dw);
226    let np = df * dh * dw;
227    for c in 0..x.c {
228        for i1 in 0..p1 {
229            for i2 in 0..p2 {
230                for i3 in 0..p3 {
231                    let dc = ((c * p1 + i1) * p2 + i2) * p3 + i3;
232                    for f in 0..df {
233                        for y in 0..dh {
234                            for z in 0..dw {
235                                out.data[dc * np + (f * dh + y) * dw + z] =
236                                    x.at(c, f * p1 + i1, y * p2 + i2, z * p3 + i3);
237                            }
238                        }
239                    }
240                }
241            }
242        }
243    }
244    out
245}
246
247enum Block {
248    Res(Vec<Res>),
249    Down(Down),
250}
251
252pub struct VideoEncoder {
253    patch: usize,
254    conv_in: Conv,
255    blocks: Vec<Block>,
256    conv_out: Conv,
257    mean: Vec<f32>,
258    std: Vec<f32>,
259    latent_channels: usize,
260}
261
262impl VideoEncoder {
263    pub fn from_cmf(model: &Arc<CmfModel>) -> Result<VideoEncoder, String> {
264        let cfg: serde_json::Value = ["vvae.config_json", "ltx.config_json"]
265            .iter()
266            .filter_map(|n| model.tensor(n).map(|e| model.entry_bytes(e)))
267            .filter_map(|b| serde_json::from_slice::<serde_json::Value>(b).ok())
268            .find(|c| c.get("vae").and_then(|v| v.get("encoder_blocks")).is_some())
269            .ok_or("no config in this container carries vae.encoder_blocks")?;
270        let vae = &cfg["vae"];
271        let patch = vae["patch_size"].as_u64().unwrap_or(4) as usize;
272        let list = vae["encoder_blocks"].as_array().ok_or("vae.encoder_blocks")?;
273        let mut blocks = Vec::new();
274        for (i, entry) in list.iter().enumerate() {
275            let name = entry[0].as_str().unwrap_or("");
276            let p = format!("vvae.encoder.down_blocks.{i}");
277            match name {
278                "res_x" => {
279                    let mut r = Vec::new();
280                    let mut j = 0usize;
281                    while model.tensor(&format!("{p}.res_blocks.{j}.conv1.conv.weight")).is_some() {
282                        r.push(Res {
283                            c1: Conv::load(model, &format!("{p}.res_blocks.{j}.conv1"), (1, 1, 1))?,
284                            c2: Conv::load(model, &format!("{p}.res_blocks.{j}.conv2"), (1, 1, 1))?,
285                        });
286                        j += 1;
287                    }
288                    blocks.push(Block::Res(r));
289                }
290                "compress_all_res" | "compress_time_res" | "compress_space_res" => {
291                    let stride = match name {
292                        "compress_time_res" => (2, 1, 1),
293                        "compress_space_res" => (1, 2, 2),
294                        _ => (2, 2, 2),
295                    };
296                    let mult = entry[1]
297                        .get("multiplier")
298                        .and_then(|v| v.as_u64())
299                        .unwrap_or(2) as usize;
300                    blocks.push(Block::Down(Down::load(model, &p, stride, mult)?));
301                }
302                other => return Err(format!("encoder block '{other}' is not ported")),
303            }
304        }
305        Ok(VideoEncoder {
306            patch,
307            conv_in: Conv::load(model, "vvae.encoder.conv_in", (1, 1, 1))?,
308            blocks,
309            conv_out: Conv::load(model, "vvae.encoder.conv_out", (1, 1, 1))?,
310            mean: tensor_f32(model, "vvae.per_channel_statistics.mean-of-means")?.0,
311            std: tensor_f32(model, "vvae.per_channel_statistics.std-of-means")?.0,
312            latent_channels: vae["latent_channels"].as_u64().unwrap_or(128) as usize,
313        })
314    }
315
316    /// `[3, F, H, W]` in `[-1, 1]` → `[128, 1+(F-1)/8, H/32, W/32]`, in the
317    /// units the transformer works in.
318    pub fn encode(&self, frames: &Vol, pool: Option<&Pool>) -> Vol {
319        let p = self.patch;
320        let (h2, w2) = (frames.h / p, frames.w / p);
321        // patchify is `b c (f p) (h q) (w r) -> b (c p r q) f h w`: the
322        // *width* offset varies slower than the height one, which is the
323        // opposite of the obvious order and the difference between a picture
324        // and a grid of shuffled tiles.
325        let mut x = Vol::zeros(frames.c * p * p, frames.f, h2, w2);
326        let np = x.positions();
327        for c in 0..frames.c {
328            for a in 0..p {
329                for b in 0..p {
330                    let dc = (c * p + b) * p + a;
331                    for f in 0..frames.f {
332                        for y in 0..h2 {
333                            for z in 0..w2 {
334                                x.data[dc * np + (f * h2 + y) * w2 + z] =
335                                    frames.at(c, f, y * p + a, z * p + b);
336                            }
337                        }
338                    }
339                }
340            }
341        }
342        let mut h = self.conv_in.forward(&x, pool);
343        for b in &self.blocks {
344            h = match b {
345                Block::Res(rs) => {
346                    let mut cur = h;
347                    for r in rs {
348                        cur = r.forward(&cur, pool);
349                    }
350                    cur
351                }
352                Block::Down(c) => c.forward(&h, pool),
353            };
354        }
355        pixel_norm(&mut h);
356        h.data.iter_mut().for_each(|v| *v = silu(*v));
357        let out = self.conv_out.forward(&h, pool);
358        // the means are the first `latent_channels`; the trailing channel is
359        // the shared log-variance, which sampling ignores
360        let npos = out.positions();
361        let mut lat = Vol::zeros(self.latent_channels, out.f, out.h, out.w);
362        for c in 0..self.latent_channels {
363            for i in 0..npos {
364                lat.data[c * npos + i] = (out.data[c * npos + i] - self.mean[c]) / self.std[c];
365            }
366        }
367        lat
368    }
369}