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,
31];
32pub const STAGE2_SIGMAS: [f32; 4] = [0.909375, 0.725, 0.421875, 0.0];
33
34/// Counter-based RNG with a Box-Muller normal — reproducible from a seed,
35/// and independent of any host library.
36pub struct Rng(u64);
37
38impl Rng {
39    pub fn new(seed: u64) -> Rng {
40        Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
41    }
42    fn next_u64(&mut self) -> u64 {
43        // splitmix64
44        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
45        let mut z = self.0;
46        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
47        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
48        z ^ (z >> 31)
49    }
50    fn unit(&mut self) -> f64 {
51        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
52    }
53    /// One standard normal sample.
54    pub fn normal(&mut self) -> f32 {
55        let u1 = self.unit().max(1e-12);
56        let u2 = self.unit();
57        ((-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()) as f32
58    }
59    pub fn fill_normal(&mut self, dst: &mut [f32]) {
60        for v in dst.iter_mut() {
61            *v = self.normal();
62        }
63    }
64}
65
66/// Latent geometry of one render.
67#[derive(Clone, Copy, Debug)]
68pub struct Geometry {
69    pub frames: usize,
70    pub height: usize,
71    pub width: usize,
72    pub fps: f64,
73    /// Latent frames / rows / columns.
74    pub lf: usize,
75    pub lh: usize,
76    pub lw: usize,
77    /// Audio latent frames.
78    pub af: usize,
79}
80
81impl Geometry {
82    pub fn new(frames: usize, height: usize, width: usize, fps: f64) -> Geometry {
83        let lf = (frames - 1) / SCALE_TIME + 1;
84        let duration = frames as f64 / fps;
85        Geometry {
86            frames,
87            height,
88            width,
89            fps,
90            lf,
91            lh: height / SCALE_SPACE,
92            lw: width / SCALE_SPACE,
93            af: (duration * AUDIO_LATENTS_PER_SEC).round() as usize,
94        }
95    }
96
97    pub fn video_tokens(&self) -> usize {
98        self.lf * self.lh * self.lw
99    }
100
101    pub fn tokens_per_frame(&self) -> usize {
102        self.lh * self.lw
103    }
104
105    /// Patch midpoints in `(seconds, pixel row, pixel column)`, in the
106    /// frame-major order `patchify` produces.
107    pub fn video_positions(&self) -> Vec<Vec<f64>> {
108        let causal = |v: f64| (v + 1.0 - SCALE_TIME as f64).max(0.0);
109        let mut out = Vec::with_capacity(self.video_tokens());
110        for f in 0..self.lf {
111            let t0 = causal((f * SCALE_TIME) as f64) / self.fps;
112            let t1 = causal(((f + 1) * SCALE_TIME) as f64) / self.fps;
113            let t = (t0 + t1) / 2.0;
114            for h in 0..self.lh {
115                let y = ((h * SCALE_SPACE) as f64 + ((h + 1) * SCALE_SPACE) as f64) / 2.0;
116                for w in 0..self.lw {
117                    let x = ((w * SCALE_SPACE) as f64 + ((w + 1) * SCALE_SPACE) as f64) / 2.0;
118                    out.push(vec![t, y, x]);
119                }
120            }
121        }
122        out
123    }
124
125    /// Audio patch midpoints, in seconds.
126    pub fn audio_positions(&self) -> Vec<Vec<f64>> {
127        let sec = |i: usize| {
128            let mel = (i as f64 * AUDIO_DOWNSAMPLE + 1.0 - AUDIO_DOWNSAMPLE).max(0.0);
129            mel * AUDIO_HOP / AUDIO_RATE
130        };
131        (0..self.af)
132            .map(|i| vec![(sec(i) + sec(i + 1)) / 2.0])
133            .collect()
134    }
135
136    /// Non-zero on the first latent frame, whose latent encodes a single
137    /// standalone pixel frame.
138    /// Patch midpoints for a *guide* block of `gf` latent frames placed at
139    /// `frame_offset` pixel frames — negative for a reference slot, which is
140    /// what puts it before the clip on the time axis. The causal correction
141    /// of the first frame is the same one `video_positions` applies; the
142    /// offset is added after it, in pixel frames, exactly as the reference
143    /// implementation shifts a keyframe's coordinates.
144    pub fn guide_positions(&self, gf: usize, frame_offset: i64) -> Vec<Vec<f64>> {
145        let causal = |v: f64| (v + 1.0 - SCALE_TIME as f64).max(0.0);
146        let off = frame_offset as f64;
147        let mut out = Vec::with_capacity(gf * self.tokens_per_frame());
148        for f in 0..gf {
149            let t0 = (causal((f * SCALE_TIME) as f64) + off) / self.fps;
150            let t1 = (causal(((f + 1) * SCALE_TIME) as f64) + off) / self.fps;
151            let t = (t0 + t1) / 2.0;
152            for h in 0..self.lh {
153                let y = ((h * SCALE_SPACE) as f64 + ((h + 1) * SCALE_SPACE) as f64) / 2.0;
154                for w in 0..self.lw {
155                    let x = ((w * SCALE_SPACE) as f64 + ((w + 1) * SCALE_SPACE) as f64) / 2.0;
156                    out.push(vec![t, y, x]);
157                }
158            }
159        }
160        out
161    }
162
163    pub fn keyframes_mask(&self) -> Vec<f32> {
164        let mut m = vec![0f32; self.video_tokens()];
165        for v in m.iter_mut().take(self.tokens_per_frame()) {
166            *v = 1.0;
167        }
168        m
169    }
170}
171
172/// A denoising stage: which schedule, and whether it re-injects noise.
173pub struct Stage {
174    pub sigmas: Vec<f32>,
175    pub ancestral: bool,
176}
177
178impl Stage {
179    pub fn stage1() -> Stage {
180        Stage {
181            sigmas: STAGE1_SIGMAS.to_vec(),
182            ancestral: true,
183        }
184    }
185    pub fn stage2() -> Stage {
186        Stage {
187            sigmas: STAGE2_SIGMAS.to_vec(),
188            ancestral: false,
189        }
190    }
191
192    /// The same ladder resampled to `steps` rungs. The distilled schedule is
193    /// not a discretization of a continuous curve that more steps approximate
194    /// better — it is four near-zero moves at the top and three large jumps,
195    /// which is what the model was distilled to take. Asking for more steps
196    /// puts it on sigmas it never saw, and the usual result is a softer frame,
197    /// not a sharper one. The dial exists so that can be measured rather than
198    /// argued about; `steps == 8` returns the distilled ladder unchanged, bit
199    /// for bit.
200    pub fn stage1_steps(steps: usize) -> Stage {
201        let base = &STAGE1_SIGMAS;
202        let n = steps.max(1);
203        if n + 1 == base.len() {
204            return Stage::stage1();
205        }
206        let last = base.len() - 1;
207        let sigmas: Vec<f32> = (0..=n)
208            .map(|i| {
209                let t = i as f64 * last as f64 / n as f64;
210                let lo = (t.floor() as usize).min(last);
211                let hi = (lo + 1).min(last);
212                let f = (t - lo as f64) as f32;
213                base[lo] + (base[hi] - base[lo]) * f
214            })
215            .collect();
216        Stage {
217            sigmas,
218            ancestral: true,
219        }
220    }
221
222    /// `stage2` resampled the same way, for the refinement pass.
223    pub fn stage2_steps(steps: usize) -> Stage {
224        let base = &STAGE2_SIGMAS;
225        let n = steps.max(1);
226        if n + 1 == base.len() {
227            return Stage::stage2();
228        }
229        let last = base.len() - 1;
230        let sigmas: Vec<f32> = (0..=n)
231            .map(|i| {
232                let t = i as f64 * last as f64 / n as f64;
233                let lo = (t.floor() as usize).min(last);
234                let hi = (lo + 1).min(last);
235                let f = (t - lo as f64) as f32;
236                base[lo] + (base[hi] - base[lo]) * f
237            })
238            .collect();
239        Stage {
240            sigmas,
241            ancestral: false,
242        }
243    }
244
245    /// The tail of the schedule that starts at or below `strength` — the
246    /// video-to-video dial. The clip is re-noised to that level and denoised
247    /// from there, so 1.0 keeps only the composition and 0.2 barely touches
248    /// it. The first sigma of the returned schedule *is* the noise scale the
249    /// starting latent is mixed to, which is the same pairing the reference
250    /// uses between its second stage and the latent it upsampled.
251    pub fn from_strength(strength: f32) -> Stage {
252        let s0 = strength.clamp(0.02, 1.0);
253        // Start *at* the level asked for, then follow the distilled ladder
254        // down. Filtering the ladder alone would silently start lower than
255        // requested — at 0.72 the nearest rung below is 0.42, which is a
256        // different edit than the one the caller asked for.
257        let mut sigmas = vec![s0];
258        sigmas.extend(STAGE1_SIGMAS.iter().copied().filter(|&s| s < s0 && s > 0.0));
259        sigmas.push(0.0);
260        Stage {
261            sigmas,
262            ancestral: true,
263        }
264    }
265}
266
267/// One ancestral Euler step in the rectified-flow parameterization
268/// (`alpha = 1 - sigma`): advance to `sigma_down`, then renoise back up to
269/// `sigma_next` with the variance-preserving rescale. `eta = 0` reduces it to
270/// a plain Euler step and ignores `noise`.
271fn euler_step(
272    x: &mut [f32],
273    denoised: &[f32],
274    sigma: f32,
275    sigma_next: f32,
276    eta: f32,
277    noise: Option<&[f32]>,
278) {
279    if sigma_next == 0.0 {
280        x.copy_from_slice(denoised);
281        return;
282    }
283    let down_ratio = 1.0 + (sigma_next / sigma - 1.0) * eta;
284    let sigma_down = sigma_next * down_ratio;
285    let r = sigma_down / sigma;
286    for (v, &d) in x.iter_mut().zip(denoised) {
287        *v = r * *v + (1.0 - r) * d;
288    }
289    if eta > 0.0 {
290        let alpha_next = 1.0 - sigma_next;
291        let alpha_down = 1.0 - sigma_down;
292        let coeff = (sigma_next * sigma_next
293            - sigma_down * sigma_down * alpha_next * alpha_next / (alpha_down * alpha_down))
294            .max(0.0)
295            .sqrt();
296        let scale = alpha_next / alpha_down;
297        let n = noise.expect("ancestral step needs noise");
298        for (v, &e) in x.iter_mut().zip(n) {
299            *v = scale * *v + e * coeff;
300        }
301    }
302}
303
304/// The state a stage carries: patchified latents for both streams.
305pub struct Latents {
306    pub video: Vec<f32>,
307    pub audio: Vec<f32>,
308}
309
310/// What is held fixed while the rest is denoised. `mask[t] = 0` freezes
311/// token `t` at `clean[t]` and hands the transformer a timestep of zero for
312/// it — which is how one encoded image becomes the first frame of a
313/// generated shot, and how a whole encoded clip becomes the picture a
314/// soundtrack is written for.
315#[derive(Clone, Default)]
316pub struct Conditioning {
317    pub video_mask: Vec<f32>,
318    pub video_clean: Vec<f32>,
319    pub audio_mask: Vec<f32>,
320    pub audio_clean: Vec<f32>,
321    /// Extra clean video tokens carried alongside the clip — reference
322    /// images, at their own positions on the time axis. They are denoised
323    /// with the sequence and dropped from the result.
324    pub refs: Option<RefTokens>,
325}
326
327/// Reference tokens: already patchified `[count, 128]`, with one position
328/// triple each.
329#[derive(Clone, Default)]
330pub struct RefTokens {
331    pub latent: Vec<f32>,
332    pub positions: Vec<Vec<f64>>,
333    pub count: usize,
334}
335
336impl Conditioning {
337    /// Freeze the first `frames` latent frames at `clean` (patchified).
338    pub fn video_prefix(geo: &Geometry, clean: &[f32], frames: usize) -> Conditioning {
339        let per = geo.tokens_per_frame();
340        let mut mask = vec![1f32; geo.video_tokens()];
341        let mut full = vec![0f32; geo.video_tokens() * 128];
342        let n = (frames * per).min(geo.video_tokens());
343        for (t, m) in mask.iter_mut().enumerate().take(n) {
344            *m = 0.0;
345            full[t * 128..(t + 1) * 128].copy_from_slice(&clean[t * 128..(t + 1) * 128]);
346        }
347        Conditioning {
348            video_mask: mask,
349            video_clean: full,
350            ..Default::default()
351        }
352    }
353
354    /// Freeze the whole video stream — the picture is given, the sound is
355    /// what is being generated.
356    pub fn video_all(geo: &Geometry, clean: &[f32]) -> Conditioning {
357        Conditioning {
358            video_mask: vec![0f32; geo.video_tokens()],
359            video_clean: clean.to_vec(),
360            ..Default::default()
361        }
362    }
363
364    /// Carry reference images beside the clip. They are frozen (a guide is
365    /// given, not generated) and cropped off the result, so the render comes
366    /// back the size the caller asked for.
367    pub fn with_references(mut self, refs: RefTokens) -> Conditioning {
368        self.refs = Some(refs);
369        self
370    }
371
372    /// Freeze the whole soundtrack — the sound is given, the picture is what
373    /// is being generated.
374    pub fn with_audio_all(mut self, geo: &Geometry, clean: &[f32]) -> Conditioning {
375        self.audio_mask = vec![0f32; geo.af];
376        self.audio_clean = clean.to_vec();
377        self
378    }
379}
380
381/// Progress callback: `(step, total, seconds for that step)`.
382pub type Progress<'a> = &'a mut dyn FnMut(usize, usize, f64);
383
384#[allow(clippy::too_many_arguments)]
385pub fn run_stage(
386    dit: &LtxDit,
387    geo: &Geometry,
388    stage: &Stage,
389    video_ctx: &[f32],
390    audio_ctx: &[f32],
391    ctx_len: usize,
392    init: Option<Latents>,
393    rng: &mut Rng,
394    pool: Option<&Pool>,
395    progress: Progress<'_>,
396) -> Latents {
397    run_stage_cond(
398        dit, geo, stage, video_ctx, audio_ctx, ctx_len, init, None, rng, pool, progress,
399    )
400}
401
402#[allow(clippy::too_many_arguments)]
403pub fn run_stage_cond(
404    dit: &LtxDit,
405    geo: &Geometry,
406    stage: &Stage,
407    video_ctx: &[f32],
408    audio_ctx: &[f32],
409    ctx_len: usize,
410    init: Option<Latents>,
411    cond: Option<&Conditioning>,
412    rng: &mut Rng,
413    pool: Option<&Pool>,
414    progress: Progress<'_>,
415) -> Latents {
416    let vt = geo.video_tokens();
417    let at = geo.af;
418    let vch = 128usize;
419    let ach = 128usize;
420    let s0 = stage.sigmas[0];
421
422    // A fresh stage starts from pure noise; a refinement stage lerps the
423    // incoming latent toward noise by the first sigma, exactly as the
424    // reference's noiser does.
425    let mut v = vec![0f32; vt * vch];
426    let mut a = vec![0f32; at * ach];
427    rng.fill_normal(&mut v);
428    rng.fill_normal(&mut a);
429    if let Some(prev) = init {
430        for (x, &p) in v.iter_mut().zip(&prev.video) {
431            *x = p + (*x - p) * s0;
432        }
433        for (x, &p) in a.iter_mut().zip(&prev.audio) {
434            *x = p + (*x - p) * s0;
435        }
436    }
437
438    // conditioning: a frozen token starts clean, stays clean, and is handed
439    // a timestep of zero so the modulation treats it as already denoised
440    let vmask: Vec<f32> = cond
441        .map(|c| c.video_mask.clone())
442        .filter(|m| m.len() == vt)
443        .unwrap_or_else(|| vec![1f32; vt]);
444    let amask: Vec<f32> = cond
445        .map(|c| c.audio_mask.clone())
446        .filter(|m| m.len() == at)
447        .unwrap_or_else(|| vec![1f32; at]);
448    let vclean: Vec<f32> = cond
449        .map(|c| c.video_clean.clone())
450        .filter(|c| c.len() == v.len())
451        .unwrap_or_else(|| vec![0f32; v.len()]);
452    let aclean: Vec<f32> = cond
453        .map(|c| c.audio_clean.clone())
454        .filter(|c| c.len() == a.len())
455        .unwrap_or_else(|| vec![0f32; a.len()]);
456    let blend = |x: &mut [f32], clean: &[f32], mask: &[f32], ch: usize| {
457        for (t, &m) in mask.iter().enumerate() {
458            if m >= 1.0 {
459                continue;
460            }
461            for d in 0..ch {
462                let i = t * ch + d;
463                x[i] = clean[i] + (x[i] - clean[i]) * m;
464            }
465        }
466    };
467    blend(&mut v, &vclean, &vmask, vch);
468    blend(&mut a, &aclean, &amask, ach);
469
470    let mut vpos = geo.video_positions();
471    let apos = geo.audio_positions();
472    let mut kf = geo.keyframes_mask();
473
474    // Reference tokens ride in the same sequence: clean, frozen, at their own
475    // coordinates. The transformer's attention is permutation-invariant apart
476    // from RoPE, so appending them is the same operation the reference
477    // implementation calls prepending — the position is what carries the
478    // meaning, not the index. `vt` grows here and the result is cropped back
479    // to `clip_tokens` at the end.
480    let clip_tokens = vt;
481    let mut vt = vt;
482    let mut vmask = vmask;
483    let mut vclean = vclean;
484    let mut v = v;
485    if let Some(r) = cond.and_then(|c| c.refs.as_ref()) {
486        if r.count > 0 && r.latent.len() == r.count * vch && r.positions.len() == r.count {
487            v.extend_from_slice(&r.latent);
488            vclean.extend_from_slice(&r.latent);
489            vmask.extend(std::iter::repeat_n(0f32, r.count));
490            kf.extend(std::iter::repeat_n(0f32, r.count));
491            vpos.extend(r.positions.iter().cloned());
492            vt += r.count;
493            tracing::info!(
494                "reference conditioning: {} tokens beside {clip_tokens} of clip",
495                r.count
496            );
497        } else if r.count > 0 {
498            tracing::warn!(
499                "reference conditioning ignored: {} tokens, {} latents, {} positions",
500                r.count,
501                r.latent.len(),
502                r.positions.len()
503            );
504        }
505    }
506    // The frozen-stream rule is decided on the clip, not on the guides: a
507    // render that carries references is still generating its picture, and
508    // reading the whole extended mask would call it clean and close the
509    // fusion gate on it.
510    let v_frozen_src: Vec<f32> = vmask[..clip_tokens].to_vec();
511    let steps = stage.sigmas.len() - 1;
512    let eta = if stage.ancestral { 1.0 } else { 0.0 };
513
514    // A stream that is frozen everywhere is *clean*, and both its own
515    // prompt-adaLN and the other stream's fusion gate must be told so: the
516    // gate reads the other side's sigma and closes on noise, so leaving the
517    // schedule's sigma there makes the transformer discount a picture it was
518    // handed intact. The reference sets it to zero for a frozen modality.
519    let v_frozen = v_frozen_src.iter().all(|&m| m == 0.0);
520    let a_frozen = amask.iter().all(|&m| m == 0.0);
521
522    for i in 0..steps {
523        let t0 = std::time::Instant::now();
524        let sigma = stage.sigmas[i];
525        let sigma_next = stage.sigmas[i + 1];
526        let vin = StreamInput {
527            latent: v.clone(),
528            tokens: vt,
529            timesteps: vmask.iter().map(|m| sigma * m).collect(),
530            positions: vpos.clone(),
531            context: video_ctx.to_vec(),
532            ctx_len,
533            context_mask: Vec::new(),
534            keyframes: kf.clone(),
535            sigma: if v_frozen { 0.0 } else { sigma },
536        };
537        let ain = StreamInput {
538            latent: a.clone(),
539            tokens: at,
540            timesteps: amask.iter().map(|m| sigma * m).collect(),
541            positions: apos.clone(),
542            context: audio_ctx.to_vec(),
543            ctx_len,
544            context_mask: Vec::new(),
545            keyframes: Vec::new(),
546            sigma: if a_frozen { 0.0 } else { sigma },
547        };
548        let (vv, av) = dit.forward(&vin, &ain, pool);
549        // velocity → denoised, at the token's own timestep
550        // velocity → denoised at each token's own timestep, then the frozen
551        // tokens are put back exactly as they were
552        let mut vd: Vec<f32> = v
553            .iter()
554            .zip(&vv)
555            .enumerate()
556            .map(|(i, (&x, &g))| x - g * sigma * vmask[i / vch])
557            .collect();
558        let mut ad: Vec<f32> = a
559            .iter()
560            .zip(&av)
561            .enumerate()
562            .map(|(i, (&x, &g))| x - g * sigma * amask[i / ach])
563            .collect();
564        blend(&mut vd, &vclean, &vmask, vch);
565        blend(&mut ad, &aclean, &amask, ach);
566        let (vn, an) = if eta > 0.0 && sigma_next > 0.0 {
567            let mut vn = vec![0f32; v.len()];
568            let mut an = vec![0f32; a.len()];
569            rng.fill_normal(&mut vn);
570            rng.fill_normal(&mut an);
571            (Some(vn), Some(an))
572        } else {
573            (None, None)
574        };
575        euler_step(&mut v, &vd, sigma, sigma_next, eta, vn.as_deref());
576        euler_step(&mut a, &ad, sigma, sigma_next, eta, an.as_deref());
577        blend(&mut v, &vclean, &vmask, vch);
578        blend(&mut a, &aclean, &amask, ach);
579        progress(i + 1, steps, t0.elapsed().as_secs_f64());
580    }
581    v.truncate(clip_tokens * vch);
582    Latents { video: v, audio: a }
583}
584
585/// Patchified video tokens `[T, 128]` back to a `[128, F, H, W]` volume.
586pub fn unpatchify_video(tokens: &[f32], geo: &Geometry) -> Vec<f32> {
587    let (lf, lh, lw) = (geo.lf, geo.lh, geo.lw);
588    let c = 128usize;
589    let mut out = vec![0f32; c * lf * lh * lw];
590    for f in 0..lf {
591        for h in 0..lh {
592            for w in 0..lw {
593                let t = (f * lh + h) * lw + w;
594                for ch in 0..c {
595                    out[((ch * lf + f) * lh + h) * lw + w] = tokens[t * c + ch];
596                }
597            }
598        }
599    }
600    out
601}
602
603/// Patchified audio tokens `[T, 128]` back to `[8, T, 16]` (channels, time,
604/// mel bins) — the layout the audio VAE decodes.
605pub fn unpatchify_audio(tokens: &[f32], frames: usize) -> Vec<f32> {
606    let (c, mel) = (8usize, 16usize);
607    let mut out = vec![0f32; c * frames * mel];
608    for t in 0..frames {
609        for ch in 0..c {
610            for m in 0..mel {
611                out[(ch * frames + t) * mel + m] = tokens[t * c * mel + ch * mel + m];
612            }
613        }
614    }
615    out
616}
617
618/// A `[128, F, H, W]` volume back to patchified tokens `[T, 128]` — the
619/// inverse of [`unpatchify_video`], for feeding a stage its starting latent.
620pub fn patchify_video(vol: &[f32], geo: &Geometry) -> Vec<f32> {
621    let (lf, lh, lw) = (geo.lf, geo.lh, geo.lw);
622    let c = 128usize;
623    let mut out = vec![0f32; c * lf * lh * lw];
624    for f in 0..lf {
625        for h in 0..lh {
626            for w in 0..lw {
627                let t = (f * lh + h) * lw + w;
628                for ch in 0..c {
629                    out[t * c + ch] = vol[((ch * lf + f) * lh + h) * lw + w];
630                }
631            }
632        }
633    }
634    out
635}