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::sampler::SplitMix64;
24use crate::tokenizer::Tokenizer;
25use crate::vae3d::VideoVae;
26use crate::vae3d::VideoVaeEncoder;
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    /// Extra reference frames with the pixel index each one stands for:
49    /// video-to-video as this architecture actually takes it — every
50    /// frame is a condition row pinned to its own time coordinate, the
51    /// same machinery `--first-frame`/`--last-frame` use for two.
52    pub mid_frames: Vec<((Vec<f32>, usize, usize), usize)>,
53    /// A LoRA adapter (.safetensors) applied at runtime, and how hard.
54    pub lora: Option<String>,
55    pub lora_strength: f32,
56    /// The published latent upscaler (.safetensors) and the factor to
57    /// apply: the denoised latent is resized by the learned net and the
58    /// VAE decodes at the larger size, so the 5 B-parameter decode →
59    /// pixel resize → encode round trip never happens.
60    pub upscale: Option<String>,
61    pub upscale_by: f32,
62    /// Chunk-causal (streaming) generation: latent frames per chunk, and
63    /// how many chunks a chunk may see — `sink` from the start of the
64    /// clip and a sliding `window` of recent ones. 0 chunks = the
65    /// bidirectional path. This is what a streaming adapter is trained
66    /// for, and it is what stops the activation cache growing with the
67    /// clip's length.
68    pub stream_chunk: usize,
69    pub stream_sink: usize,
70    pub stream_window: usize,
71}
72
73/// The vision-block token ids the H3 presentation flanks a picture with.
74const VISION_START: u32 = 151_652;
75const VISION_END: u32 = 151_653;
76
77/// Resize `[3, h, w]` RGB to the canvas. The first frame is a geometry
78/// anchor and is stretched; the last one follows and is cover-cropped,
79/// which is what the reference node does with each.
80pub fn fit_to_canvas(
81    rgb: &[f32],
82    h: usize,
83    w: usize,
84    out_h: usize,
85    out_w: usize,
86    crop: bool,
87) -> Vec<f32> {
88    // Cover-crop picks the largest centred rectangle of the source with
89    // the target's aspect; a stretch takes the whole thing.
90    let (sx0, sy0, sw, sh) = if crop {
91        let (tw, th) = (out_w as f64, out_h as f64);
92        let scale = (w as f64 / tw).min(h as f64 / th);
93        let (cw, ch) = ((tw * scale).round() as usize, (th * scale).round() as usize);
94        ((w - cw) / 2, (h - ch) / 2, cw.max(1), ch.max(1))
95    } else {
96        (0, 0, w, h)
97    };
98    let mut out = vec![0f32; 3 * out_h * out_w];
99    for c in 0..3 {
100        for y in 0..out_h {
101            let sy = ((y as f64 + 0.5) * sh as f64 / out_h as f64 - 0.5).max(0.0);
102            let y0 = sy.floor() as usize;
103            let y1 = (y0 + 1).min(sh - 1);
104            let fy = (sy - y0 as f64) as f32;
105            for x in 0..out_w {
106                let sx = ((x as f64 + 0.5) * sw as f64 / out_w as f64 - 0.5).max(0.0);
107                let x0 = sx.floor() as usize;
108                let x1 = (x0 + 1).min(sw - 1);
109                let fx = (sx - x0 as f64) as f32;
110                let p = |yy: usize, xx: usize| rgb[(c * h + sy0 + yy) * w + sx0 + xx];
111                let top = p(y0, x0) * (1.0 - fx) + p(y0, x1) * fx;
112                let bot = p(y1, x0) * (1.0 - fx) + p(y1, x1) * fx;
113                out[(c * out_h + y) * out_w + x] = top * (1.0 - fy) + bot * fy;
114            }
115        }
116    }
117    out
118}
119
120impl Default for AnimParams {
121    fn default() -> Self {
122        Self {
123            width: 512,
124            height: 288,
125            frames: 39,
126            steps: 4,
127            seed: 42,
128            stock_sampler: false,
129            max_tokens: 512,
130            first_frame: None,
131            last_frame: None,
132            mid_frames: Vec::new(),
133            lora: None,
134            lora_strength: 1.0,
135            upscale: None,
136            upscale_by: 2.0,
137            stream_chunk: 0,
138            stream_sink: 2,
139            stream_window: 2,
140        }
141    }
142}
143
144/// The rendered result: RGB in [0, 1] as `[3, frames, h, w]`, and
145/// stereo f32 in [-1, 1] as `[2, samples]`.
146pub struct Anim {
147    pub rgb: Vec<f32>,
148    pub frames: usize,
149    pub height: usize,
150    pub width: usize,
151    pub audio: Vec<f32>,
152    pub samples: usize,
153    pub sample_rate: usize,
154}
155
156/// Frame counts snap UP to 17k+5 — the grid the temporal VAE and the
157/// DiT's frame-span pattern agree on.
158pub fn align_frames(n: usize) -> usize {
159    let mut n = n.max(5);
160    while n % 17 != 5 {
161        n += 1;
162    }
163    n
164}
165
166pub fn video_latent_t(frames: usize) -> usize {
167    if frames <= 5 {
168        2
169    } else {
170        (frames - 5) / 17 * 5 + 2
171    }
172}
173
174/// `(frames, latent_t, audio_t)` for a requested length.
175pub fn temporal_shape(len: usize) -> (usize, usize, usize) {
176    let frames = align_frames(len);
177    let audio_t = ((frames as f64 / FPS as f64) * AUDIO_LATENT_FPS as f64).round() as usize;
178    (frames, video_latent_t(frames), audio_t)
179}
180
181/// The `simple` scheduler over `ModelSamplingDiscreteFlow(shift)`: the
182/// 1000-entry sigma table sampled at even strides, terminal 0 appended.
183pub fn sigmas(steps: usize, shift: f64) -> Vec<f64> {
184    let table = 1000usize;
185    let mut out: Vec<f64> = (0..steps)
186        .map(|x| {
187            let idx = table - 1 - x * table / steps;
188            let t = (idx + 1) as f64 / table as f64;
189            shift * t / (1.0 + (shift - 1.0) * t)
190        })
191        .collect();
192    out.push(0.0);
193    out
194}
195
196/// Shared with the DiT's condition-row noise blend.
197pub fn gauss_pub(n: usize, seed: u64) -> Vec<f32> {
198    gauss(n, seed)
199}
200
201fn gauss(n: usize, seed: u64) -> Vec<f32> {
202    let mut rng = SplitMix64::new(seed);
203    let mut u = || (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
204    let mut out = Vec::with_capacity(n);
205    while out.len() < n {
206        let (a, b) = (u().max(1e-300), u());
207        let r = (-2.0 * a.ln()).sqrt();
208        let ang = 2.0 * std::f64::consts::PI * b;
209        out.push((r * ang.cos()) as f32);
210        if out.len() < n {
211            out.push((r * ang.sin()) as f32);
212        }
213    }
214    out
215}
216
217/// Text→(video, audio) from a packaged `.cmf`.
218pub fn generate(
219    path: &Path,
220    prompt: &str,
221    p: &AnimParams,
222    mut progress: impl FnMut(&str, usize, usize),
223) -> Result<Anim, String> {
224    if p.width % 32 != 0 || p.height % 32 != 0 {
225        return Err("width/height must be multiples of 32".into());
226    }
227    // The wgpu wide-GEMM arm was measured WRONG on one driver stack
228    // (RTX PRO 6000: step-1 velocity rms off, step-2 NaN) and byte-
229    // healthy on another (2×RTX 5090, coop and plain arms within 0.5%
230    // of each other and 3.5% of the host render). Trust is therefore
231    // PER-STACK, decided by a parity probe on this file's own first
232    // qkv weight at DiT-scale activations — not by a hardcoded verdict
233    // either way. CMF_MMH3_GPU=1/0 still forces.
234    let use_gpu = match std::env::var("CMF_MMH3_GPU").ok().as_deref() {
235        Some("1") => true,
236        Some("0") => false,
237        _ => mmh3_gpu_parity_probe(path).unwrap_or(false),
238    };
239    if use_gpu {
240        generate_inner(path, prompt, p, &mut progress)
241    } else {
242        crate::gpu::cpu_scope(|| generate_inner(path, prompt, p, &mut progress))
243    }
244}
245
246/// GPU-vs-host parity on the packed DiT's first attention projection:
247/// the real q4tp bytes, activations spanning the modulation range
248/// (±2000 mixed with ±2), rms gate at 1e-2 — the measured failure was
249/// ~24%, honest drift is ~1e-5, so the gate has a decade of margin on
250/// each side. Any refusal (no adapter, dtype outside the kernel) is a
251/// clean "no": the host path is never wrong, only slower.
252pub(crate) fn mmh3_gpu_parity_probe(path: &Path) -> Result<bool, String> {
253    let model = Arc::new(
254        cortiq_core::CmfModel::open(path).map_err(|e| format!("{}: {e}", path.display()))?,
255    );
256    // Any codec, not just q4tp: the probe's job is to check THIS file's
257    // device arm against the host, and a container packed as q8_2f has one
258    // too. Matching on dtype sent every non-q4tp build to the CPU for the
259    // whole render — measured at 357 s against 60 on the same card.
260    // A container with the prompt encoder alone (`animate-pack --te` by
261    // itself) is probed on the encoder's first projection instead.
262    let Some(idx) = model
263        .tensors
264        .iter()
265        .position(|t| t.name.starts_with("dit.") && t.name.ends_with("attn.qkv_proj.weight"))
266        .or_else(|| {
267            model
268                .tensors
269                .iter()
270                .position(|t| t.name.starts_with("te.layers.") && t.name.ends_with("mlp.gate_proj.weight"))
271        })
272    else {
273        tracing::info!("mmh3 GPU parity probe: no qkv weight — host path");
274        return Ok(false);
275    };
276    let entry = &model.tensors[idx];
277    let (rows, cols) = (entry.shape[0], entry.shape[1]);
278    let b = 64usize;
279    let mut xs = vec![0f32; b * cols];
280    for (i, v) in xs.iter_mut().enumerate() {
281        let base = ((i * 37 + 11) % 1009) as f32 / 1009.0 - 0.5;
282        *v = base * if i % 7 == 0 { 2000.0 } else { 2.0 };
283    }
284    let mut gpu = vec![0f32; b * rows];
285    if std::env::var("CMF_GPU_DEBUG").is_ok() {
286        eprintln!(
287            "mmh3 probe env: CMF_GPU={:?} enabled={} avail={}",
288            std::env::var("CMF_GPU").ok(),
289            crate::gpu::enabled(),
290            crate::gpu::backend_available(),
291        );
292    }
293    let qt = crate::qtensor::QTensor::from_model(&model, &entry.name.clone())?;
294    // The native CUDA backend reads q4tp and q8_2f alike through one
295    // product (`gpu::q4tp_matmat`'s arm there); the per-codec device entry
296    // points of `device_matmat` are wgpu's and Metal's.
297    let took = if crate::gpu::cuda_active() {
298        crate::gpu::q4tp_matmat(&model, idx, &xs, b, rows, cols, &mut gpu)
299    } else {
300        qt.device_matmat(&xs, b, &mut gpu)
301    };
302    if !took {
303        tracing::info!(
304            "mmh3 GPU parity probe: {:?} device GEMM refused ({rows}x{cols}) — host path",
305            entry.dtype
306        );
307        if std::env::var("CMF_GPU_DEBUG").is_ok() {
308            eprintln!(
309                "mmh3 probe: device GEMM refused {rows}x{cols} for {:?}",
310                entry.dtype
311            );
312        }
313        return Ok(false);
314    }
315    let host = {
316        let name = entry.name.clone();
317        let proj = crate::dit::Proj::from_model(&model, &name)?;
318        let mut out = vec![0f32; b * rows];
319        crate::gpu::cpu_scope(|| proj.matmat(&xs, b, &mut out, None));
320        out
321    };
322    let mut num = 0f64;
323    let mut den = 0f64;
324    for (g, h) in gpu.iter().zip(&host) {
325        num += ((g - h) as f64).powi(2);
326        den += (*h as f64).powi(2);
327    }
328    let rel = (num / den.max(1e-30)).sqrt();
329    let ok = rel < 1e-2;
330    tracing::info!(
331        "mmh3 GPU parity probe: rel rms {rel:.2e} → {}",
332        if ok { "device" } else { "host" }
333    );
334    if std::env::var("CMF_GPU_DEBUG").is_ok() {
335        eprintln!("mmh3 GPU parity probe: rel rms {rel:.2e}");
336    }
337    Ok(ok)
338}
339
340fn generate_inner(
341    path: &Path,
342    prompt: &str,
343    p: &AnimParams,
344    progress: &mut dyn FnMut(&str, usize, usize),
345) -> Result<Anim, String> {
346    let model = Arc::new(
347        cortiq_core::CmfModel::open(path).map_err(|e| format!("{}: {e}", path.display()))?,
348    );
349    let (frames_total, latent_t, audio_t) = temporal_shape(p.frames);
350    let (lat_h, lat_w) = (p.height / 16, p.width / 16);
351
352    // ── prompt ──
353    // The H3 presentation is raw text: no chat template, no BOS, no
354    // special tokens at all.
355    // Stage clock. Half of a render is not the DiT — at 8 steps the
356    // denoiser is 63 s of 116 — and until this line existed there was
357    // no way to see which half anything went to.
358    let t_stage = std::time::Instant::now();
359    let mut marks: Vec<(&str, f32)> = Vec::new();
360    let lap = |marks: &mut Vec<(&'static str, f32)>, name: &'static str| {
361        let prev: f32 = marks.iter().map(|(_, v)| v).sum();
362        marks.push((name, t_stage.elapsed().as_secs_f32() - prev));
363    };
364    let vocab = model
365        .vocab
366        .as_deref()
367        .ok_or("packaged .cmf has no embedded tokenizer")?;
368    let tok = Tokenizer::from_bytes(vocab).map_err(|e| format!("tokenizer: {e}"))?;
369    // fl2va: every keyframe is presented as "<Picture i>: " and a
370    // vision block BEFORE the prompt, and separately conditions the DiT
371    // as a latent. Both halves come from the same picture.
372    let mut keyframes: Vec<(&(Vec<f32>, usize, usize), usize)> = p
373        .first_frame
374        .iter()
375        .map(|f| (f, 0usize))
376        .chain(
377            p.mid_frames
378                .iter()
379                .map(|(f, i)| (f, (*i).min(frames_total - 1))),
380        )
381        .chain(p.last_frame.iter().map(|f| (f, frames_total - 1)))
382        .collect();
383    // Condition rows are placed by time coordinate, so they have to arrive
384    // in time order and only once per frame — two references pinned to the
385    // same pixel index would be two rows claiming one moment.
386    keyframes.sort_by_key(|(_, i)| *i);
387    keyframes.dedup_by_key(|(_, i)| *i);
388    let mut ids: Vec<u32> = Vec::new();
389    let mut spans: Vec<ImageSpan> = Vec::new();
390    let mut embeds: Vec<Vec<f32>> = Vec::new();
391    let mut deepstack: Vec<Vec<f32>> = Vec::new();
392    let mut cond: Vec<Vec<f32>> = Vec::new();
393    let mut tags: Vec<u8> = Vec::new();
394
395    if !keyframes.is_empty() {
396        let tower = VisionTower::from_cmf(&model)?;
397        // An activation harvest (CMF_TE_ONLY) never denoises, and the
398        // VAE latent is the DiT's food alone — the frame's 3-D conv
399        // encode is 99.5 s of a 102.5 s M4 run. Skip it.
400        let te_only = std::env::var("CMF_TE_ONLY").as_deref() == Ok("1");
401        let venc = if te_only {
402            None
403        } else {
404            Some(VideoVaeEncoder::from_cmf(&model)?)
405        };
406        for (i, (frame, _)) in keyframes.iter().enumerate() {
407            let (src, sh, sw) = *frame;
408            // The picture the DiT sees is on the generation canvas; the
409            // one Qwen sees keeps its own resolution policy.
410            let fitted = fit_to_canvas(src, *sh, *sw, p.height, p.width, i > 0);
411            if let Some(venc) = &venc {
412                let (z, _, _) = venc.encode_frame(
413                    &fitted.iter().map(|&v| v * 2.0 - 1.0).collect::<Vec<_>>(),
414                    p.height,
415                    p.width,
416                );
417                cond.push(z);
418            }
419
420            for t in tok.encode(&format!("<Picture {}>: ", i + 1)) {
421                ids.push(t);
422                tags.push(1);
423            }
424            let (patches, gh, gw) = qwen3vis::preprocess(
425                &fitted,
426                p.height,
427                p.width,
428                tower.patch_size,
429                tower.temporal_patch,
430                tower.merge,
431            );
432            let (merged, deep) = tower.forward(&patches, gh, gw);
433            let n_img = merged.len() / tower.out_hidden;
434            // The whole block carries the VIDEO tag, the flanking
435            // markers included.
436            ids.push(VISION_START);
437            tags.push(0);
438            let start = ids.len();
439            for _ in 0..n_img {
440                ids.push(VISION_START); // a placeholder the embed replaces
441                tags.push(0);
442            }
443            ids.push(VISION_END);
444            tags.push(0);
445            spans.push(ImageSpan {
446                start,
447                len: n_img,
448                merged_h: gh / tower.merge,
449                merged_w: gw / tower.merge,
450            });
451            embeds.push(merged);
452            if deepstack.is_empty() {
453                deepstack = deep;
454            } else {
455                for (a, b) in deepstack.iter_mut().zip(deep) {
456                    a.extend_from_slice(&b);
457                }
458            }
459        }
460    }
461    for t in tok.encode(prompt) {
462        ids.push(t);
463        tags.push(1);
464    }
465    if ids.is_empty() {
466        ids.push(151643); // the pad id, as the reference does for ""
467        tags.push(1);
468    }
469    ids.truncate(p.max_tokens);
470    tags.truncate(ids.len());
471    lap(&mut marks, "prepare");
472    // The prompt encoder is a one-shot pass over 12 GB of weights; on a
473    // machine the file does not fit it streams from disk and its GEMMs
474    // run over any contention budget for reasons that are not contention.
475    // The kill stays disarmed until the encode is done (users on 24 GB
476    // Macs had to patch it out to keep the denoise loop on the GPU).
477    crate::gpu::mm_kill_arm(false);
478    progress("encode", 0, 1);
479    let states = {
480        let enc = Qwen3Encoder::from_cmf(&model)?;
481        enc.encode_with_images(&ids, &spans, &embeds, &deepstack)
482    };
483    // `CMF_TE_DUMP=<path>`: the conditioning as `[u64 n][u64 width]`
484    // then f32 rows. A stand-in encoder is only as good as the stream
485    // it hands the DiT, and that is measurable against the teacher's
486    // dump on the same prompt WITHOUT rendering a frame.
487    if let Ok(p) = std::env::var("CMF_TE_DUMP") {
488        let w = states.len() / ids.len().max(1);
489        let mut b = Vec::with_capacity(16 + states.len() * 4);
490        b.extend_from_slice(&(ids.len() as u64).to_le_bytes());
491        b.extend_from_slice(&(w as u64).to_le_bytes());
492        for v in &states {
493            b.extend_from_slice(&v.to_le_bytes());
494        }
495        std::fs::write(&p, &b).map_err(|e| format!("CMF_TE_DUMP {p}: {e}"))?;
496        eprintln!("te dump: {} tokens x {w} -> {p}", ids.len());
497    }
498    progress("encode", 1, 1);
499    lap(&mut marks, "text encode");
500    crate::gpu::mm_kill_arm(true);
501    // `CMF_TE_ONLY=1`: stop after the dump — an activation-harvest run
502    // (the ClipProj refit) wants hundreds of encodes and zero renders.
503    if std::env::var("CMF_TE_ONLY").as_deref() == Ok("1") {
504        return Err("CMF_TE_ONLY: encode dumped, render skipped".into());
505    }
506    // The prompt encoder and vision tower ran their once-per-generation
507    // pass; release their page cache so the denoise loop's DiT does not
508    // fight 12+ GB of dead weights for RAM. On a 24 GB Mac with the
509    // 25.7 GB full-encoder fl2va file this is the difference between
510    // denoise steps at DiT speed and 320 s/step of SSD thrash.
511    {
512        let dropped = model.advise_done(|n| n.starts_with("model.") || n.starts_with("vis."));
513        if dropped > 0 {
514            tracing::info!(
515                "encoder pages released after prompt encode: {} MB",
516                dropped / (1024 * 1024)
517            );
518        }
519    }
520    // The same on the card: a resident chain keeps the encoder's weights
521    // until its stage is declared done, and the DiT does not fit beside
522    // them on a 24 GB card.
523    crate::gpu::stage_done(&model);
524
525    // ── denoise ──
526    let (mut video, audio) = {
527        // The adapter is read here, after the encoder's pages are gone:
528        // a rank-32 file for this DiT is 130 MB of f32 once expanded and
529        // there is no reason for it to share a peak with 12 GB of text
530        // tower on a 24 GB machine.
531        let bank = match p.lora.as_deref() {
532            None => None,
533            Some(path) => {
534                let k =
535                    crate::ltxlora::LoraBank::load(std::path::Path::new(path), p.lora_strength)?;
536                Some(k)
537            }
538        };
539        let dit = MiniMaxH3::from_cmf_lora(&model, bank.as_ref())?;
540        if let Some(k) = &bank {
541            let bound = dit.lora_bound();
542            // Say what did NOT land. An adaLN branch on a curve-form
543            // pack is the one real gap, and a user who sees "applied"
544            // while half the adapter sat out has been lied to.
545            let mut skipped: std::collections::BTreeMap<String, usize> = Default::default();
546            for name in k.keys() {
547                if !dit.lora_binds(name) {
548                    let fam = name
549                        .rsplit_once('.')
550                        .map(|(_, t)| {
551                            let head = name.split('.').next().unwrap_or("");
552                            format!("{head}…{t}")
553                        })
554                        .unwrap_or_else(|| name.to_string());
555                    *skipped.entry(fam).or_default() += 1;
556                }
557            }
558            let tail = if skipped.is_empty() {
559                String::new()
560            } else {
561                let parts: Vec<String> = skipped.iter().map(|(k, v)| format!("{k} ×{v}")).collect();
562                format!("; not applied: {}", parts.join(", "))
563            };
564            tracing::info!(
565                "lora: rank {}, {} branches, {} bound at strength {}{}",
566                k.rank(),
567                k.len(),
568                bound,
569                p.lora_strength,
570                tail
571            );
572            println!(
573                "lora: rank {}, {}/{} branches bound{}",
574                k.rank(),
575                bound,
576                k.len(),
577                tail
578            );
579        }
580        let kf: Vec<(usize, usize)> = keyframes
581            .iter()
582            .map(|&(_, idx)| (idx, frames_total))
583            .collect();
584        let layout = if kf.is_empty() {
585            Layout::t2va(ids.len(), latent_t, lat_h, lat_w, audio_t)
586        } else {
587            Layout::fl2va(ids.len(), latent_t, lat_h, lat_w, audio_t, &kf, &tags)
588        };
589        let text = dit.refine_text(&states, ids.len());
590        let mut v = gauss(dit.latents_dim * latent_t * lat_h * lat_w, p.seed);
591        let mut a = gauss(dit.audio_dim * 2 * audio_t, p.seed ^ 0x9E37_79B9_7F4A_7C15);
592        let sg = sigmas(p.steps, dit.shift_video);
593        let mut return_streaming: Option<(Vec<f32>, Vec<f32>)> = None;
594        // `CMF_ANIM_PROF=1`: the per-step rms of both streams and of
595        // their velocities. A run that is not denoising shows it here
596        // long before anything is written out.
597        let prof = std::env::var_os("CMF_ANIM_PROF").is_some();
598        let rms = |x: &[f32]| {
599            (x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64).sqrt()
600        };
601        if prof {
602            eprintln!(
603                "  text {} tok, refined rms {:.4}, sigmas {:?}",
604                ids.len(),
605                rms(&text),
606                sg.iter()
607                    .map(|v| (v * 1e4).round() / 1e4)
608                    .collect::<Vec<_>>()
609            );
610        }
611        // ── chunk-causal rollout ──
612        //
613        // Each chunk is denoised while seeing only the text, `sink` chunks
614        // from the start and a sliding `window` of recent ones, and it is
615        // conditioned on their finished frames at timestep 0 — the
616        // protocol a streaming adapter is trained for. The reference keeps
617        // that context in a KV cache; not packing the rows a chunk may not
618        // see produces the same attention pattern, and the cache is then
619        // an optimization of this rather than a prerequisite.
620        if p.stream_chunk > 0 && cond.is_empty() {
621            let hw = lat_h * lat_w / 4 * 4; // frame element count is c·h·w below
622            let _ = hw;
623            let vc = dit.latents_dim;
624            let ac = dit.audio_dim;
625            let vframe = lat_h * lat_w; // per channel, per latent frame
626            let mut chunks: Vec<std::ops::Range<usize>> = (0..latent_t)
627                .step_by(p.stream_chunk)
628                .map(|s| s..(s + p.stream_chunk).min(latent_t))
629                .collect();
630            // A tail shorter than half a chunk joins the one before it. A
631            // two-frame chunk with four frames of context is the one shape
632            // that reliably wandered off — a different street in the last
633            // half second — and it is an artefact of the arithmetic, not
634            // something anyone asked for.
635            if chunks.len() > 1 {
636                let tail = chunks[chunks.len() - 1].clone();
637                if tail.len() < p.stream_chunk.div_ceil(2) {
638                    chunks.pop();
639                    let last = chunks.len() - 1;
640                    chunks[last].end = tail.end;
641                }
642            }
643            // The audio grid runs at its own rate; split it in the same
644            // proportion so a chunk's sound is the sound of its frames.
645            let a_bound = |k: usize| (k * audio_t).div_ceil(latent_t.max(1));
646            let mut v_out = vec![0f32; v.len()];
647            let mut a_out = vec![0f32; a.len()];
648            let gather_v = |src: &[f32], idx: &[usize]| -> Vec<f32> {
649                let mut out = vec![0f32; vc * idx.len() * vframe];
650                for ci in 0..vc {
651                    for (n, &k) in idx.iter().enumerate() {
652                        let s = (ci * latent_t + k) * vframe;
653                        let d = (ci * idx.len() + n) * vframe;
654                        out[d..d + vframe].copy_from_slice(&src[s..s + vframe]);
655                    }
656                }
657                out
658            };
659            let gather_a = |src: &[f32], idx: &[usize]| -> Vec<f32> {
660                let mut out = vec![0f32; ac * 2 * idx.len()];
661                for ci in 0..ac {
662                    for ch in 0..2 {
663                        for (n, &i) in idx.iter().enumerate() {
664                            out[(ci * 2 + ch) * idx.len() + n] = src[(ci * 2 + ch) * audio_t + i];
665                        }
666                    }
667                }
668                out
669            };
670            for (ci_, cur) in chunks.iter().enumerate() {
671                // The sink and the window are counted in LATENT FRAMES, the
672                // unit the reference's KV cache holds: two frames pinned at
673                // the start and two trailing the current chunk, not two whole
674                // chunks of each. Counting them in chunks made a chunk attend
675                // to four chunks' worth of rows — which is both slower than
676                // the bidirectional path it replaces and not the pattern the
677                // adapter trained. `CMF_STREAM_UNIT=chunks` keeps the old
678                // reading for comparison.
679                let by_chunk = std::env::var("CMF_STREAM_UNIT").as_deref() == Ok("chunks");
680                let ctx_v: Vec<usize> = if by_chunk {
681                    let mut vis: Vec<usize> = (0..p.stream_sink.min(ci_)).collect();
682                    for j in ci_.saturating_sub(p.stream_window)..ci_ {
683                        if !vis.contains(&j) {
684                            vis.push(j);
685                        }
686                    }
687                    vis.sort_unstable();
688                    vis.iter().flat_map(|&j| chunks[j].clone()).collect()
689                } else {
690                    let done = cur.start; // every frame finished so far
691                    let mut f: Vec<usize> = (0..p.stream_sink.min(done)).collect();
692                    for k in done.saturating_sub(p.stream_window)..done {
693                        if !f.contains(&k) {
694                            f.push(k);
695                        }
696                    }
697                    f.sort_unstable();
698                    f
699                };
700                let cur_v: Vec<usize> = cur.clone().collect();
701                // Audio rows follow the video frames they belong to.
702                let mut ctx_a: Vec<usize> = Vec::new();
703                for &k in &ctx_v {
704                    for i in a_bound(k)..a_bound(k + 1) {
705                        if !ctx_a.contains(&i) {
706                            ctx_a.push(i);
707                        }
708                    }
709                }
710                ctx_a.sort_unstable();
711                let cur_a: Vec<usize> = (a_bound(cur.start)..a_bound(cur.end)).collect();
712                let clay = Layout::streaming(
713                    ids.len(),
714                    &tags,
715                    lat_h,
716                    lat_w,
717                    &ctx_v,
718                    &cur_v,
719                    &ctx_a,
720                    &cur_a,
721                );
722                let ctx_vx = gather_v(&v_out, &ctx_v);
723                let ctx_ax = gather_a(&a_out, &ctx_a);
724                let mut xv = gather_v(&v, &cur_v);
725                let mut xa = gather_a(&a, &cur_a);
726                for i in 0..p.steps {
727                    let (sv, sv_n) = (sg[i], sg[i + 1]);
728                    // [context frames | current frames], the order the
729                    // layout lays its rows out in.
730                    let mut vin = vec![0f32; vc * (ctx_v.len() + cur_v.len()) * vframe];
731                    let mut ain = vec![0f32; ac * 2 * (ctx_a.len() + cur_a.len())];
732                    let (nc, nk) = (ctx_v.len(), cur_v.len());
733                    for c in 0..vc {
734                        let d = c * (nc + nk) * vframe;
735                        vin[d..d + nc * vframe]
736                            .copy_from_slice(&ctx_vx[c * nc * vframe..(c + 1) * nc * vframe]);
737                        vin[d + nc * vframe..d + (nc + nk) * vframe]
738                            .copy_from_slice(&xv[c * nk * vframe..(c + 1) * nk * vframe]);
739                    }
740                    let (mc, mk) = (ctx_a.len(), cur_a.len());
741                    for c in 0..ac {
742                        for ch in 0..2 {
743                            let d = (c * 2 + ch) * (mc + mk);
744                            ain[d..d + mc]
745                                .copy_from_slice(&ctx_ax[(c * 2 + ch) * mc..(c * 2 + ch + 1) * mc]);
746                            ain[d + mc..d + mc + mk]
747                                .copy_from_slice(&xa[(c * 2 + ch) * mk..(c * 2 + ch + 1) * mk]);
748                        }
749                    }
750                    let (dv, da) = dit.forward(&clay, &text, &vin, &ain, sv, &[]);
751                    let step_v = (sv_n - sv) as f32;
752                    for (x, &d) in xv.iter_mut().zip(&dv) {
753                        *x += step_v * d;
754                    }
755                    let step_a = if p.stock_sampler {
756                        step_v
757                    } else {
758                        (time_shift_sigma(sv_n, dit.shift_video, dit.shift_audio)
759                            - time_shift_sigma(sv.max(1e-6), dit.shift_video, dit.shift_audio))
760                            as f32
761                    };
762                    for (x, &d) in xa.iter_mut().zip(&da) {
763                        *x += step_a * d;
764                    }
765                }
766                for c in 0..vc {
767                    for (n, &k) in cur_v.iter().enumerate() {
768                        let d = (c * latent_t + k) * vframe;
769                        let s = (c * cur_v.len() + n) * vframe;
770                        v_out[d..d + vframe].copy_from_slice(&xv[s..s + vframe]);
771                    }
772                }
773                for c in 0..ac {
774                    for ch in 0..2 {
775                        for (n, &i) in cur_a.iter().enumerate() {
776                            a_out[(c * 2 + ch) * audio_t + i] = xa[(c * 2 + ch) * cur_a.len() + n];
777                        }
778                    }
779                }
780                progress("stream", ci_ + 1, chunks.len());
781            }
782            if let Some(rep) = dit.lora_report() {
783                eprint!("{rep}");
784            }
785            return_streaming = Some((v_out, a_out));
786        }
787
788        for i in 0..p.steps {
789            if return_streaming.is_some() {
790                break;
791            }
792            let (sv, sv_n) = (sg[i], sg[i + 1]);
793            let (dv, da) = dit.forward(&layout, &text, &v, &a, sv, &cond);
794            // `CMF_ANIM_DUMP=<prefix>`: this step's two velocities as raw
795            // f32 (`<prefix>.v<i>.f32`, `<prefix>.a<i>.f32`), so one forward
796            // can be compared between two paths before the sampler
797            // amplifies their difference.
798            if let Ok(pre) = std::env::var("CMF_ANIM_DUMP") {
799                let raw = |x: &[f32]| x.iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>();
800                let _ = std::fs::write(format!("{pre}.v{i}.f32"), raw(&dv));
801                let _ = std::fs::write(format!("{pre}.a{i}.f32"), raw(&da));
802            }
803            let step_v = (sv_n - sv) as f32;
804            for (x, &d) in v.iter_mut().zip(&dv) {
805                *x += step_v * d;
806            }
807            let step_a = if p.stock_sampler {
808                step_v
809            } else {
810                (time_shift_sigma(sv_n, dit.shift_video, dit.shift_audio)
811                    - time_shift_sigma(sv.max(1e-6), dit.shift_video, dit.shift_audio))
812                    as f32
813            };
814            for (x, &d) in a.iter_mut().zip(&da) {
815                *x += step_a * d;
816            }
817            if prof {
818                eprintln!(
819                    "  step {i}: sv {sv:.4}->{sv_n:.4} v_vel {:.4} a_vel {:.4} | video {:.4} audio {:.4}",
820                    rms(&dv),
821                    rms(&da),
822                    rms(&v),
823                    rms(&a)
824                );
825            }
826            progress("denoise", i + 1, p.steps);
827        }
828        if let Some(rep) = dit.lora_report() {
829            eprint!("{rep}");
830        }
831        match return_streaming {
832            Some(pair) => pair,
833            None => (v, a),
834        }
835    };
836
837    lap(&mut marks, "denoise");
838    // The DiT is done: its weights leave the card before the decoders load.
839    crate::gpu::stage_done(&model);
840
841    // ── the learned latent resize, when one was handed in ──
842    let (mut out_h, mut out_w) = (p.height, p.width);
843    let (mut lat_h, mut lat_w) = (lat_h, lat_w);
844    if let Some(path) = p.upscale.as_deref() {
845        progress("upscale", 0, 1);
846        let t = std::time::Instant::now();
847        let ups = crate::mmh3ups::LatentUpscaler::load(std::path::Path::new(path))?;
848        let z = crate::mmh3ups::Vol {
849            c: video.len() / (latent_t * lat_h * lat_w),
850            t: latent_t,
851            h: lat_h,
852            w: lat_w,
853            data: video,
854        };
855        // Snap to the VAE's own 16-pixel grid: the net takes any target,
856        // the decoder does not.
857        let f = p.upscale_by.max(1.0);
858        let nh = ((lat_h as f32 * f).round() as usize).max(lat_h);
859        let nw = ((lat_w as f32 * f).round() as usize).max(lat_w);
860        let big = ups.upscale(&z, nh, nw, None);
861        tracing::info!(
862            "latent upscale {}x{} -> {}x{} in {:.1}s",
863            lat_h,
864            lat_w,
865            nh,
866            nw,
867            t.elapsed().as_secs_f64()
868        );
869        out_h = out_h * nh / lat_h;
870        out_w = out_w * nw / lat_w;
871        lat_h = nh;
872        lat_w = nw;
873        video = big.data;
874        progress("upscale", 1, 1);
875        lap(&mut marks, "upscale");
876    }
877
878    // ── decode ──
879    progress("video vae", 0, 1);
880    let (rgb, out_frames) = {
881        let vae = VideoVae::from_cmf(&model)?;
882        vae.decode(&video, latent_t, lat_h, lat_w)
883    };
884    progress("video vae", 1, 1);
885    lap(&mut marks, "video vae");
886    crate::gpu::stage_done(&model);
887    progress("audio vae", 0, 1);
888    let (wave, samples, sr) = {
889        let vae = AudioVae::from_cmf(&model)?;
890        let c = audio.len() / (2 * audio_t);
891        let (w, n) = vae.decode(&audio, c, audio_t);
892        (w, n, vae.sample_rate)
893    };
894    progress("audio vae", 1, 1);
895    lap(&mut marks, "audio vae");
896    tracing::info!(
897        "stages: {}",
898        marks
899            .iter()
900            .map(|(n, v)| format!("{n} {v:.1}s"))
901            .collect::<Vec<_>>()
902            .join(" · ")
903    );
904    // Where a GEMM's wall time goes on unified memory: copies or kernel.
905    // The answer decides whether fusing blocks or tuning the kernel is
906    // the optimization worth doing.
907    #[cfg(target_os = "macos")]
908    if std::env::var("CMF_METAL_MMPROF").is_ok() {
909        use std::sync::atomic::Ordering::Relaxed;
910        let n = crate::gpu_metal::MM_N.load(Relaxed);
911        eprintln!(
912            "  q4tp mm x{n}: upload {:.1}s · submit+wait {:.1}s · readback {:.1}s",
913            crate::gpu_metal::MM_UP.load(Relaxed) as f64 / 1e6,
914            crate::gpu_metal::MM_GPU.load(Relaxed) as f64 / 1e6,
915            crate::gpu_metal::MM_DN.load(Relaxed) as f64 / 1e6,
916        );
917    }
918
919    // The VAE emits latent_t·4 frames; the request snapped to 17k+5,
920    // which is one fewer than a multiple of four plus the leading key
921    // frame, so trim rather than pad.
922    let keep = out_frames.min(frames_total);
923    Ok(Anim {
924        rgb: trim_frames(&rgb, out_frames, keep, out_h, out_w),
925        frames: keep,
926        height: out_h,
927        width: out_w,
928        audio: wave,
929        samples,
930        sample_rate: sr,
931    })
932}
933
934fn trim_frames(rgb: &[f32], have: usize, keep: usize, h: usize, w: usize) -> Vec<f32> {
935    if keep == have {
936        return rgb.to_vec();
937    }
938    let mut out = vec![0f32; 3 * keep * h * w];
939    for c in 0..3 {
940        let s = c * have * h * w;
941        let d = c * keep * h * w;
942        out[d..d + keep * h * w].copy_from_slice(&rgb[s..s + keep * h * w]);
943    }
944    out
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950
951    #[test]
952    fn the_four_step_schedule_is_the_references() {
953        let s = sigmas(4, 12.0);
954        let want = [1.0, 0.972_973, 0.923_077, 0.8, 0.0];
955        assert_eq!(s.len(), want.len());
956        for (g, w) in s.iter().zip(&want) {
957            assert!((g - w).abs() < 1e-6, "{s:?}");
958        }
959    }
960
961    #[test]
962    fn a_stretch_keeps_the_corners_and_a_crop_takes_the_middle() {
963        // A 4x2 ramp: value rises left to right, so the corners name
964        // themselves.
965        let (h, w) = (2usize, 4usize);
966        let mut rgb = vec![0f32; 3 * h * w];
967        for c in 0..3 {
968            for y in 0..h {
969                for x in 0..w {
970                    rgb[(c * h + y) * w + x] = x as f32 / (w - 1) as f32;
971                }
972            }
973        }
974        // Stretch to a square: the far edges survive.
975        let s = fit_to_canvas(&rgb, h, w, 4, 4, false);
976        assert!((s[0] - 0.0).abs() < 1e-6, "left edge");
977        assert!((s[3] - 1.0).abs() < 1e-6, "right edge");
978        // Cover-crop to a square takes the centre 2x2, so the extremes
979        // are gone and the span is narrower.
980        let c = fit_to_canvas(&rgb, h, w, 4, 4, true);
981        let (lo, hi) = c[..16]
982            .iter()
983            .fold((f32::MAX, f32::MIN), |(a, b), &v| (a.min(v), b.max(v)));
984        assert!(lo > 0.05, "crop kept the left edge: {lo}");
985        assert!(hi < 0.95, "crop kept the right edge: {hi}");
986    }
987
988    #[test]
989    fn frame_counts_snap_to_the_models_grid() {
990        // The grid is 5 + 17k: 5, 22, 39, 56, … 124.
991        assert_eq!(align_frames(1), 5);
992        assert_eq!(align_frames(39), 39);
993        assert_eq!(align_frames(41), 56);
994        assert_eq!(align_frames(124), 124);
995        assert_eq!(video_latent_t(124), 37);
996        assert_eq!(video_latent_t(39), 12);
997        let (f, lt, at) = temporal_shape(124);
998        assert_eq!((f, lt, at), (124, 37, 207));
999    }
1000}