Skip to main content

ff_decode/extract/
mod.rs

1//! Batch frame extraction and thumbnail selection.
2//!
3//! [`FrameExtractor`] samples one frame per configurable time interval across
4//! the full duration of a video. Returns a `Vec<VideoFrame>` suitable for
5//! thumbnail strips and preview generation.
6//!
7//! [`ThumbnailSelector`] picks the single best representative frame by scoring
8//! candidates for brightness and sharpness, skipping near-black, near-white,
9//! and blurry frames.
10
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14use ff_format::{PixelFormat, VideoFrame};
15
16use crate::DecodeError;
17use crate::VideoDecoder;
18
19/// Extracts one frame per time interval from a video file.
20///
21/// # Examples
22///
23/// ```ignore
24/// use ff_decode::FrameExtractor;
25/// use std::time::Duration;
26///
27/// let frames = FrameExtractor::new("video.mp4")
28///     .interval(Duration::from_secs(5))
29///     .run()?;
30/// println!("extracted {} frames", frames.len());
31/// ```
32pub struct FrameExtractor {
33    input: PathBuf,
34    interval: Duration,
35}
36
37impl FrameExtractor {
38    /// Creates a new `FrameExtractor` for the given input file.
39    ///
40    /// The default extraction interval is 1 second.
41    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    /// Sets the time interval between extracted frames.
49    ///
50    /// Passing [`Duration::ZERO`] causes [`run`](Self::run) to return
51    /// [`DecodeError::AnalysisFailed`].
52    #[must_use]
53    pub fn interval(self, d: Duration) -> Self {
54        Self {
55            interval: d,
56            ..self
57        }
58    }
59
60    /// Runs the extraction and returns one frame per interval.
61    ///
62    /// Timestamps `0, interval, 2×interval, …` up to (but not including)
63    /// the video duration are sampled. [`DecodeError::NoFrameAtTimestamp`]
64    /// for a given timestamp is silently skipped with a `warn!` log; all
65    /// other errors are propagated immediately.
66    ///
67    /// # Errors
68    ///
69    /// - [`DecodeError::AnalysisFailed`] — interval is zero, or the input
70    ///   file cannot be opened.
71    /// - Any other [`DecodeError`] propagated from the decoder.
72    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
109/// Automatically selects the best thumbnail frame from a video file.
110///
111/// Candidates are sampled at regular intervals. Frames that are near-black
112/// (mean luma < 10), near-white (mean luma > 245), or blurry (Laplacian
113/// variance < 100) are skipped. The first candidate that passes all quality
114/// gates is returned. If no candidate passes, the sharpest frame seen is
115/// returned as a fallback.
116///
117/// # Examples
118///
119/// ```ignore
120/// use ff_decode::ThumbnailSelector;
121/// use std::time::Duration;
122///
123/// let frame = ThumbnailSelector::new("video.mp4")
124///     .candidate_interval(Duration::from_secs(5))
125///     .run()?;
126/// ```
127pub struct ThumbnailSelector {
128    input: PathBuf,
129    candidate_interval: Duration,
130}
131
132impl ThumbnailSelector {
133    /// Creates a new `ThumbnailSelector` for the given input file.
134    ///
135    /// Default candidate interval is 5 seconds.
136    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    /// Sets the interval between candidate frames (default: 5s).
144    #[must_use]
145    pub fn candidate_interval(self, d: Duration) -> Self {
146        Self {
147            candidate_interval: d,
148            ..self
149        }
150    }
151
152    /// Runs thumbnail selection and returns the best frame.
153    ///
154    /// # Errors
155    ///
156    /// - [`DecodeError::AnalysisFailed`] — the interval is zero, or no frame
157    ///   can be sampled from the video.
158    /// - Any other [`DecodeError`] propagated from the decoder.
159    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        // (laplacian_variance, frame) — best seen so far for fallback.
172        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
227// ── Private scoring helpers ───────────────────────────────────────────────────
228
229/// Computes mean BT.601 luma across all pixels in an `RGB24` frame.
230///
231/// Returns `0.0` when frame data is unavailable or the frame is empty.
232fn 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
263/// Computes the variance of the Laplacian applied to the luma channel.
264///
265/// A high value indicates a sharp image; near-zero indicates a blurry or
266/// uniform image. Border pixels are excluded from the computation.
267///
268/// Returns `0.0` when the frame is smaller than 3×3 or data is unavailable.
269fn 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        // Unit test: verify the timestamp generation logic without a real file.
332        // We test this via the zero-interval guard and rely on integration tests
333        // for the full run() path with a real video file.
334        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    // ── mean_luma unit tests ──────────────────────────────────────────────────
350
351    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        // Pure red: luma = 0.299 * 255 = ~76.245
376        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        // All-black frame has luma = 0.0, which is < 10.0 — rejected.
387        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        // Uniform frame has zero Laplacian response everywhere → variance = 0.
398        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}