Skip to main content

cortiq_engine/
ltxpipe.rs

1//! The LTX-2.5 sampler: latent geometry, the noise schedule and the Euler
2//! loops that drive [`crate::ltxdit::LtxDit`] from noise to a clean latent.
3//!
4//! The reference pipeline is two-stage — eight ancestral steps at half
5//! resolution, a latent upsample, then three deterministic steps at full
6//! resolution. Both stages run the same loop; they differ only in the sigma
7//! schedule, whether noise is re-injected, and what the latent starts from.
8//!
9//! Everything positional is derived here, because the DiT reads positions
10//! rather than shapes: video tokens carry `(seconds, pixel row, pixel
11//! column)` patch midpoints — the temporal axis divided by the frame rate so
12//! it shares a unit with audio — and the first latent frame is shifted by the
13//! causal correction, since a causal video encoder gives it one pixel frame
14//! where every later latent frame gets eight.
15
16use crate::ltxdit::{LtxDit, StreamInput};
17use crate::pool::Pool;
18
19/// Video VAE downscaling: 8 frames, 32 rows, 32 columns per latent step.
20pub const SCALE_TIME: usize = 8;
21pub const SCALE_SPACE: usize = 32;
22/// Audio latents per second: 16000 / 160 / 4.
23pub const AUDIO_LATENTS_PER_SEC: f64 = 25.0;
24const AUDIO_HOP: f64 = 160.0;
25const AUDIO_RATE: f64 = 16000.0;
26const AUDIO_DOWNSAMPLE: f64 = 4.0;
27
28/// The distilled schedules the release ships with.
29pub const STAGE1_SIGMAS: [f32; 9] =
30    [1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0];
31pub const STAGE2_SIGMAS: [f32; 4] = [0.909375, 0.725, 0.421875, 0.0];
32
33/// Counter-based RNG with a Box-Muller normal — reproducible from a seed,
34/// and independent of any host library.
35pub struct Rng(u64);
36
37impl Rng {
38    pub fn new(seed: u64) -> Rng {
39        Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
40    }
41    fn next_u64(&mut self) -> u64 {
42        // splitmix64
43        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
44        let mut z = self.0;
45        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
46        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
47        z ^ (z >> 31)
48    }
49    fn unit(&mut self) -> f64 {
50        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
51    }
52    /// One standard normal sample.
53    pub fn normal(&mut self) -> f32 {
54        let u1 = self.unit().max(1e-12);
55        let u2 = self.unit();
56        ((-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()) as f32
57    }
58    pub fn fill_normal(&mut self, dst: &mut [f32]) {
59        for v in dst.iter_mut() {
60            *v = self.normal();
61        }
62    }
63}
64
65/// Latent geometry of one render.
66#[derive(Clone, Copy, Debug)]
67pub struct Geometry {
68    pub frames: usize,
69    pub height: usize,
70    pub width: usize,
71    pub fps: f64,
72    /// Latent frames / rows / columns.
73    pub lf: usize,
74    pub lh: usize,
75    pub lw: usize,
76    /// Audio latent frames.
77    pub af: usize,
78}
79
80impl Geometry {
81    pub fn new(frames: usize, height: usize, width: usize, fps: f64) -> Geometry {
82        let lf = (frames - 1) / SCALE_TIME + 1;
83        let duration = frames as f64 / fps;
84        Geometry {
85            frames,
86            height,
87            width,
88            fps,
89            lf,
90            lh: height / SCALE_SPACE,
91            lw: width / SCALE_SPACE,
92            af: (duration * AUDIO_LATENTS_PER_SEC).round() as usize,
93        }
94    }
95
96    pub fn video_tokens(&self) -> usize {
97        self.lf * self.lh * self.lw
98    }
99
100    pub fn tokens_per_frame(&self) -> usize {
101        self.lh * self.lw
102    }
103
104    /// Patch midpoints in `(seconds, pixel row, pixel column)`, in the
105    /// frame-major order `patchify` produces.
106    pub fn video_positions(&self) -> Vec<Vec<f64>> {
107        let causal = |v: f64| (v + 1.0 - SCALE_TIME as f64).max(0.0);
108        let mut out = Vec::with_capacity(self.video_tokens());
109        for f in 0..self.lf {
110            let t0 = causal((f * SCALE_TIME) as f64) / self.fps;
111            let t1 = causal(((f + 1) * SCALE_TIME) as f64) / self.fps;
112            let t = (t0 + t1) / 2.0;
113            for h in 0..self.lh {
114                let y = ((h * SCALE_SPACE) as f64 + ((h + 1) * SCALE_SPACE) as f64) / 2.0;
115                for w in 0..self.lw {
116                    let x = ((w * SCALE_SPACE) as f64 + ((w + 1) * SCALE_SPACE) as f64) / 2.0;
117                    out.push(vec![t, y, x]);
118                }
119            }
120        }
121        out
122    }
123
124    /// Audio patch midpoints, in seconds.
125    pub fn audio_positions(&self) -> Vec<Vec<f64>> {
126        let sec = |i: usize| {
127            let mel = (i as f64 * AUDIO_DOWNSAMPLE + 1.0 - AUDIO_DOWNSAMPLE).max(0.0);
128            mel * AUDIO_HOP / AUDIO_RATE
129        };
130        (0..self.af).map(|i| vec![(sec(i) + sec(i + 1)) / 2.0]).collect()
131    }
132
133    /// Non-zero on the first latent frame, whose latent encodes a single
134    /// standalone pixel frame.
135    pub fn keyframes_mask(&self) -> Vec<f32> {
136        let mut m = vec![0f32; self.video_tokens()];
137        for v in m.iter_mut().take(self.tokens_per_frame()) {
138            *v = 1.0;
139        }
140        m
141    }
142}
143
144/// A denoising stage: which schedule, and whether it re-injects noise.
145pub struct Stage {
146    pub sigmas: Vec<f32>,
147    pub ancestral: bool,
148}
149
150impl Stage {
151    pub fn stage1() -> Stage {
152        Stage { sigmas: STAGE1_SIGMAS.to_vec(), ancestral: true }
153    }
154    pub fn stage2() -> Stage {
155        Stage { sigmas: STAGE2_SIGMAS.to_vec(), ancestral: false }
156    }
157
158    /// The tail of the schedule that starts at or below `strength` — the
159    /// video-to-video dial. The clip is re-noised to that level and denoised
160    /// from there, so 1.0 keeps only the composition and 0.2 barely touches
161    /// it. The first sigma of the returned schedule *is* the noise scale the
162    /// starting latent is mixed to, which is the same pairing the reference
163    /// uses between its second stage and the latent it upsampled.
164    pub fn from_strength(strength: f32) -> Stage {
165        let s0 = strength.clamp(0.02, 1.0);
166        // Start *at* the level asked for, then follow the distilled ladder
167        // down. Filtering the ladder alone would silently start lower than
168        // requested — at 0.72 the nearest rung below is 0.42, which is a
169        // different edit than the one the caller asked for.
170        let mut sigmas = vec![s0];
171        sigmas.extend(STAGE1_SIGMAS.iter().copied().filter(|&s| s < s0 && s > 0.0));
172        sigmas.push(0.0);
173        Stage { sigmas, ancestral: true }
174    }
175}
176
177/// One ancestral Euler step in the rectified-flow parameterization
178/// (`alpha = 1 - sigma`): advance to `sigma_down`, then renoise back up to
179/// `sigma_next` with the variance-preserving rescale. `eta = 0` reduces it to
180/// a plain Euler step and ignores `noise`.
181fn euler_step(x: &mut [f32], denoised: &[f32], sigma: f32, sigma_next: f32, eta: f32, noise: Option<&[f32]>) {
182    if sigma_next == 0.0 {
183        x.copy_from_slice(denoised);
184        return;
185    }
186    let down_ratio = 1.0 + (sigma_next / sigma - 1.0) * eta;
187    let sigma_down = sigma_next * down_ratio;
188    let r = sigma_down / sigma;
189    for (v, &d) in x.iter_mut().zip(denoised) {
190        *v = r * *v + (1.0 - r) * d;
191    }
192    if eta > 0.0 {
193        let alpha_next = 1.0 - sigma_next;
194        let alpha_down = 1.0 - sigma_down;
195        let coeff = (sigma_next * sigma_next
196            - sigma_down * sigma_down * alpha_next * alpha_next / (alpha_down * alpha_down))
197            .max(0.0)
198            .sqrt();
199        let scale = alpha_next / alpha_down;
200        let n = noise.expect("ancestral step needs noise");
201        for (v, &e) in x.iter_mut().zip(n) {
202            *v = scale * *v + e * coeff;
203        }
204    }
205}
206
207/// The state a stage carries: patchified latents for both streams.
208pub struct Latents {
209    pub video: Vec<f32>,
210    pub audio: Vec<f32>,
211}
212
213/// What is held fixed while the rest is denoised. `mask[t] = 0` freezes
214/// token `t` at `clean[t]` and hands the transformer a timestep of zero for
215/// it — which is how one encoded image becomes the first frame of a
216/// generated shot, and how a whole encoded clip becomes the picture a
217/// soundtrack is written for.
218#[derive(Clone, Default)]
219pub struct Conditioning {
220    pub video_mask: Vec<f32>,
221    pub video_clean: Vec<f32>,
222    pub audio_mask: Vec<f32>,
223    pub audio_clean: Vec<f32>,
224}
225
226impl Conditioning {
227    /// Freeze the first `frames` latent frames at `clean` (patchified).
228    pub fn video_prefix(geo: &Geometry, clean: &[f32], frames: usize) -> Conditioning {
229        let per = geo.tokens_per_frame();
230        let mut mask = vec![1f32; geo.video_tokens()];
231        let mut full = vec![0f32; geo.video_tokens() * 128];
232        let n = (frames * per).min(geo.video_tokens());
233        for (t, m) in mask.iter_mut().enumerate().take(n) {
234            *m = 0.0;
235            full[t * 128..(t + 1) * 128].copy_from_slice(&clean[t * 128..(t + 1) * 128]);
236        }
237        Conditioning { video_mask: mask, video_clean: full, ..Default::default() }
238    }
239
240    /// Freeze the whole video stream — the picture is given, the sound is
241    /// what is being generated.
242    pub fn video_all(geo: &Geometry, clean: &[f32]) -> Conditioning {
243        Conditioning {
244            video_mask: vec![0f32; geo.video_tokens()],
245            video_clean: clean.to_vec(),
246            ..Default::default()
247        }
248    }
249
250    /// Freeze the whole soundtrack — the sound is given, the picture is what
251    /// is being generated.
252    pub fn with_audio_all(mut self, geo: &Geometry, clean: &[f32]) -> Conditioning {
253        self.audio_mask = vec![0f32; geo.af];
254        self.audio_clean = clean.to_vec();
255        self
256    }
257}
258
259/// Progress callback: `(step, total, seconds for that step)`.
260pub type Progress<'a> = &'a mut dyn FnMut(usize, usize, f64);
261
262#[allow(clippy::too_many_arguments)]
263pub fn run_stage(
264    dit: &LtxDit,
265    geo: &Geometry,
266    stage: &Stage,
267    video_ctx: &[f32],
268    audio_ctx: &[f32],
269    ctx_len: usize,
270    init: Option<Latents>,
271    rng: &mut Rng,
272    pool: Option<&Pool>,
273    progress: Progress<'_>,
274) -> Latents {
275    run_stage_cond(dit, geo, stage, video_ctx, audio_ctx, ctx_len, init, None, rng, pool, progress)
276}
277
278#[allow(clippy::too_many_arguments)]
279pub fn run_stage_cond(
280    dit: &LtxDit,
281    geo: &Geometry,
282    stage: &Stage,
283    video_ctx: &[f32],
284    audio_ctx: &[f32],
285    ctx_len: usize,
286    init: Option<Latents>,
287    cond: Option<&Conditioning>,
288    rng: &mut Rng,
289    pool: Option<&Pool>,
290    progress: Progress<'_>,
291) -> Latents {
292    let vt = geo.video_tokens();
293    let at = geo.af;
294    let vch = 128usize;
295    let ach = 128usize;
296    let s0 = stage.sigmas[0];
297
298    // A fresh stage starts from pure noise; a refinement stage lerps the
299    // incoming latent toward noise by the first sigma, exactly as the
300    // reference's noiser does.
301    let mut v = vec![0f32; vt * vch];
302    let mut a = vec![0f32; at * ach];
303    rng.fill_normal(&mut v);
304    rng.fill_normal(&mut a);
305    if let Some(prev) = init {
306        for (x, &p) in v.iter_mut().zip(&prev.video) {
307            *x = p + (*x - p) * s0;
308        }
309        for (x, &p) in a.iter_mut().zip(&prev.audio) {
310            *x = p + (*x - p) * s0;
311        }
312    }
313
314    // conditioning: a frozen token starts clean, stays clean, and is handed
315    // a timestep of zero so the modulation treats it as already denoised
316    let vmask: Vec<f32> = cond
317        .map(|c| c.video_mask.clone())
318        .filter(|m| m.len() == vt)
319        .unwrap_or_else(|| vec![1f32; vt]);
320    let amask: Vec<f32> = cond
321        .map(|c| c.audio_mask.clone())
322        .filter(|m| m.len() == at)
323        .unwrap_or_else(|| vec![1f32; at]);
324    let vclean: Vec<f32> = cond
325        .map(|c| c.video_clean.clone())
326        .filter(|c| c.len() == v.len())
327        .unwrap_or_else(|| vec![0f32; v.len()]);
328    let aclean: Vec<f32> = cond
329        .map(|c| c.audio_clean.clone())
330        .filter(|c| c.len() == a.len())
331        .unwrap_or_else(|| vec![0f32; a.len()]);
332    let blend = |x: &mut [f32], clean: &[f32], mask: &[f32], ch: usize| {
333        for (t, &m) in mask.iter().enumerate() {
334            if m >= 1.0 {
335                continue;
336            }
337            for d in 0..ch {
338                let i = t * ch + d;
339                x[i] = clean[i] + (x[i] - clean[i]) * m;
340            }
341        }
342    };
343    blend(&mut v, &vclean, &vmask, vch);
344    blend(&mut a, &aclean, &amask, ach);
345
346    let vpos = geo.video_positions();
347    let apos = geo.audio_positions();
348    let kf = geo.keyframes_mask();
349    let steps = stage.sigmas.len() - 1;
350    let eta = if stage.ancestral { 1.0 } else { 0.0 };
351
352    // A stream that is frozen everywhere is *clean*, and both its own
353    // prompt-adaLN and the other stream's fusion gate must be told so: the
354    // gate reads the other side's sigma and closes on noise, so leaving the
355    // schedule's sigma there makes the transformer discount a picture it was
356    // handed intact. The reference sets it to zero for a frozen modality.
357    let v_frozen = vmask.iter().all(|&m| m == 0.0);
358    let a_frozen = amask.iter().all(|&m| m == 0.0);
359
360    for i in 0..steps {
361        let t0 = std::time::Instant::now();
362        let sigma = stage.sigmas[i];
363        let sigma_next = stage.sigmas[i + 1];
364        let vin = StreamInput {
365            latent: v.clone(),
366            tokens: vt,
367            timesteps: vmask.iter().map(|m| sigma * m).collect(),
368            positions: vpos.clone(),
369            context: video_ctx.to_vec(),
370            ctx_len,
371            context_mask: Vec::new(),
372            keyframes: kf.clone(),
373            sigma: if v_frozen { 0.0 } else { sigma },
374        };
375        let ain = StreamInput {
376            latent: a.clone(),
377            tokens: at,
378            timesteps: amask.iter().map(|m| sigma * m).collect(),
379            positions: apos.clone(),
380            context: audio_ctx.to_vec(),
381            ctx_len,
382            context_mask: Vec::new(),
383            keyframes: Vec::new(),
384            sigma: if a_frozen { 0.0 } else { sigma },
385        };
386        let (vv, av) = dit.forward(&vin, &ain, pool);
387        // velocity → denoised, at the token's own timestep
388        // velocity → denoised at each token's own timestep, then the frozen
389        // tokens are put back exactly as they were
390        let mut vd: Vec<f32> = v
391            .iter()
392            .zip(&vv)
393            .enumerate()
394            .map(|(i, (&x, &g))| x - g * sigma * vmask[i / vch])
395            .collect();
396        let mut ad: Vec<f32> = a
397            .iter()
398            .zip(&av)
399            .enumerate()
400            .map(|(i, (&x, &g))| x - g * sigma * amask[i / ach])
401            .collect();
402        blend(&mut vd, &vclean, &vmask, vch);
403        blend(&mut ad, &aclean, &amask, ach);
404        let (vn, an) = if eta > 0.0 && sigma_next > 0.0 {
405            let mut vn = vec![0f32; v.len()];
406            let mut an = vec![0f32; a.len()];
407            rng.fill_normal(&mut vn);
408            rng.fill_normal(&mut an);
409            (Some(vn), Some(an))
410        } else {
411            (None, None)
412        };
413        euler_step(&mut v, &vd, sigma, sigma_next, eta, vn.as_deref());
414        euler_step(&mut a, &ad, sigma, sigma_next, eta, an.as_deref());
415        blend(&mut v, &vclean, &vmask, vch);
416        blend(&mut a, &aclean, &amask, ach);
417        progress(i + 1, steps, t0.elapsed().as_secs_f64());
418    }
419    Latents { video: v, audio: a }
420}
421
422/// Patchified video tokens `[T, 128]` back to a `[128, F, H, W]` volume.
423pub fn unpatchify_video(tokens: &[f32], geo: &Geometry) -> Vec<f32> {
424    let (lf, lh, lw) = (geo.lf, geo.lh, geo.lw);
425    let c = 128usize;
426    let mut out = vec![0f32; c * lf * lh * lw];
427    for f in 0..lf {
428        for h in 0..lh {
429            for w in 0..lw {
430                let t = (f * lh + h) * lw + w;
431                for ch in 0..c {
432                    out[((ch * lf + f) * lh + h) * lw + w] = tokens[t * c + ch];
433                }
434            }
435        }
436    }
437    out
438}
439
440/// Patchified audio tokens `[T, 128]` back to `[8, T, 16]` (channels, time,
441/// mel bins) — the layout the audio VAE decodes.
442pub fn unpatchify_audio(tokens: &[f32], frames: usize) -> Vec<f32> {
443    let (c, mel) = (8usize, 16usize);
444    let mut out = vec![0f32; c * frames * mel];
445    for t in 0..frames {
446        for ch in 0..c {
447            for m in 0..mel {
448                out[(ch * frames + t) * mel + m] = tokens[t * c * mel + ch * mel + m];
449            }
450        }
451    }
452    out
453}
454
455/// A `[128, F, H, W]` volume back to patchified tokens `[T, 128]` — the
456/// inverse of [`unpatchify_video`], for feeding a stage its starting latent.
457pub fn patchify_video(vol: &[f32], geo: &Geometry) -> Vec<f32> {
458    let (lf, lh, lw) = (geo.lf, geo.lh, geo.lw);
459    let c = 128usize;
460    let mut out = vec![0f32; c * lf * lh * lw];
461    for f in 0..lf {
462        for h in 0..lh {
463            for w in 0..lw {
464                let t = (f * lh + h) * lw + w;
465                for ch in 0..c {
466                    out[t * c + ch] = vol[((ch * lf + f) * lh + h) * lw + w];
467                }
468            }
469        }
470    }
471    out
472}