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.
252fn 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    let Some(idx) = model.tensors.iter().position(|t| {
261        t.name.starts_with("dit.") && t.name.ends_with("attn.qkv_proj.weight")
262    }) else {
263        tracing::info!("mmh3 GPU parity probe: no qkv weight — host path");
264        return Ok(false);
265    };
266    let entry = &model.tensors[idx];
267    let (rows, cols) = (entry.shape[0], entry.shape[1]);
268    let b = 64usize;
269    let mut xs = vec![0f32; b * cols];
270    for (i, v) in xs.iter_mut().enumerate() {
271        let base = ((i * 37 + 11) % 1009) as f32 / 1009.0 - 0.5;
272        *v = base * if i % 7 == 0 { 2000.0 } else { 2.0 };
273    }
274    let mut gpu = vec![0f32; b * rows];
275    if std::env::var("CMF_GPU_DEBUG").is_ok() {
276        eprintln!(
277            "mmh3 probe env: CMF_GPU={:?} enabled={} avail={}",
278            std::env::var("CMF_GPU").ok(),
279            crate::gpu::enabled(),
280            crate::gpu::backend_available(),
281        );
282    }
283    let qt = crate::qtensor::QTensor::from_model(&model, &entry.name.clone())?;
284    if !qt.device_matmat(&xs, b, &mut gpu) {
285        tracing::info!(
286            "mmh3 GPU parity probe: {:?} device GEMM refused ({rows}x{cols}) — host path",
287            entry.dtype
288        );
289        if std::env::var("CMF_GPU_DEBUG").is_ok() {
290            eprintln!("mmh3 probe: device GEMM refused {rows}x{cols} for {:?}", entry.dtype);
291        }
292        return Ok(false);
293    }
294    let host = {
295        let name = entry.name.clone();
296        let proj = crate::dit::Proj::from_model(&model, &name)?;
297        let mut out = vec![0f32; b * rows];
298        crate::gpu::cpu_scope(|| proj.matmat(&xs, b, &mut out, None));
299        out
300    };
301    let mut num = 0f64;
302    let mut den = 0f64;
303    for (g, h) in gpu.iter().zip(&host) {
304        num += ((g - h) as f64).powi(2);
305        den += (*h as f64).powi(2);
306    }
307    let rel = (num / den.max(1e-30)).sqrt();
308    let ok = rel < 1e-2;
309    tracing::info!(
310        "mmh3 GPU parity probe: rel rms {rel:.2e} → {}",
311        if ok { "device" } else { "host" }
312    );
313    if std::env::var("CMF_GPU_DEBUG").is_ok() {
314        eprintln!("mmh3 GPU parity probe: rel rms {rel:.2e}");
315    }
316    Ok(ok)
317}
318
319fn generate_inner(
320    path: &Path,
321    prompt: &str,
322    p: &AnimParams,
323    progress: &mut dyn FnMut(&str, usize, usize),
324) -> Result<Anim, String> {
325    let model = Arc::new(
326        cortiq_core::CmfModel::open(path).map_err(|e| format!("{}: {e}", path.display()))?,
327    );
328    let (frames_total, latent_t, audio_t) = temporal_shape(p.frames);
329    let (lat_h, lat_w) = (p.height / 16, p.width / 16);
330
331    // ── prompt ──
332    // The H3 presentation is raw text: no chat template, no BOS, no
333    // special tokens at all.
334    // Stage clock. Half of a render is not the DiT — at 8 steps the
335    // denoiser is 63 s of 116 — and until this line existed there was
336    // no way to see which half anything went to.
337    let t_stage = std::time::Instant::now();
338    let mut marks: Vec<(&str, f32)> = Vec::new();
339    let lap = |marks: &mut Vec<(&'static str, f32)>, name: &'static str| {
340        let prev: f32 = marks.iter().map(|(_, v)| v).sum();
341        marks.push((name, t_stage.elapsed().as_secs_f32() - prev));
342    };
343    let vocab = model
344        .vocab
345        .as_deref()
346        .ok_or("packaged .cmf has no embedded tokenizer")?;
347    let tok = Tokenizer::from_bytes(vocab).map_err(|e| format!("tokenizer: {e}"))?;
348    // fl2va: every keyframe is presented as "<Picture i>: " and a
349    // vision block BEFORE the prompt, and separately conditions the DiT
350    // as a latent. Both halves come from the same picture.
351    let mut keyframes: Vec<(&(Vec<f32>, usize, usize), usize)> = p
352        .first_frame
353        .iter()
354        .map(|f| (f, 0usize))
355        .chain(p.mid_frames.iter().map(|(f, i)| (f, (*i).min(frames_total - 1))))
356        .chain(p.last_frame.iter().map(|f| (f, frames_total - 1)))
357        .collect();
358    // Condition rows are placed by time coordinate, so they have to arrive
359    // in time order and only once per frame — two references pinned to the
360    // same pixel index would be two rows claiming one moment.
361    keyframes.sort_by_key(|(_, i)| *i);
362    keyframes.dedup_by_key(|(_, i)| *i);
363    let mut ids: Vec<u32> = Vec::new();
364    let mut spans: Vec<ImageSpan> = Vec::new();
365    let mut embeds: Vec<Vec<f32>> = Vec::new();
366    let mut deepstack: Vec<Vec<f32>> = Vec::new();
367    let mut cond: Vec<Vec<f32>> = Vec::new();
368    let mut tags: Vec<u8> = Vec::new();
369
370    if !keyframes.is_empty() {
371        let tower = VisionTower::from_cmf(&model)?;
372        // An activation harvest (CMF_TE_ONLY) never denoises, and the
373        // VAE latent is the DiT's food alone — the frame's 3-D conv
374        // encode is 99.5 s of a 102.5 s M4 run. Skip it.
375        let te_only = std::env::var("CMF_TE_ONLY").as_deref() == Ok("1");
376        let venc = if te_only {
377            None
378        } else {
379            Some(VideoVaeEncoder::from_cmf(&model)?)
380        };
381        for (i, (frame, _)) in keyframes.iter().enumerate() {
382            let (src, sh, sw) = *frame;
383            // The picture the DiT sees is on the generation canvas; the
384            // one Qwen sees keeps its own resolution policy.
385            let fitted = fit_to_canvas(src, *sh, *sw, p.height, p.width, i > 0);
386            if let Some(venc) = &venc {
387                let (z, _, _) = venc.encode_frame(
388                    &fitted.iter().map(|&v| v * 2.0 - 1.0).collect::<Vec<_>>(),
389                    p.height,
390                    p.width,
391                );
392                cond.push(z);
393            }
394
395            for t in tok.encode(&format!("<Picture {}>: ", i + 1)) {
396                ids.push(t);
397                tags.push(1);
398            }
399            let (patches, gh, gw) = qwen3vis::preprocess(
400                &fitted,
401                p.height,
402                p.width,
403                tower.patch_size,
404                tower.temporal_patch,
405                tower.merge,
406            );
407            let (merged, deep) = tower.forward(&patches, gh, gw);
408            let n_img = merged.len() / tower.out_hidden;
409            // The whole block carries the VIDEO tag, the flanking
410            // markers included.
411            ids.push(VISION_START);
412            tags.push(0);
413            let start = ids.len();
414            for _ in 0..n_img {
415                ids.push(VISION_START); // a placeholder the embed replaces
416                tags.push(0);
417            }
418            ids.push(VISION_END);
419            tags.push(0);
420            spans.push(ImageSpan {
421                start,
422                len: n_img,
423                merged_h: gh / tower.merge,
424                merged_w: gw / tower.merge,
425            });
426            embeds.push(merged);
427            if deepstack.is_empty() {
428                deepstack = deep;
429            } else {
430                for (a, b) in deepstack.iter_mut().zip(deep) {
431                    a.extend_from_slice(&b);
432                }
433            }
434        }
435    }
436    for t in tok.encode(prompt) {
437        ids.push(t);
438        tags.push(1);
439    }
440    if ids.is_empty() {
441        ids.push(151643); // the pad id, as the reference does for ""
442        tags.push(1);
443    }
444    ids.truncate(p.max_tokens);
445    tags.truncate(ids.len());
446    lap(&mut marks, "prepare");
447    // The prompt encoder is a one-shot pass over 12 GB of weights; on a
448    // machine the file does not fit it streams from disk and its GEMMs
449    // run over any contention budget for reasons that are not contention.
450    // The kill stays disarmed until the encode is done (users on 24 GB
451    // Macs had to patch it out to keep the denoise loop on the GPU).
452    crate::gpu::mm_kill_arm(false);
453    progress("encode", 0, 1);
454    let states = {
455        let enc = Qwen3Encoder::from_cmf(&model)?;
456        enc.encode_with_images(&ids, &spans, &embeds, &deepstack)
457    };
458    // `CMF_TE_DUMP=<path>`: the conditioning as `[u64 n][u64 width]`
459    // then f32 rows. A stand-in encoder is only as good as the stream
460    // it hands the DiT, and that is measurable against the teacher's
461    // dump on the same prompt WITHOUT rendering a frame.
462    if let Ok(p) = std::env::var("CMF_TE_DUMP") {
463        let w = states.len() / ids.len().max(1);
464        let mut b = Vec::with_capacity(16 + states.len() * 4);
465        b.extend_from_slice(&(ids.len() as u64).to_le_bytes());
466        b.extend_from_slice(&(w as u64).to_le_bytes());
467        for v in &states {
468            b.extend_from_slice(&v.to_le_bytes());
469        }
470        std::fs::write(&p, &b).map_err(|e| format!("CMF_TE_DUMP {p}: {e}"))?;
471        eprintln!("te dump: {} tokens x {w} -> {p}", ids.len());
472    }
473    progress("encode", 1, 1);
474    lap(&mut marks, "text encode");
475    crate::gpu::mm_kill_arm(true);
476    // `CMF_TE_ONLY=1`: stop after the dump — an activation-harvest run
477    // (the ClipProj refit) wants hundreds of encodes and zero renders.
478    if std::env::var("CMF_TE_ONLY").as_deref() == Ok("1") {
479        return Err("CMF_TE_ONLY: encode dumped, render skipped".into());
480    }
481    // The prompt encoder and vision tower ran their once-per-generation
482    // pass; release their page cache so the denoise loop's DiT does not
483    // fight 12+ GB of dead weights for RAM. On a 24 GB Mac with the
484    // 25.7 GB full-encoder fl2va file this is the difference between
485    // denoise steps at DiT speed and 320 s/step of SSD thrash.
486    {
487        let dropped = model.advise_done(|n| n.starts_with("model.") || n.starts_with("vis."));
488        if dropped > 0 {
489            tracing::info!(
490                "encoder pages released after prompt encode: {} MB",
491                dropped / (1024 * 1024)
492            );
493        }
494    }
495
496    // ── denoise ──
497    let (mut video, audio) = {
498        // The adapter is read here, after the encoder's pages are gone:
499        // a rank-32 file for this DiT is 130 MB of f32 once expanded and
500        // there is no reason for it to share a peak with 12 GB of text
501        // tower on a 24 GB machine.
502        let bank = match p.lora.as_deref() {
503            None => None,
504            Some(path) => {
505                let k = crate::ltxlora::LoraBank::load(std::path::Path::new(path), p.lora_strength)?;
506                Some(k)
507            }
508        };
509        let dit = MiniMaxH3::from_cmf_lora(&model, bank.as_ref())?;
510        if let Some(k) = &bank {
511            let bound = dit.lora_bound();
512            // Say what did NOT land. An adaLN branch on a curve-form
513            // pack is the one real gap, and a user who sees "applied"
514            // while half the adapter sat out has been lied to.
515            let mut skipped: std::collections::BTreeMap<String, usize> = Default::default();
516            for name in k.keys() {
517                if !dit.lora_binds(name) {
518                    let fam = name
519                        .rsplit_once('.')
520                        .map(|(_, t)| {
521                            let head = name.split('.').next().unwrap_or("");
522                            format!("{head}…{t}")
523                        })
524                        .unwrap_or_else(|| name.to_string());
525                    *skipped.entry(fam).or_default() += 1;
526                }
527            }
528            let tail = if skipped.is_empty() {
529                String::new()
530            } else {
531                let parts: Vec<String> =
532                    skipped.iter().map(|(k, v)| format!("{k} ×{v}")).collect();
533                format!("; not applied: {}", parts.join(", "))
534            };
535            tracing::info!(
536                "lora: rank {}, {} branches, {} bound at strength {}{}",
537                k.rank(),
538                k.len(),
539                bound,
540                p.lora_strength,
541                tail
542            );
543            println!(
544                "lora: rank {}, {}/{} branches bound{}",
545                k.rank(),
546                bound,
547                k.len(),
548                tail
549            );
550        }
551        let kf: Vec<(usize, usize)> = keyframes
552            .iter()
553            .map(|&(_, idx)| (idx, frames_total))
554            .collect();
555        let layout = if kf.is_empty() {
556            Layout::t2va(ids.len(), latent_t, lat_h, lat_w, audio_t)
557        } else {
558            Layout::fl2va(ids.len(), latent_t, lat_h, lat_w, audio_t, &kf, &tags)
559        };
560        let text = dit.refine_text(&states, ids.len());
561        let mut v = gauss(dit.latents_dim * latent_t * lat_h * lat_w, p.seed);
562        let mut a = gauss(dit.audio_dim * 2 * audio_t, p.seed ^ 0x9E37_79B9_7F4A_7C15);
563        let sg = sigmas(p.steps, dit.shift_video);
564        let mut return_streaming: Option<(Vec<f32>, Vec<f32>)> = None;
565        // `CMF_ANIM_PROF=1`: the per-step rms of both streams and of
566        // their velocities. A run that is not denoising shows it here
567        // long before anything is written out.
568        let prof = std::env::var_os("CMF_ANIM_PROF").is_some();
569        let rms = |x: &[f32]| {
570            (x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64).sqrt()
571        };
572        if prof {
573            eprintln!(
574                "  text {} tok, refined rms {:.4}, sigmas {:?}",
575                ids.len(),
576                rms(&text),
577                sg.iter()
578                    .map(|v| (v * 1e4).round() / 1e4)
579                    .collect::<Vec<_>>()
580            );
581        }
582        // ── chunk-causal rollout ──
583        //
584        // Each chunk is denoised while seeing only the text, `sink` chunks
585        // from the start and a sliding `window` of recent ones, and it is
586        // conditioned on their finished frames at timestep 0 — the
587        // protocol a streaming adapter is trained for. The reference keeps
588        // that context in a KV cache; not packing the rows a chunk may not
589        // see produces the same attention pattern, and the cache is then
590        // an optimization of this rather than a prerequisite.
591        if p.stream_chunk > 0 && cond.is_empty() {
592            let hw = lat_h * lat_w / 4 * 4; // frame element count is c·h·w below
593            let _ = hw;
594            let vc = dit.latents_dim;
595            let ac = dit.audio_dim;
596            let vframe = lat_h * lat_w; // per channel, per latent frame
597            let mut chunks: Vec<std::ops::Range<usize>> = (0..latent_t)
598                .step_by(p.stream_chunk)
599                .map(|s| s..(s + p.stream_chunk).min(latent_t))
600                .collect();
601            // A tail shorter than half a chunk joins the one before it. A
602            // two-frame chunk with four frames of context is the one shape
603            // that reliably wandered off — a different street in the last
604            // half second — and it is an artefact of the arithmetic, not
605            // something anyone asked for.
606            if chunks.len() > 1 {
607                let tail = chunks[chunks.len() - 1].clone();
608                if tail.len() < p.stream_chunk.div_ceil(2) {
609                    chunks.pop();
610                    let last = chunks.len() - 1;
611                    chunks[last].end = tail.end;
612                }
613            }
614            // The audio grid runs at its own rate; split it in the same
615            // proportion so a chunk's sound is the sound of its frames.
616            let a_bound = |k: usize| (k * audio_t).div_ceil(latent_t.max(1));
617            let mut v_out = vec![0f32; v.len()];
618            let mut a_out = vec![0f32; a.len()];
619            let gather_v = |src: &[f32], idx: &[usize]| -> Vec<f32> {
620                let mut out = vec![0f32; vc * idx.len() * vframe];
621                for ci in 0..vc {
622                    for (n, &k) in idx.iter().enumerate() {
623                        let s = (ci * latent_t + k) * vframe;
624                        let d = (ci * idx.len() + n) * vframe;
625                        out[d..d + vframe].copy_from_slice(&src[s..s + vframe]);
626                    }
627                }
628                out
629            };
630            let gather_a = |src: &[f32], idx: &[usize]| -> Vec<f32> {
631                let mut out = vec![0f32; ac * 2 * idx.len()];
632                for ci in 0..ac {
633                    for ch in 0..2 {
634                        for (n, &i) in idx.iter().enumerate() {
635                            out[(ci * 2 + ch) * idx.len() + n] = src[(ci * 2 + ch) * audio_t + i];
636                        }
637                    }
638                }
639                out
640            };
641            for (ci_, cur) in chunks.iter().enumerate() {
642                // The sink and the window are counted in LATENT FRAMES, the
643                // unit the reference's KV cache holds: two frames pinned at
644                // the start and two trailing the current chunk, not two whole
645                // chunks of each. Counting them in chunks made a chunk attend
646                // to four chunks' worth of rows — which is both slower than
647                // the bidirectional path it replaces and not the pattern the
648                // adapter trained. `CMF_STREAM_UNIT=chunks` keeps the old
649                // reading for comparison.
650                let by_chunk = std::env::var("CMF_STREAM_UNIT").as_deref() == Ok("chunks");
651                let ctx_v: Vec<usize> = if by_chunk {
652                    let mut vis: Vec<usize> = (0..p.stream_sink.min(ci_)).collect();
653                    for j in ci_.saturating_sub(p.stream_window)..ci_ {
654                        if !vis.contains(&j) {
655                            vis.push(j);
656                        }
657                    }
658                    vis.sort_unstable();
659                    vis.iter().flat_map(|&j| chunks[j].clone()).collect()
660                } else {
661                    let done = cur.start; // every frame finished so far
662                    let mut f: Vec<usize> = (0..p.stream_sink.min(done)).collect();
663                    for k in done.saturating_sub(p.stream_window)..done {
664                        if !f.contains(&k) {
665                            f.push(k);
666                        }
667                    }
668                    f.sort_unstable();
669                    f
670                };
671                let cur_v: Vec<usize> = cur.clone().collect();
672                // Audio rows follow the video frames they belong to.
673                let mut ctx_a: Vec<usize> = Vec::new();
674                for &k in &ctx_v {
675                    for i in a_bound(k)..a_bound(k + 1) {
676                        if !ctx_a.contains(&i) {
677                            ctx_a.push(i);
678                        }
679                    }
680                }
681                ctx_a.sort_unstable();
682                let cur_a: Vec<usize> = (a_bound(cur.start)..a_bound(cur.end)).collect();
683                let clay = Layout::streaming(
684                    ids.len(),
685                    &tags,
686                    lat_h,
687                    lat_w,
688                    &ctx_v,
689                    &cur_v,
690                    &ctx_a,
691                    &cur_a,
692                );
693                let ctx_vx = gather_v(&v_out, &ctx_v);
694                let ctx_ax = gather_a(&a_out, &ctx_a);
695                let mut xv = gather_v(&v, &cur_v);
696                let mut xa = gather_a(&a, &cur_a);
697                for i in 0..p.steps {
698                    let (sv, sv_n) = (sg[i], sg[i + 1]);
699                    // [context frames | current frames], the order the
700                    // layout lays its rows out in.
701                    let mut vin = vec![0f32; vc * (ctx_v.len() + cur_v.len()) * vframe];
702                    let mut ain = vec![0f32; ac * 2 * (ctx_a.len() + cur_a.len())];
703                    let (nc, nk) = (ctx_v.len(), cur_v.len());
704                    for c in 0..vc {
705                        let d = c * (nc + nk) * vframe;
706                        vin[d..d + nc * vframe]
707                            .copy_from_slice(&ctx_vx[c * nc * vframe..(c + 1) * nc * vframe]);
708                        vin[d + nc * vframe..d + (nc + nk) * vframe]
709                            .copy_from_slice(&xv[c * nk * vframe..(c + 1) * nk * vframe]);
710                    }
711                    let (mc, mk) = (ctx_a.len(), cur_a.len());
712                    for c in 0..ac {
713                        for ch in 0..2 {
714                            let d = (c * 2 + ch) * (mc + mk);
715                            ain[d..d + mc]
716                                .copy_from_slice(&ctx_ax[(c * 2 + ch) * mc..(c * 2 + ch + 1) * mc]);
717                            ain[d + mc..d + mc + mk]
718                                .copy_from_slice(&xa[(c * 2 + ch) * mk..(c * 2 + ch + 1) * mk]);
719                        }
720                    }
721                    let (dv, da) = dit.forward(&clay, &text, &vin, &ain, sv, &[]);
722                    let step_v = (sv_n - sv) as f32;
723                    for (x, &d) in xv.iter_mut().zip(&dv) {
724                        *x += step_v * d;
725                    }
726                    let step_a = if p.stock_sampler {
727                        step_v
728                    } else {
729                        (time_shift_sigma(sv_n, dit.shift_video, dit.shift_audio)
730                            - time_shift_sigma(sv.max(1e-6), dit.shift_video, dit.shift_audio))
731                            as f32
732                    };
733                    for (x, &d) in xa.iter_mut().zip(&da) {
734                        *x += step_a * d;
735                    }
736                }
737                for c in 0..vc {
738                    for (n, &k) in cur_v.iter().enumerate() {
739                        let d = (c * latent_t + k) * vframe;
740                        let s = (c * cur_v.len() + n) * vframe;
741                        v_out[d..d + vframe].copy_from_slice(&xv[s..s + vframe]);
742                    }
743                }
744                for c in 0..ac {
745                    for ch in 0..2 {
746                        for (n, &i) in cur_a.iter().enumerate() {
747                            a_out[(c * 2 + ch) * audio_t + i] =
748                                xa[(c * 2 + ch) * cur_a.len() + n];
749                        }
750                    }
751                }
752                progress("stream", ci_ + 1, chunks.len());
753            }
754            if let Some(rep) = dit.lora_report() {
755                eprint!("{rep}");
756            }
757            return_streaming = Some((v_out, a_out));
758        }
759
760        for i in 0..p.steps {
761            if return_streaming.is_some() {
762                break;
763            }
764            let (sv, sv_n) = (sg[i], sg[i + 1]);
765            let (dv, da) = dit.forward(&layout, &text, &v, &a, sv, &cond);
766            let step_v = (sv_n - sv) as f32;
767            for (x, &d) in v.iter_mut().zip(&dv) {
768                *x += step_v * d;
769            }
770            let step_a = if p.stock_sampler {
771                step_v
772            } else {
773                (time_shift_sigma(sv_n, dit.shift_video, dit.shift_audio)
774                    - time_shift_sigma(sv.max(1e-6), dit.shift_video, dit.shift_audio))
775                    as f32
776            };
777            for (x, &d) in a.iter_mut().zip(&da) {
778                *x += step_a * d;
779            }
780            if prof {
781                eprintln!(
782                    "  step {i}: sv {sv:.4}->{sv_n:.4} v_vel {:.4} a_vel {:.4} | video {:.4} audio {:.4}",
783                    rms(&dv),
784                    rms(&da),
785                    rms(&v),
786                    rms(&a)
787                );
788            }
789            progress("denoise", i + 1, p.steps);
790        }
791        if let Some(rep) = dit.lora_report() {
792            eprint!("{rep}");
793        }
794        match return_streaming {
795            Some(pair) => pair,
796            None => (v, a),
797        }
798    };
799
800    lap(&mut marks, "denoise");
801
802    // ── the learned latent resize, when one was handed in ──
803    let (mut out_h, mut out_w) = (p.height, p.width);
804    let (mut lat_h, mut lat_w) = (lat_h, lat_w);
805    if let Some(path) = p.upscale.as_deref() {
806        progress("upscale", 0, 1);
807        let t = std::time::Instant::now();
808        let ups = crate::mmh3ups::LatentUpscaler::load(std::path::Path::new(path))?;
809        let z = crate::mmh3ups::Vol {
810            c: video.len() / (latent_t * lat_h * lat_w),
811            t: latent_t,
812            h: lat_h,
813            w: lat_w,
814            data: video,
815        };
816        // Snap to the VAE's own 16-pixel grid: the net takes any target,
817        // the decoder does not.
818        let f = p.upscale_by.max(1.0);
819        let nh = ((lat_h as f32 * f).round() as usize).max(lat_h);
820        let nw = ((lat_w as f32 * f).round() as usize).max(lat_w);
821        let big = ups.upscale(&z, nh, nw, None);
822        tracing::info!(
823            "latent upscale {}x{} -> {}x{} in {:.1}s",
824            lat_h,
825            lat_w,
826            nh,
827            nw,
828            t.elapsed().as_secs_f64()
829        );
830        out_h = out_h * nh / lat_h;
831        out_w = out_w * nw / lat_w;
832        lat_h = nh;
833        lat_w = nw;
834        video = big.data;
835        progress("upscale", 1, 1);
836        lap(&mut marks, "upscale");
837    }
838
839    // ── decode ──
840    progress("video vae", 0, 1);
841    let (rgb, out_frames) = {
842        let vae = VideoVae::from_cmf(&model)?;
843        vae.decode(&video, latent_t, lat_h, lat_w)
844    };
845    progress("video vae", 1, 1);
846    lap(&mut marks, "video vae");
847    progress("audio vae", 0, 1);
848    let (wave, samples, sr) = {
849        let vae = AudioVae::from_cmf(&model)?;
850        let c = audio.len() / (2 * audio_t);
851        let (w, n) = vae.decode(&audio, c, audio_t);
852        (w, n, vae.sample_rate)
853    };
854    progress("audio vae", 1, 1);
855    lap(&mut marks, "audio vae");
856    tracing::info!(
857        "stages: {}",
858        marks
859            .iter()
860            .map(|(n, v)| format!("{n} {v:.1}s"))
861            .collect::<Vec<_>>()
862            .join(" · ")
863    );
864    // Where a GEMM's wall time goes on unified memory: copies or kernel.
865    // The answer decides whether fusing blocks or tuning the kernel is
866    // the optimization worth doing.
867    #[cfg(target_os = "macos")]
868    if std::env::var("CMF_METAL_MMPROF").is_ok() {
869        use std::sync::atomic::Ordering::Relaxed;
870        let n = crate::gpu_metal::MM_N.load(Relaxed);
871        eprintln!(
872            "  q4tp mm x{n}: upload {:.1}s · submit+wait {:.1}s · readback {:.1}s",
873            crate::gpu_metal::MM_UP.load(Relaxed) as f64 / 1e6,
874            crate::gpu_metal::MM_GPU.load(Relaxed) as f64 / 1e6,
875            crate::gpu_metal::MM_DN.load(Relaxed) as f64 / 1e6,
876        );
877    }
878
879    // The VAE emits latent_t·4 frames; the request snapped to 17k+5,
880    // which is one fewer than a multiple of four plus the leading key
881    // frame, so trim rather than pad.
882    let keep = out_frames.min(frames_total);
883    Ok(Anim {
884        rgb: trim_frames(&rgb, out_frames, keep, out_h, out_w),
885        frames: keep,
886        height: out_h,
887        width: out_w,
888        audio: wave,
889        samples,
890        sample_rate: sr,
891    })
892}
893
894fn trim_frames(rgb: &[f32], have: usize, keep: usize, h: usize, w: usize) -> Vec<f32> {
895    if keep == have {
896        return rgb.to_vec();
897    }
898    let mut out = vec![0f32; 3 * keep * h * w];
899    for c in 0..3 {
900        let s = c * have * h * w;
901        let d = c * keep * h * w;
902        out[d..d + keep * h * w].copy_from_slice(&rgb[s..s + keep * h * w]);
903    }
904    out
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910
911    #[test]
912    fn the_four_step_schedule_is_the_references() {
913        let s = sigmas(4, 12.0);
914        let want = [1.0, 0.972_973, 0.923_077, 0.8, 0.0];
915        assert_eq!(s.len(), want.len());
916        for (g, w) in s.iter().zip(&want) {
917            assert!((g - w).abs() < 1e-6, "{s:?}");
918        }
919    }
920
921    #[test]
922    fn a_stretch_keeps_the_corners_and_a_crop_takes_the_middle() {
923        // A 4x2 ramp: value rises left to right, so the corners name
924        // themselves.
925        let (h, w) = (2usize, 4usize);
926        let mut rgb = vec![0f32; 3 * h * w];
927        for c in 0..3 {
928            for y in 0..h {
929                for x in 0..w {
930                    rgb[(c * h + y) * w + x] = x as f32 / (w - 1) as f32;
931                }
932            }
933        }
934        // Stretch to a square: the far edges survive.
935        let s = fit_to_canvas(&rgb, h, w, 4, 4, false);
936        assert!((s[0] - 0.0).abs() < 1e-6, "left edge");
937        assert!((s[3] - 1.0).abs() < 1e-6, "right edge");
938        // Cover-crop to a square takes the centre 2x2, so the extremes
939        // are gone and the span is narrower.
940        let c = fit_to_canvas(&rgb, h, w, 4, 4, true);
941        let (lo, hi) = c[..16]
942            .iter()
943            .fold((f32::MAX, f32::MIN), |(a, b), &v| (a.min(v), b.max(v)));
944        assert!(lo > 0.05, "crop kept the left edge: {lo}");
945        assert!(hi < 0.95, "crop kept the right edge: {hi}");
946    }
947
948    #[test]
949    fn frame_counts_snap_to_the_models_grid() {
950        // The grid is 5 + 17k: 5, 22, 39, 56, … 124.
951        assert_eq!(align_frames(1), 5);
952        assert_eq!(align_frames(39), 39);
953        assert_eq!(align_frames(41), 56);
954        assert_eq!(align_frames(124), 124);
955        assert_eq!(video_latent_t(124), 37);
956        assert_eq!(video_latent_t(39), 12);
957        let (f, lt, at) = temporal_shape(124);
958        assert_eq!((f, lt, at), (124, 37, 207));
959    }
960}