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