metrale_model_layers/
video_preprocess.rs1use 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
30pub const DEFAULT_FPS: f32 = 2.0;
34
35pub const DEFAULT_MIN_FRAMES: usize = 4;
38pub const DEFAULT_MAX_FRAMES: usize = 768;
39
40pub struct PreprocessedVideo {
42 pub groups: Vec<Vec<f32>>,
45 pub grid_t: usize,
46 pub grid_h: usize,
47 pub grid_w: usize,
48}
49
50impl 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 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
74pub 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 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 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
134pub 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 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
165fn 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
184fn 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
221pub 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 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 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 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;