spark_model/
video_preprocess.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Video → patch tensor, the temporal sibling of [`crate::vision_preprocess`].
4//!
5//! # What a video is, to this encoder
6//!
7//! Qwen3-VL's ViT has NO temporal attention. Frames fuse inside a patch: the
8//! flattened patch dimension is `C × temporal_patch_size × patch² `, so a
9//! patch already spans `tp` frames' worth of pixels. A still image fills that
10//! axis by REPLICATING itself `tp` times (see `preprocess_image`) — the axis
11//! was always there, and a still is the degenerate case of a video.
12//!
13//! So a video of `n` frames becomes `grid_t = n / tp` TEMPORAL GROUPS, each
14//! group a full `grid_h × grid_w` patch plane built from `tp` consecutive
15//! frames. Each group is shaped exactly like a preprocessed still, which is
16//! why the encoder needs no change at all: the groups ride the existing
17//! per-image path and only the bookkeeping downstream knows they belong to one
18//! item.
19//!
20//! What DOES differ is position. An image holds MRoPE's T coordinate constant
21//! across its whole pad run; a video advances T once per group. That is the
22//! reason `grid_t` is carried rather than groups being flattened into
23//! independent images, and it is why videos get their own pad token.
24//!
25//! # Container support
26//!
27//! Two backends, chosen by MAGIC BYTES rather than the declared MIME:
28//!
29//! - **GIF** decodes in-process, pure Rust, always available, no dependency.
30//! - **Everything else** (MP4/MOV, WebM/Matroska, AVI — H.264, H.265, VP9,
31//!   AV1) goes to ffmpeg as a subprocess, which is opt-in.
32//!
33//! Sniffing the bytes rather than trusting the label means a client that
34//! sends an mp4 as `video/gif`, or as `application/octet-stream`, still gets
35//! the right decoder. See `video_decode_ffmpeg` for why a subprocess rather
36//! than a linked decoder, and issue #515.
37
38use anyhow::{Context, Result, ensure};
39use atlas_core::config::VisionConfig;
40use image::RgbImage;
41
42use crate::vision_preprocess::{MEAN, STD, decode_data_uri_bytes, target_size_for};
43
44/// Frames per second to sample at, when the caller has no better idea.
45/// Matches the `fps: 2` every Qwen3-VL `video_processor` block declares.
46pub const DEFAULT_FPS: f32 = 2.0;
47
48/// Sampling floor and ceiling, also from the checkpoints' own video processor
49/// (`min_frames: 4`, `max_frames: 768`). The floor matters more than it looks:
50/// with `temporal_patch_size = 2`, fewer than 2 frames cannot fill a single
51/// temporal group, and a 1-frame "video" would silently become a still.
52pub const DEFAULT_MIN_FRAMES: usize = 4;
53pub const DEFAULT_MAX_FRAMES: usize = 768;
54
55/// A decoded, ready-to-encode video.
56pub struct PreprocessedVideo {
57    /// One entry per temporal group, each shaped exactly like a preprocessed
58    /// still: `[grid_h * grid_w, C * tp * patch * patch]`.
59    pub groups: Vec<Vec<f32>>,
60    pub grid_t: usize,
61    pub grid_h: usize,
62    pub grid_w: usize,
63}
64
65/// Summarised rather than derived: the payload is megabytes of f32 and a
66/// derived `Debug` would dump all of it into any test failure or log line
67/// that happens to format one.
68impl std::fmt::Debug for PreprocessedVideo {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        write!(
71            f,
72            "PreprocessedVideo {{ grid_t: {}, grid_h: {}, grid_w: {}, groups: {} x {} f32 }}",
73            self.grid_t,
74            self.grid_h,
75            self.grid_w,
76            self.groups.len(),
77            self.groups.first().map_or(0, Vec::len)
78        )
79    }
80}
81
82impl PreprocessedVideo {
83    /// Merged tokens this video contributes: one per `merge × merge` block of
84    /// patches, per temporal group.
85    pub fn pad_count(&self, spatial_merge_size: usize) -> usize {
86        let sms = spatial_merge_size.max(1);
87        self.grid_t * (self.grid_h / sms) * (self.grid_w / sms)
88    }
89}
90
91/// Pick which frame indices to keep so the clip plays at `fps`.
92///
93/// `native_fps` is what the container says it runs at. Sampling is by
94/// NEAREST-INDEX over a uniform grid rather than by dropping every Nth frame:
95/// the latter quantises badly when the ratio is not an integer (a 30fps clip
96/// sampled at 2fps by "keep every 15th" is fine, at 2.5fps it is not).
97///
98/// The result is clamped into `[min_frames, max_frames]` and then to a
99/// multiple of `temporal_patch_size`, because a partial group cannot be
100/// encoded. Returns indices into the decoded frame list.
101pub fn sample_indices(
102    n_frames: usize,
103    native_fps: f32,
104    target_fps: f32,
105    min_frames: usize,
106    max_frames: usize,
107    temporal_patch_size: usize,
108) -> Vec<usize> {
109    if n_frames == 0 {
110        return Vec::new();
111    }
112    let tp = temporal_patch_size.max(1);
113    let native_fps = if native_fps.is_finite() && native_fps > 0.0 {
114        native_fps
115    } else {
116        DEFAULT_FPS
117    };
118    let target_fps = if target_fps.is_finite() && target_fps > 0.0 {
119        target_fps
120    } else {
121        DEFAULT_FPS
122    };
123
124    let duration = n_frames as f32 / native_fps;
125    let wanted = (duration * target_fps).round().max(1.0) as usize;
126
127    // Clamp to the checkpoint's band, but never ask for more frames than
128    // exist — upsampling a short clip by repeating frames would inflate the
129    // token count with no new information.
130    let max_frames = max_frames.max(1);
131    let min_frames = min_frames.max(1).min(max_frames);
132    let wanted = wanted.clamp(min_frames, max_frames).min(n_frames);
133
134    // Round DOWN to a whole number of temporal groups; a partial group has no
135    // representation. Never below one group, or there is nothing to encode.
136    let wanted = (wanted / tp).max(1) * tp;
137    let wanted = wanted.min((n_frames / tp).max(1) * tp).min(n_frames);
138
139    if wanted >= n_frames {
140        return (0..n_frames).collect();
141    }
142    // Uniform positions across the clip, nearest index, deduplicated in order.
143    let mut out = Vec::with_capacity(wanted);
144    for i in 0..wanted {
145        let pos = if wanted == 1 {
146            0.0
147        } else {
148            (i as f32) * ((n_frames - 1) as f32) / ((wanted - 1) as f32)
149        };
150        out.push((pos.round() as usize).min(n_frames - 1));
151    }
152    out
153}
154
155/// Decode every frame of a container, choosing a backend by what the bytes
156/// actually are.
157///
158/// Returns the frames and the rate they represent. GIF is decoded in-process
159/// (pure Rust, no dependency) and reports the container's own average rate,
160/// so the caller still has to sample it. ffmpeg resamples during decode, so
161/// its frames are ALREADY at `target_fps` and it reports that — which makes
162/// the caller's sampling step a no-op rather than a second, lossy resample.
163///
164/// Dispatch is on MAGIC BYTES, not the declared MIME. A client that labels an
165/// mp4 `video/gif`, or sends `application/octet-stream`, still gets the right
166/// decoder; and a GIF mislabelled as mp4 does not needlessly spawn a process.
167pub fn decode_frames(
168    data_uri: &str,
169    target_fps: f32,
170    ffmpeg: &crate::video_decode_ffmpeg::FfmpegPolicy,
171) -> Result<(Vec<RgbImage>, f32)> {
172    let (mime, bytes) = decode_data_uri_bytes(data_uri)?;
173    ensure!(!bytes.is_empty(), "the video payload is empty");
174
175    if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
176        return decode_gif(&bytes);
177    }
178
179    // Everything else goes to the subprocess backend. If it is disabled the
180    // error names the flag AND the container, so the operator is not left
181    // guessing which of the two problems they have.
182    let kind = sniff_container(&bytes, &mime);
183    crate::video_decode_ffmpeg::decode_frames(&bytes, target_fps, ffmpeg)
184        .with_context(|| format!("decoding {kind}"))
185        .map(|f| (f, target_fps))
186}
187
188/// Best-effort container name for error messages. Cosmetic only — nothing
189/// branches on it — so an unrecognized blob is described as such rather than
190/// guessed at.
191fn sniff_container(bytes: &[u8], mime: &str) -> String {
192    let by_magic = if bytes.len() > 12 && &bytes[4..8] == b"ftyp" {
193        Some("an MP4/MOV container")
194    } else if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
195        Some("a Matroska/WebM container")
196    } else if bytes.starts_with(b"RIFF") {
197        Some("an AVI container")
198    } else {
199        None
200    };
201    match (by_magic, mime.is_empty()) {
202        (Some(k), _) => k.to_string(),
203        (None, false) => format!("a {mime} payload"),
204        (None, true) => "an unrecognized container".to_string(),
205    }
206}
207
208/// In-process GIF decode. The rate is derived from the per-frame delays the
209/// format stores; a GIF may declare 0 delay ("as fast as possible"), which is
210/// treated as the default rather than divided by.
211fn decode_gif(bytes: &[u8]) -> Result<(Vec<RgbImage>, f32)> {
212    use image::AnimationDecoder;
213    use image::codecs::gif::GifDecoder;
214    let decoder =
215        GifDecoder::new(std::io::Cursor::new(bytes.to_vec())).context("not a decodable GIF")?;
216    let frames = decoder
217        .into_frames()
218        .collect_frames()
219        .context("failed to decode animation frames")?;
220    ensure!(!frames.is_empty(), "the container decoded to zero frames");
221
222    let total_ms: f64 = frames
223        .iter()
224        .map(|f| {
225            let (num, den) = f.delay().numer_denom_ms();
226            if den == 0 {
227                0.0
228            } else {
229                num as f64 / den as f64
230            }
231        })
232        .sum();
233    let fps = if total_ms > 0.0 {
234        (frames.len() as f64 * 1000.0 / total_ms) as f32
235    } else {
236        DEFAULT_FPS
237    };
238
239    let rgb: Vec<RgbImage> = frames
240        .into_iter()
241        .map(|f| image::DynamicImage::ImageRgba8(f.into_buffer()).to_rgb8())
242        .collect();
243    Ok((rgb, fps))
244}
245
246/// Full pipeline: a base64 `data:` URI holding an animated container becomes
247/// temporal groups of patches.
248pub fn preprocess_video(
249    data_uri: &str,
250    vcfg: &VisionConfig,
251    max_pixels: Option<usize>,
252    target_fps: f32,
253    ffmpeg: &crate::video_decode_ffmpeg::FfmpegPolicy,
254) -> Result<PreprocessedVideo> {
255    ensure!(
256        vcfg.patch_size > 0 && vcfg.spatial_merge_size > 0 && vcfg.temporal_patch_size > 0,
257        "vision_config geometry is invalid (patch/merge/temporal size is 0)"
258    );
259    let (frames, native_fps) = decode_frames(data_uri, target_fps, ffmpeg)?;
260    let tp = vcfg.temporal_patch_size;
261
262    let keep = sample_indices(
263        frames.len(),
264        native_fps,
265        target_fps,
266        DEFAULT_MIN_FRAMES,
267        DEFAULT_MAX_FRAMES,
268        tp,
269    );
270    ensure!(!keep.is_empty(), "frame sampling selected no frames");
271
272    // A clip shorter than one temporal group cannot be encoded as video.
273    // Saying so beats silently padding it into a still, which would report a
274    // plausible token count for something the model never saw as motion.
275    ensure!(
276        keep.len() >= tp,
277        "video has {} usable frame(s) but temporal_patch_size is {tp}; a clip must carry at \
278         least one full temporal group",
279        keep.len()
280    );
281
282    // Geometry is decided ONCE, from the first kept frame, and applied to all
283    // of them. Per-frame sizing would be a correctness bug rather than a
284    // refinement: the groups are concatenated into one pad run whose token
285    // count assumes a single grid.
286    let first = &frames[keep[0]];
287    let grid_unit = (vcfg.patch_size * vcfg.spatial_merge_size) as u32;
288    let (th, tw) = target_size_for(first.height(), first.width(), grid_unit, max_pixels);
289
290    let ps = vcfg.patch_size;
291    let grid_h = (th as usize) / ps;
292    let grid_w = (tw as usize) / ps;
293    let grid_t = keep.len() / tp;
294    let patch_dim = 3 * tp * ps * ps;
295    let plane = grid_h * grid_w;
296
297    let mut groups = Vec::with_capacity(grid_t);
298    for g in 0..grid_t {
299        // Resize this group's `tp` frames once each, up front: the patch loop
300        // reads every pixel of every frame, so resizing inside it would redo
301        // the work `patch²` times.
302        let resized: Vec<RgbImage> = (0..tp)
303            .map(|k| {
304                let f = &frames[keep[g * tp + k]];
305                image::imageops::resize(f, tw, th, image::imageops::FilterType::CatmullRom)
306            })
307            .collect();
308
309        let mut pixels = vec![0.0f32; plane * patch_dim];
310        for ph in 0..grid_h {
311            for pw in 0..grid_w {
312                let patch_idx = ph * grid_w + pw;
313                for c in 0..3usize {
314                    for (t, frame) in resized.iter().enumerate() {
315                        for py in 0..ps {
316                            for px in 0..ps {
317                                let raw = frame
318                                    .get_pixel((pw * ps + px) as u32, (ph * ps + py) as u32)[c]
319                                    as f32
320                                    / 255.0;
321                                let off = c * (tp * ps * ps) + t * (ps * ps) + py * ps + px;
322                                pixels[patch_idx * patch_dim + off] = (raw - MEAN[c]) / STD[c];
323                            }
324                        }
325                    }
326                }
327            }
328        }
329        groups.push(pixels);
330    }
331
332    Ok(PreprocessedVideo {
333        groups,
334        grid_t,
335        grid_h,
336        grid_w,
337    })
338}
339
340#[cfg(test)]
341#[path = "video_preprocess_tests.rs"]
342mod tests;