metrale_model_layers/
video_preprocess.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Video to patch tensor, the temporal counterpart of
4//! [`crate::vision_preprocess`].
5//!
6//! Owner: model-layers (vision input).
7//! Invariants:
8//! - Every group of a `PreprocessedVideo` has the same `grid_h × grid_w`
9//!   plane, from one target size chosen from the first kept frame.
10//! - `preprocess_video` returns at least one full temporal group or an error.
11//!
12//! The flattened patch dimension is `C × temporal_patch_size × patch²`, so a
13//! patch spans `tp` frames. A still image fills that axis by repeating itself
14//! `tp` times (`preprocess_image`); a video of `n` sampled frames becomes
15//! `grid_t = n / tp` temporal groups, each a full patch plane built from `tp`
16//! consecutive frames. The encoder takes each group as one image. `grid_t` is
17//! carried, not flattened away, because MRoPE advances T once per group of a
18//! video and holds it constant across an image.
19//!
20//! The decoder is chosen by magic bytes, not the declared MIME: GIF decodes
21//! in-process; everything else goes to the opt-in ffmpeg subprocess
22//! (`video_decode_ffmpeg`).
23
24use anyhow::{Context, Result, ensure};
25use image::RgbImage;
26use metrale_config::VisionConfig;
27
28use crate::vision_preprocess::{MEAN, STD, decode_data_uri_bytes, target_size_for};
29
30/// 2026-09-25: Frames per second to sample at when the caller gives no usable
31/// rate. The Qwen3-VL-30B-A3B checkpoint's `video_preprocessor_config.json`
32/// declares `fps: 2`.
33pub const DEFAULT_FPS: f32 = 2.0;
34
35/// 2026-09-25: Sampling floor and ceiling, as `min_frames: 4` and
36/// `max_frames: 768` in the same `video_preprocessor_config.json`.
37pub const DEFAULT_MIN_FRAMES: usize = 4;
38pub const DEFAULT_MAX_FRAMES: usize = 768;
39
40/// 2026-09-25: A decoded, ready-to-encode video.
41pub struct PreprocessedVideo {
42    /// 2026-09-25: One entry per temporal group, each shaped like a preprocessed
43    /// still: `[grid_h * grid_w, C * tp * patch * patch]`.
44    pub groups: Vec<Vec<f32>>,
45    pub grid_t: usize,
46    pub grid_h: usize,
47    pub grid_w: usize,
48}
49
50/// 2026-09-25: Summarised rather than derived: the payload is megabytes of f32.
51impl std::fmt::Debug for PreprocessedVideo {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(
54            f,
55            "PreprocessedVideo {{ grid_t: {}, grid_h: {}, grid_w: {}, groups: {} x {} f32 }}",
56            self.grid_t,
57            self.grid_h,
58            self.grid_w,
59            self.groups.len(),
60            self.groups.first().map_or(0, Vec::len)
61        )
62    }
63}
64
65impl PreprocessedVideo {
66    /// 2026-09-25: Merged tokens this video contributes: one per `merge × merge`
67    /// block of patches, per temporal group.
68    pub fn pad_count(&self, spatial_merge_size: usize) -> usize {
69        let sms = spatial_merge_size.max(1);
70        self.grid_t * (self.grid_h / sms) * (self.grid_w / sms)
71    }
72}
73
74/// 2026-09-25: Pick which frame indices to keep so the clip plays at `fps`.
75///
76/// `native_fps` is the container's rate. Indices are the nearest to a uniform
77/// grid over the clip, not every Nth frame, which quantises badly when the
78/// ratio is not an integer. The count is clamped into `[min_frames,
79/// max_frames]`, never above the frames that exist, and then down to a
80/// multiple of `temporal_patch_size` (at least one group), because a partial
81/// group cannot be encoded. A clip shorter than one group returns all of its
82/// frames. A non-finite or non-positive rate counts as `DEFAULT_FPS`.
83pub fn sample_indices(
84    n_frames: usize,
85    native_fps: f32,
86    target_fps: f32,
87    min_frames: usize,
88    max_frames: usize,
89    temporal_patch_size: usize,
90) -> Vec<usize> {
91    if n_frames == 0 {
92        return Vec::new();
93    }
94    let tp = temporal_patch_size.max(1);
95    let native_fps = if native_fps.is_finite() && native_fps > 0.0 {
96        native_fps
97    } else {
98        DEFAULT_FPS
99    };
100    let target_fps = if target_fps.is_finite() && target_fps > 0.0 {
101        target_fps
102    } else {
103        DEFAULT_FPS
104    };
105
106    let duration = n_frames as f32 / native_fps;
107    let wanted = (duration * target_fps).round().max(1.0) as usize;
108
109    // 2026-09-25: Never more frames than exist: repeating frames would add tokens
110    // with no new information.
111    let max_frames = max_frames.max(1);
112    let min_frames = min_frames.max(1).min(max_frames);
113    let wanted = wanted.clamp(min_frames, max_frames).min(n_frames);
114
115    let wanted = (wanted / tp).max(1) * tp;
116    let wanted = wanted.min((n_frames / tp).max(1) * tp).min(n_frames);
117
118    if wanted >= n_frames {
119        return (0..n_frames).collect();
120    }
121    // 2026-09-25: Uniform positions across the clip, nearest index.
122    let mut out = Vec::with_capacity(wanted);
123    for i in 0..wanted {
124        let pos = if wanted == 1 {
125            0.0
126        } else {
127            (i as f32) * ((n_frames - 1) as f32) / ((wanted - 1) as f32)
128        };
129        out.push((pos.round() as usize).min(n_frames - 1));
130    }
131    out
132}
133
134/// 2026-09-25: Decode every frame of a container, choosing a backend by what the
135/// bytes are.
136///
137/// Returns the frames and the rate they represent. GIF is decoded in-process
138/// and reports the container's average rate, so the caller still samples it.
139/// ffmpeg samples during decode and reports `target_fps`, so the caller's
140/// sampling applies only the frame-count clamps.
141///
142/// Dispatch is on magic bytes, not the declared MIME: an mp4 labelled
143/// `video/gif` or `application/octet-stream` still reaches ffmpeg, and a GIF
144/// labelled mp4 does not spawn a process.
145pub fn decode_frames(
146    data_uri: &str,
147    target_fps: f32,
148    ffmpeg: &crate::video_decode_ffmpeg::FfmpegPolicy,
149) -> Result<(Vec<RgbImage>, f32)> {
150    let (mime, bytes) = decode_data_uri_bytes(data_uri)?;
151    ensure!(!bytes.is_empty(), "the video payload is empty");
152
153    if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
154        return decode_gif(&bytes);
155    }
156
157    // 2026-09-25: Everything else goes to the subprocess backend. Its error is
158    // wrapped with the sniffed container name.
159    let kind = sniff_container(&bytes, &mime);
160    crate::video_decode_ffmpeg::decode_frames(&bytes, target_fps, ffmpeg)
161        .with_context(|| format!("decoding {kind}"))
162        .map(|f| (f, target_fps))
163}
164
165/// 2026-09-25: Best-effort container name for error messages only; nothing
166/// branches on it.
167fn sniff_container(bytes: &[u8], mime: &str) -> String {
168    let by_magic = if bytes.len() > 12 && &bytes[4..8] == b"ftyp" {
169        Some("an MP4/MOV container")
170    } else if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
171        Some("a Matroska/WebM container")
172    } else if bytes.starts_with(b"RIFF") {
173        Some("an AVI container")
174    } else {
175        None
176    };
177    match (by_magic, mime.is_empty()) {
178        (Some(k), _) => k.to_string(),
179        (None, false) => format!("a {mime} payload"),
180        (None, true) => "an unrecognized container".to_string(),
181    }
182}
183
184/// 2026-09-25: In-process GIF decode. The rate is frames over the summed
185/// per-frame delays; when the delays sum to zero, the rate is `DEFAULT_FPS`.
186fn decode_gif(bytes: &[u8]) -> Result<(Vec<RgbImage>, f32)> {
187    use image::AnimationDecoder;
188    use image::codecs::gif::GifDecoder;
189    let decoder =
190        GifDecoder::new(std::io::Cursor::new(bytes.to_vec())).context("not a decodable GIF")?;
191    let frames = decoder
192        .into_frames()
193        .collect_frames()
194        .context("failed to decode animation frames")?;
195    ensure!(!frames.is_empty(), "the container decoded to zero frames");
196
197    let total_ms: f64 = frames
198        .iter()
199        .map(|f| {
200            let (num, den) = f.delay().numer_denom_ms();
201            if den == 0 {
202                0.0
203            } else {
204                num as f64 / den as f64
205            }
206        })
207        .sum();
208    let fps = if total_ms > 0.0 {
209        (frames.len() as f64 * 1000.0 / total_ms) as f32
210    } else {
211        DEFAULT_FPS
212    };
213
214    let rgb: Vec<RgbImage> = frames
215        .into_iter()
216        .map(|f| image::DynamicImage::ImageRgba8(f.into_buffer()).to_rgb8())
217        .collect();
218    Ok((rgb, fps))
219}
220
221/// 2026-09-25: Full pipeline: a base64 `data:` URI holding an animated container
222/// becomes temporal groups of patches.
223pub fn preprocess_video(
224    data_uri: &str,
225    vcfg: &VisionConfig,
226    max_pixels: Option<usize>,
227    target_fps: f32,
228    ffmpeg: &crate::video_decode_ffmpeg::FfmpegPolicy,
229) -> Result<PreprocessedVideo> {
230    ensure!(
231        vcfg.patch_size > 0 && vcfg.spatial_merge_size > 0 && vcfg.temporal_patch_size > 0,
232        "vision_config geometry is invalid (patch/merge/temporal size is 0)"
233    );
234    let (frames, native_fps) = decode_frames(data_uri, target_fps, ffmpeg)?;
235    let tp = vcfg.temporal_patch_size;
236
237    let keep = sample_indices(
238        frames.len(),
239        native_fps,
240        target_fps,
241        DEFAULT_MIN_FRAMES,
242        DEFAULT_MAX_FRAMES,
243        tp,
244    );
245    ensure!(!keep.is_empty(), "frame sampling selected no frames");
246
247    // 2026-09-25: A clip shorter than one temporal group is refused rather than
248    // padded into a still.
249    ensure!(
250        keep.len() >= tp,
251        "video has {} usable frame(s) but temporal_patch_size is {tp}; a clip must carry at \
252         least one full temporal group",
253        keep.len()
254    );
255
256    // 2026-09-25: The target size is decided once, from the first kept frame, and
257    // applied to every frame: the groups share one pad run whose token count
258    // assumes a single grid.
259    let first = &frames[keep[0]];
260    let grid_unit = (vcfg.patch_size * vcfg.spatial_merge_size) as u32;
261    let (th, tw) = target_size_for(first.height(), first.width(), grid_unit, max_pixels);
262
263    let ps = vcfg.patch_size;
264    let grid_h = (th as usize) / ps;
265    let grid_w = (tw as usize) / ps;
266    let grid_t = keep.len() / tp;
267    let patch_dim = 3 * tp * ps * ps;
268    let plane = grid_h * grid_w;
269
270    let mut groups = Vec::with_capacity(grid_t);
271    for g in 0..grid_t {
272        // 2026-09-25: Resize each of this group's `tp` frames once, before the
273        // patch loop reads them.
274        let resized: Vec<RgbImage> = (0..tp)
275            .map(|k| {
276                let f = &frames[keep[g * tp + k]];
277                image::imageops::resize(f, tw, th, image::imageops::FilterType::CatmullRom)
278            })
279            .collect();
280
281        let mut pixels = vec![0.0f32; plane * patch_dim];
282        for ph in 0..grid_h {
283            for pw in 0..grid_w {
284                let patch_idx = ph * grid_w + pw;
285                for c in 0..3usize {
286                    for (t, frame) in resized.iter().enumerate() {
287                        for py in 0..ps {
288                            for px in 0..ps {
289                                let raw = frame
290                                    .get_pixel((pw * ps + px) as u32, (ph * ps + py) as u32)[c]
291                                    as f32
292                                    / 255.0;
293                                let off = c * (tp * ps * ps) + t * (ps * ps) + py * ps + px;
294                                pixels[patch_idx * patch_dim + off] = (raw - MEAN[c]) / STD[c];
295                            }
296                        }
297                    }
298                }
299            }
300        }
301        groups.push(pixels);
302    }
303
304    Ok(PreprocessedVideo {
305        groups,
306        grid_t,
307        grid_h,
308        grid_w,
309    })
310}
311
312#[cfg(test)]
313#[path = "video_preprocess_tests.rs"]
314mod tests;