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;