1use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14use ff_format::{PixelFormat, VideoFrame};
15
16use crate::DecodeError;
17use crate::VideoDecoder;
18
19pub struct FrameExtractor {
33 input: PathBuf,
34 interval: Duration,
35}
36
37impl FrameExtractor {
38 pub fn new(input: impl AsRef<Path>) -> Self {
42 Self {
43 input: input.as_ref().to_path_buf(),
44 interval: Duration::from_secs(1),
45 }
46 }
47
48 #[must_use]
53 pub fn interval(self, d: Duration) -> Self {
54 Self {
55 interval: d,
56 ..self
57 }
58 }
59
60 pub fn run(self) -> Result<Vec<VideoFrame>, DecodeError> {
73 if self.interval.is_zero() {
74 return Err(DecodeError::AnalysisFailed {
75 reason: "interval must be positive".to_string(),
76 });
77 }
78
79 let mut decoder = VideoDecoder::open(&self.input).build()?;
80 let duration = decoder.duration();
81
82 let mut frames = Vec::new();
83 let mut ts = Duration::ZERO;
84
85 while ts < duration {
86 match decoder.extract_frame(ts) {
87 Ok(frame) => frames.push(frame),
88 Err(DecodeError::NoFrameAtTimestamp { .. }) => {
89 log::warn!(
90 "frame not available, skipping timestamp={ts:?} input={}",
91 self.input.display()
92 );
93 }
94 Err(e) => return Err(e),
95 }
96 ts += self.interval;
97 }
98
99 let frame_count = frames.len();
100 log::debug!(
101 "frame extraction complete frames={frame_count} interval={interval:?}",
102 interval = self.interval
103 );
104
105 Ok(frames)
106 }
107}
108
109pub struct ThumbnailSelector {
128 input: PathBuf,
129 candidate_interval: Duration,
130}
131
132impl ThumbnailSelector {
133 pub fn new(input: impl AsRef<Path>) -> Self {
137 Self {
138 input: input.as_ref().to_path_buf(),
139 candidate_interval: Duration::from_secs(5),
140 }
141 }
142
143 #[must_use]
145 pub fn candidate_interval(self, d: Duration) -> Self {
146 Self {
147 candidate_interval: d,
148 ..self
149 }
150 }
151
152 pub fn run(self) -> Result<VideoFrame, DecodeError> {
160 if self.candidate_interval.is_zero() {
161 return Err(DecodeError::AnalysisFailed {
162 reason: "candidate_interval must be positive".to_string(),
163 });
164 }
165
166 let mut decoder = VideoDecoder::open(&self.input)
167 .output_format(PixelFormat::Rgb24)
168 .build()?;
169 let duration = decoder.duration();
170
171 let mut best: Option<(f64, VideoFrame)> = None;
173 let mut ts = Duration::ZERO;
174
175 while ts < duration {
176 let frame = match decoder.extract_frame(ts) {
177 Ok(f) => f,
178 Err(DecodeError::NoFrameAtTimestamp { .. }) => {
179 log::warn!(
180 "frame not available, skipping timestamp={ts:?} input={}",
181 self.input.display()
182 );
183 ts += self.candidate_interval;
184 continue;
185 }
186 Err(e) => return Err(e),
187 };
188
189 let luma = mean_luma(&frame);
190 if !(10.0..=245.0).contains(&luma) {
191 ts += self.candidate_interval;
192 continue;
193 }
194
195 let sharpness = laplacian_variance(&frame);
196 if sharpness >= 100.0 {
197 log::debug!(
198 "thumbnail selected timestamp={ts:?} luma={luma:.1} sharpness={sharpness:.1}"
199 );
200 return Ok(frame);
201 }
202
203 let keep = best
204 .as_ref()
205 .is_none_or(|(best_sharpness, _)| sharpness > *best_sharpness);
206 if keep {
207 best = Some((sharpness, frame));
208 }
209
210 ts += self.candidate_interval;
211 }
212
213 if let Some((sharpness, frame)) = best {
214 log::debug!(
215 "thumbnail fallback used sharpness={sharpness:.1} input={}",
216 self.input.display()
217 );
218 return Ok(frame);
219 }
220
221 Err(DecodeError::AnalysisFailed {
222 reason: "no suitable thumbnail frame found".to_string(),
223 })
224 }
225}
226
227fn mean_luma(frame: &VideoFrame) -> f64 {
233 let width = frame.width() as usize;
234 let height = frame.height() as usize;
235 let pixel_count = width * height;
236 if pixel_count == 0 {
237 return 0.0;
238 }
239 let Some(plane) = frame.plane(0) else {
240 return 0.0;
241 };
242 let Some(stride) = frame.stride(0) else {
243 return 0.0;
244 };
245
246 let mut sum = 0.0_f64;
247 for row in 0..height {
248 let row_start = row * stride;
249 for col in 0..width {
250 let offset = row_start + col * 3;
251 let r = f64::from(plane[offset]);
252 let g = f64::from(plane[offset + 1]);
253 let b = f64::from(plane[offset + 2]);
254 sum += 0.299 * r + 0.587 * g + 0.114 * b;
255 }
256 }
257 #[allow(clippy::cast_precision_loss)]
258 {
259 sum / pixel_count as f64
260 }
261}
262
263fn laplacian_variance(frame: &VideoFrame) -> f64 {
270 let width = frame.width() as usize;
271 let height = frame.height() as usize;
272 if width < 3 || height < 3 {
273 return 0.0;
274 }
275 let Some(plane) = frame.plane(0) else {
276 return 0.0;
277 };
278 let Some(stride) = frame.stride(0) else {
279 return 0.0;
280 };
281
282 let luma_at = |row: usize, col: usize| -> f64 {
283 let offset = row * stride + col * 3;
284 let r = f64::from(plane[offset]);
285 let g = f64::from(plane[offset + 1]);
286 let b = f64::from(plane[offset + 2]);
287 0.299 * r + 0.587 * g + 0.114 * b
288 };
289
290 let inner_count = (width - 2) * (height - 2);
291 let mut responses = Vec::with_capacity(inner_count);
292
293 for row in 1..(height - 1) {
294 for col in 1..(width - 1) {
295 let lap = luma_at(row - 1, col)
296 + luma_at(row + 1, col)
297 + luma_at(row, col - 1)
298 + luma_at(row, col + 1)
299 - 4.0 * luma_at(row, col);
300 responses.push(lap);
301 }
302 }
303
304 #[allow(clippy::cast_precision_loss)]
305 let n = inner_count as f64;
306 let mean = responses.iter().sum::<f64>() / n;
307 responses
308 .iter()
309 .map(|x| (x - mean) * (x - mean))
310 .sum::<f64>()
311 / n
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn frame_extractor_zero_interval_should_err() {
320 let result = FrameExtractor::new("irrelevant.mp4")
321 .interval(Duration::ZERO)
322 .run();
323 assert!(
324 matches!(result, Err(DecodeError::AnalysisFailed { .. })),
325 "expected AnalysisFailed for zero interval, got {result:?}"
326 );
327 }
328
329 #[test]
330 fn frame_extractor_should_return_correct_frame_count() {
331 let extractor = FrameExtractor::new("video.mp4").interval(Duration::from_secs(1));
335 assert_eq!(extractor.interval, Duration::from_secs(1));
336 }
337
338 #[test]
339 fn thumbnail_selector_zero_interval_should_return_analysis_failed() {
340 let result = ThumbnailSelector::new("irrelevant.mp4")
341 .candidate_interval(Duration::ZERO)
342 .run();
343 assert!(
344 matches!(result, Err(DecodeError::AnalysisFailed { .. })),
345 "expected AnalysisFailed for zero interval, got {result:?}"
346 );
347 }
348
349 fn make_rgb24_frame(width: u32, height: u32, fill: [u8; 3]) -> VideoFrame {
352 use ff_format::{PooledBuffer, Timestamp};
353
354 let stride = width as usize * 3;
355 let mut data = vec![0u8; stride * height as usize];
356 for pixel in data.chunks_mut(3) {
357 pixel[0] = fill[0];
358 pixel[1] = fill[1];
359 pixel[2] = fill[2];
360 }
361 VideoFrame::new(
362 vec![PooledBuffer::standalone(data)],
363 vec![stride],
364 width,
365 height,
366 PixelFormat::Rgb24,
367 Timestamp::default(),
368 false,
369 )
370 .unwrap()
371 }
372
373 #[test]
374 fn mean_luma_should_return_correct_value_for_solid_color() {
375 let frame = make_rgb24_frame(4, 4, [255, 0, 0]);
377 let luma = mean_luma(&frame);
378 assert!(
379 (luma - 76.245).abs() < 0.1,
380 "expected luma ≈ 76.245 for pure red, got {luma:.3}"
381 );
382 }
383
384 #[test]
385 fn thumbnail_selector_should_skip_black_frames() {
386 let frame = make_rgb24_frame(4, 4, [0, 0, 0]);
388 let luma = mean_luma(&frame);
389 assert!(
390 luma < 10.0,
391 "expected luma < 10 for black frame, got {luma:.3}"
392 );
393 }
394
395 #[test]
396 fn laplacian_variance_blurry_should_return_low_value() {
397 let frame = make_rgb24_frame(8, 8, [128, 64, 32]);
399 let variance = laplacian_variance(&frame);
400 assert!(
401 variance < 1.0,
402 "expected near-zero variance for uniform frame, got {variance:.3}"
403 );
404 }
405}