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