Skip to main content

cortiq_engine/
videogen.rs

1//! End-to-end MiniMax-H3 text→(video + synchronized stereo audio):
2//! Qwen3-VL prompt encode → 4-step dual-schedule flow sampling over the
3//! packed DiT → ViT3D video decode and BigVGAN audio decode.
4//!
5//! Stages load and drop one at a time, as the image pipeline next door
6//! does: peak resident is one component, not their sum.
7//!
8//! ## Two clocks, four steps
9//!
10//! The sampler walks the VIDEO sigma grid — `simple` at shift 12, which
11//! at four steps is 1, 0.973, 0.923, 0.8, 0 — and the audio stream is
12//! integrated on its own remap of that grid (shift 3). Stepping both on
13//! the video grid is what a stock sampler does, and it is fine at
14//! twenty steps and audibly wrong at four: `Δσ_a` and `Δσ_v` differ by
15//! a factor of three over the last interval, and no per-step slope
16//! correction fixes a step that large. Hence `--stock-sampler`, which
17//! reproduces the broken behaviour on purpose, for comparison.
18
19use crate::audiovae::AudioVae;
20use crate::mmh3::{Layout, MiniMaxH3, time_shift_sigma};
21use crate::qwen3te::{ImageSpan, Qwen3Encoder};
22use crate::qwen3vis::{self, VisionTower};
23use crate::vae3d::VideoVaeEncoder;
24use crate::sampler::SplitMix64;
25use crate::tokenizer::Tokenizer;
26use crate::vae3d::VideoVae;
27use std::path::Path;
28use std::sync::Arc;
29
30pub const FPS: usize = 24;
31pub const AUDIO_LATENT_FPS: usize = 40;
32
33pub struct AnimParams {
34    pub width: usize,
35    pub height: usize,
36    /// Frames at 24 fps; snapped up to the model's 17k+5 grid.
37    pub frames: usize,
38    pub steps: usize,
39    pub seed: u64,
40    /// Integrate the audio on the video's grid, as a stock sampler
41    /// would. Wrong at four steps; kept for A/B.
42    pub stock_sampler: bool,
43    pub max_tokens: usize,
44    /// RGB in [0, 1] as `[3, h, w]` with its size — the clip's first
45    /// frame, and/or its last.
46    pub first_frame: Option<(Vec<f32>, usize, usize)>,
47    pub last_frame: Option<(Vec<f32>, usize, usize)>,
48}
49
50/// The vision-block token ids the H3 presentation flanks a picture with.
51const VISION_START: u32 = 151_652;
52const VISION_END: u32 = 151_653;
53
54/// Resize `[3, h, w]` RGB to the canvas. The first frame is a geometry
55/// anchor and is stretched; the last one follows and is cover-cropped,
56/// which is what the reference node does with each.
57pub fn fit_to_canvas(
58    rgb: &[f32],
59    h: usize,
60    w: usize,
61    out_h: usize,
62    out_w: usize,
63    crop: bool,
64) -> Vec<f32> {
65    // Cover-crop picks the largest centred rectangle of the source with
66    // the target's aspect; a stretch takes the whole thing.
67    let (sx0, sy0, sw, sh) = if crop {
68        let (tw, th) = (out_w as f64, out_h as f64);
69        let scale = (w as f64 / tw).min(h as f64 / th);
70        let (cw, ch) = ((tw * scale).round() as usize, (th * scale).round() as usize);
71        ((w - cw) / 2, (h - ch) / 2, cw.max(1), ch.max(1))
72    } else {
73        (0, 0, w, h)
74    };
75    let mut out = vec![0f32; 3 * out_h * out_w];
76    for c in 0..3 {
77        for y in 0..out_h {
78            let sy = ((y as f64 + 0.5) * sh as f64 / out_h as f64 - 0.5).max(0.0);
79            let y0 = sy.floor() as usize;
80            let y1 = (y0 + 1).min(sh - 1);
81            let fy = (sy - y0 as f64) as f32;
82            for x in 0..out_w {
83                let sx = ((x as f64 + 0.5) * sw as f64 / out_w as f64 - 0.5).max(0.0);
84                let x0 = sx.floor() as usize;
85                let x1 = (x0 + 1).min(sw - 1);
86                let fx = (sx - x0 as f64) as f32;
87                let p = |yy: usize, xx: usize| rgb[(c * h + sy0 + yy) * w + sx0 + xx];
88                let top = p(y0, x0) * (1.0 - fx) + p(y0, x1) * fx;
89                let bot = p(y1, x0) * (1.0 - fx) + p(y1, x1) * fx;
90                out[(c * out_h + y) * out_w + x] = top * (1.0 - fy) + bot * fy;
91            }
92        }
93    }
94    out
95}
96
97impl Default for AnimParams {
98    fn default() -> Self {
99        Self {
100            width: 512,
101            height: 288,
102            frames: 39,
103            steps: 4,
104            seed: 42,
105            stock_sampler: false,
106            max_tokens: 512,
107            first_frame: None,
108            last_frame: None,
109        }
110    }
111}
112
113/// The rendered result: RGB in [0, 1] as `[3, frames, h, w]`, and
114/// stereo f32 in [-1, 1] as `[2, samples]`.
115pub struct Anim {
116    pub rgb: Vec<f32>,
117    pub frames: usize,
118    pub height: usize,
119    pub width: usize,
120    pub audio: Vec<f32>,
121    pub samples: usize,
122    pub sample_rate: usize,
123}
124
125/// Frame counts snap UP to 17k+5 — the grid the temporal VAE and the
126/// DiT's frame-span pattern agree on.
127pub fn align_frames(n: usize) -> usize {
128    let mut n = n.max(5);
129    while n % 17 != 5 {
130        n += 1;
131    }
132    n
133}
134
135pub fn video_latent_t(frames: usize) -> usize {
136    if frames <= 5 {
137        2
138    } else {
139        (frames - 5) / 17 * 5 + 2
140    }
141}
142
143/// `(frames, latent_t, audio_t)` for a requested length.
144pub fn temporal_shape(len: usize) -> (usize, usize, usize) {
145    let frames = align_frames(len);
146    let audio_t =
147        ((frames as f64 / FPS as f64) * AUDIO_LATENT_FPS as f64).round() as usize;
148    (frames, video_latent_t(frames), audio_t)
149}
150
151/// The `simple` scheduler over `ModelSamplingDiscreteFlow(shift)`: the
152/// 1000-entry sigma table sampled at even strides, terminal 0 appended.
153pub fn sigmas(steps: usize, shift: f64) -> Vec<f64> {
154    let table = 1000usize;
155    let mut out: Vec<f64> = (0..steps)
156        .map(|x| {
157            let idx = table - 1 - x * table / steps;
158            let t = (idx + 1) as f64 / table as f64;
159            shift * t / (1.0 + (shift - 1.0) * t)
160        })
161        .collect();
162    out.push(0.0);
163    out
164}
165
166/// Shared with the DiT's condition-row noise blend.
167pub fn gauss_pub(n: usize, seed: u64) -> Vec<f32> {
168    gauss(n, seed)
169}
170
171fn gauss(n: usize, seed: u64) -> Vec<f32> {
172    let mut rng = SplitMix64::new(seed);
173    let mut u = || (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
174    let mut out = Vec::with_capacity(n);
175    while out.len() < n {
176        let (a, b) = (u().max(1e-300), u());
177        let r = (-2.0 * a.ln()).sqrt();
178        let ang = 2.0 * std::f64::consts::PI * b;
179        out.push((r * ang.cos()) as f32);
180        if out.len() < n {
181            out.push((r * ang.sin()) as f32);
182        }
183    }
184    out
185}
186
187/// Text→(video, audio) from a packaged `.cmf`.
188pub fn generate(
189    path: &Path,
190    prompt: &str,
191    p: &AnimParams,
192    mut progress: impl FnMut(&str, usize, usize),
193) -> Result<Anim, String> {
194    if p.width % 32 != 0 || p.height % 32 != 0 {
195        return Err("width/height must be multiples of 32".into());
196    }
197    // The wgpu wide-GEMM arm is WRONG on this stack: measured on an RTX
198    // PRO 6000, the DiT's first step already disagrees with the host
199    // (video velocity rms 1.32 against 1.73, audio 0.16 against 1.01)
200    // and the second returns NaN. The op probe picks that arm on its
201    // own because it is three times faster, so this pipeline runs pure
202    // host unless the caller says otherwise — a wrong answer produced
203    // quickly is not a faster answer. `CMF_MMH3_GPU=1` opts in.
204    let force_gpu = std::env::var("CMF_MMH3_GPU").ok().as_deref() == Some("1");
205    if force_gpu {
206        generate_inner(path, prompt, p, &mut progress)
207    } else {
208        crate::gpu::cpu_scope(|| generate_inner(path, prompt, p, &mut progress))
209    }
210}
211
212fn generate_inner(
213    path: &Path,
214    prompt: &str,
215    p: &AnimParams,
216    progress: &mut dyn FnMut(&str, usize, usize),
217) -> Result<Anim, String> {
218    let model = Arc::new(
219        cortiq_core::CmfModel::open(path).map_err(|e| format!("{}: {e}", path.display()))?,
220    );
221    let (frames_total, latent_t, audio_t) = temporal_shape(p.frames);
222    let (lat_h, lat_w) = (p.height / 16, p.width / 16);
223
224    // ── prompt ──
225    // The H3 presentation is raw text: no chat template, no BOS, no
226    // special tokens at all.
227    let vocab = model
228        .vocab
229        .as_deref()
230        .ok_or("packaged .cmf has no embedded tokenizer")?;
231    let tok = Tokenizer::from_bytes(vocab).map_err(|e| format!("tokenizer: {e}"))?;
232    // fl2va: every keyframe is presented as "<Picture i>: " and a
233    // vision block BEFORE the prompt, and separately conditions the DiT
234    // as a latent. Both halves come from the same picture.
235    let keyframes: Vec<(&(Vec<f32>, usize, usize), usize)> = p
236        .first_frame
237        .iter()
238        .map(|f| (f, 0usize))
239        .chain(p.last_frame.iter().map(|f| (f, frames_total - 1)))
240        .collect();
241    let mut ids: Vec<u32> = Vec::new();
242    let mut spans: Vec<ImageSpan> = Vec::new();
243    let mut embeds: Vec<Vec<f32>> = Vec::new();
244    let mut deepstack: Vec<Vec<f32>> = Vec::new();
245    let mut cond: Vec<Vec<f32>> = Vec::new();
246    let mut tags: Vec<u8> = Vec::new();
247
248    if !keyframes.is_empty() {
249        let tower = VisionTower::from_cmf(&model)?;
250        let venc = VideoVaeEncoder::from_cmf(&model)?;
251        for (i, (frame, _)) in keyframes.iter().enumerate() {
252            let (src, sh, sw) = *frame;
253            // The picture the DiT sees is on the generation canvas; the
254            // one Qwen sees keeps its own resolution policy.
255            let fitted = fit_to_canvas(src, *sh, *sw, p.height, p.width, i > 0);
256            let (z, _, _) = venc.encode_frame(
257                &fitted.iter().map(|&v| v * 2.0 - 1.0).collect::<Vec<_>>(),
258                p.height,
259                p.width,
260            );
261            cond.push(z);
262
263            for t in tok.encode(&format!("<Picture {}>: ", i + 1)) {
264                ids.push(t);
265                tags.push(1);
266            }
267            let (patches, gh, gw) = qwen3vis::preprocess(
268                &fitted, p.height, p.width,
269                tower.patch_size, tower.temporal_patch, tower.merge,
270            );
271            let (merged, deep) = tower.forward(&patches, gh, gw);
272            let n_img = merged.len() / tower.out_hidden;
273            // The whole block carries the VIDEO tag, the flanking
274            // markers included.
275            ids.push(VISION_START);
276            tags.push(0);
277            let start = ids.len();
278            for _ in 0..n_img {
279                ids.push(VISION_START); // a placeholder the embed replaces
280                tags.push(0);
281            }
282            ids.push(VISION_END);
283            tags.push(0);
284            spans.push(ImageSpan { start, len: n_img, merged_h: gh / tower.merge, merged_w: gw / tower.merge });
285            embeds.push(merged);
286            if deepstack.is_empty() {
287                deepstack = deep;
288            } else {
289                for (a, b) in deepstack.iter_mut().zip(deep) {
290                    a.extend_from_slice(&b);
291                }
292            }
293        }
294    }
295    for t in tok.encode(prompt) {
296        ids.push(t);
297        tags.push(1);
298    }
299    if ids.is_empty() {
300        ids.push(151643); // the pad id, as the reference does for ""
301        tags.push(1);
302    }
303    ids.truncate(p.max_tokens);
304    tags.truncate(ids.len());
305    progress("encode", 0, 1);
306    let states = {
307        let enc = Qwen3Encoder::from_cmf(&model)?;
308        enc.encode_with_images(&ids, &spans, &embeds, &deepstack)
309    };
310    progress("encode", 1, 1);
311
312    // ── denoise ──
313    let (video, audio) = {
314        let dit = MiniMaxH3::from_cmf(&model)?;
315        let kf: Vec<(usize, usize)> = keyframes.iter().map(|&(_, idx)| (idx, frames_total)).collect();
316        let layout = if kf.is_empty() {
317            Layout::t2va(ids.len(), latent_t, lat_h, lat_w, audio_t)
318        } else {
319            Layout::fl2va(ids.len(), latent_t, lat_h, lat_w, audio_t, &kf, &tags)
320        };
321        let text = dit.refine_text(&states, ids.len());
322        let mut v = gauss(dit.latents_dim * latent_t * lat_h * lat_w, p.seed);
323        let mut a = gauss(dit.audio_dim * 2 * audio_t, p.seed ^ 0x9E37_79B9_7F4A_7C15);
324        let sg = sigmas(p.steps, dit.shift_video);
325        // `CMF_ANIM_PROF=1`: the per-step rms of both streams and of
326        // their velocities. A run that is not denoising shows it here
327        // long before anything is written out.
328        let prof = std::env::var_os("CMF_ANIM_PROF").is_some();
329        let rms = |x: &[f32]| {
330            (x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64).sqrt()
331        };
332        if prof {
333            eprintln!(
334                "  text {} tok, refined rms {:.4}, sigmas {:?}",
335                ids.len(),
336                rms(&text),
337                sg.iter().map(|v| (v * 1e4).round() / 1e4).collect::<Vec<_>>()
338            );
339        }
340        for i in 0..p.steps {
341            let (sv, sv_n) = (sg[i], sg[i + 1]);
342            let (dv, da) = dit.forward(&layout, &text, &v, &a, sv, &cond);
343            let step_v = (sv_n - sv) as f32;
344            for (x, &d) in v.iter_mut().zip(&dv) {
345                *x += step_v * d;
346            }
347            let step_a = if p.stock_sampler {
348                step_v
349            } else {
350                (time_shift_sigma(sv_n, dit.shift_video, dit.shift_audio)
351                    - time_shift_sigma(sv.max(1e-6), dit.shift_video, dit.shift_audio))
352                    as f32
353            };
354            for (x, &d) in a.iter_mut().zip(&da) {
355                *x += step_a * d;
356            }
357            if prof {
358                eprintln!(
359                    "  step {i}: sv {sv:.4}->{sv_n:.4} v_vel {:.4} a_vel {:.4} | video {:.4} audio {:.4}",
360                    rms(&dv), rms(&da), rms(&v), rms(&a)
361                );
362            }
363            progress("denoise", i + 1, p.steps);
364        }
365        (v, a)
366    };
367
368    // ── decode ──
369    progress("video vae", 0, 1);
370    let (rgb, out_frames) = {
371        let vae = VideoVae::from_cmf(&model)?;
372        vae.decode(&video, latent_t, lat_h, lat_w)
373    };
374    progress("video vae", 1, 1);
375    progress("audio vae", 0, 1);
376    let (wave, samples, sr) = {
377        let vae = AudioVae::from_cmf(&model)?;
378        let c = audio.len() / (2 * audio_t);
379        let (w, n) = vae.decode(&audio, c, audio_t);
380        (w, n, vae.sample_rate)
381    };
382    progress("audio vae", 1, 1);
383
384    // The VAE emits latent_t·4 frames; the request snapped to 17k+5,
385    // which is one fewer than a multiple of four plus the leading key
386    // frame, so trim rather than pad.
387    let keep = out_frames.min(frames_total);
388    Ok(Anim {
389        rgb: trim_frames(&rgb, out_frames, keep, p.height, p.width),
390        frames: keep,
391        height: p.height,
392        width: p.width,
393        audio: wave,
394        samples,
395        sample_rate: sr,
396    })
397}
398
399fn trim_frames(rgb: &[f32], have: usize, keep: usize, h: usize, w: usize) -> Vec<f32> {
400    if keep == have {
401        return rgb.to_vec();
402    }
403    let mut out = vec![0f32; 3 * keep * h * w];
404    for c in 0..3 {
405        let s = c * have * h * w;
406        let d = c * keep * h * w;
407        out[d..d + keep * h * w].copy_from_slice(&rgb[s..s + keep * h * w]);
408    }
409    out
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn the_four_step_schedule_is_the_references() {
418        let s = sigmas(4, 12.0);
419        let want = [1.0, 0.972_973, 0.923_077, 0.8, 0.0];
420        assert_eq!(s.len(), want.len());
421        for (g, w) in s.iter().zip(&want) {
422            assert!((g - w).abs() < 1e-6, "{s:?}");
423        }
424    }
425
426    #[test]
427    fn a_stretch_keeps_the_corners_and_a_crop_takes_the_middle() {
428        // A 4x2 ramp: value rises left to right, so the corners name
429        // themselves.
430        let (h, w) = (2usize, 4usize);
431        let mut rgb = vec![0f32; 3 * h * w];
432        for c in 0..3 {
433            for y in 0..h {
434                for x in 0..w {
435                    rgb[(c * h + y) * w + x] = x as f32 / (w - 1) as f32;
436                }
437            }
438        }
439        // Stretch to a square: the far edges survive.
440        let s = fit_to_canvas(&rgb, h, w, 4, 4, false);
441        assert!((s[0] - 0.0).abs() < 1e-6, "left edge");
442        assert!((s[3] - 1.0).abs() < 1e-6, "right edge");
443        // Cover-crop to a square takes the centre 2x2, so the extremes
444        // are gone and the span is narrower.
445        let c = fit_to_canvas(&rgb, h, w, 4, 4, true);
446        let (lo, hi) = c[..16]
447            .iter()
448            .fold((f32::MAX, f32::MIN), |(a, b), &v| (a.min(v), b.max(v)));
449        assert!(lo > 0.05, "crop kept the left edge: {lo}");
450        assert!(hi < 0.95, "crop kept the right edge: {hi}");
451    }
452
453    #[test]
454    fn frame_counts_snap_to_the_models_grid() {
455        // The grid is 5 + 17k: 5, 22, 39, 56, … 124.
456        assert_eq!(align_frames(1), 5);
457        assert_eq!(align_frames(39), 39);
458        assert_eq!(align_frames(41), 56);
459        assert_eq!(align_frames(124), 124);
460        assert_eq!(video_latent_t(124), 37);
461        assert_eq!(video_latent_t(39), 12);
462        let (f, lt, at) = temporal_shape(124);
463        assert_eq!((f, lt, at), (124, 37, 207));
464    }
465}