Skip to main content

ff_pipeline/
thumbnail.rs

1//! Thumbnail extraction pipeline.
2//!
3//! This module provides [`ThumbnailPipeline`], which extracts a [`VideoFrame`] at each
4//! caller-specified timestamp from a media file. Seeking and decoding are delegated to
5//! [`ff_decode::VideoDecoder`]; no `unsafe` code is required here.
6
7use std::path::PathBuf;
8use std::time::Duration;
9
10use ff_decode::{SeekMode, VideoDecoder};
11use ff_format::VideoFrame;
12
13use crate::PipelineError;
14
15/// Extracts still frames from a video file at requested timestamps.
16///
17/// # Construction
18///
19/// Use the consuming builder pattern:
20///
21/// ```ignore
22/// use ff_pipeline::ThumbnailPipeline;
23///
24/// let frames = ThumbnailPipeline::new("video.mp4")
25///     .timestamps(vec![0.0, 5.0, 10.0])
26///     .run()?;
27/// ```
28pub struct ThumbnailPipeline {
29    path: String,
30    timestamps: Vec<f64>,
31    output_dir: Option<PathBuf>,
32    width: Option<u32>,
33    quality: Option<u32>,
34}
35
36impl ThumbnailPipeline {
37    /// Creates a new pipeline for the given file path.
38    pub fn new(path: &str) -> Self {
39        Self {
40            path: path.to_owned(),
41            timestamps: Vec::new(),
42            output_dir: None,
43            width: None,
44            quality: None,
45        }
46    }
47
48    /// Sets the timestamps (in seconds) at which to extract frames.
49    #[must_use]
50    pub fn timestamps(mut self, times: Vec<f64>) -> Self {
51        self.timestamps = times;
52        self
53    }
54
55    /// Set output directory for [`run_to_files()`](Self::run_to_files).
56    #[must_use]
57    pub fn output_dir(mut self, dir: impl AsRef<std::path::Path>) -> Self {
58        self.output_dir = Some(dir.as_ref().to_path_buf());
59        self
60    }
61
62    /// Limit thumbnail width; height is scaled proportionally.
63    ///
64    /// Only used by [`run_to_files()`](Self::run_to_files).
65    #[must_use]
66    pub fn width(mut self, w: u32) -> Self {
67        self.width = Some(w);
68        self
69    }
70
71    /// JPEG quality 0–100.
72    ///
73    /// Only used by [`run_to_files()`](Self::run_to_files).
74    #[must_use]
75    pub fn quality(mut self, q: u32) -> Self {
76        self.quality = Some(q);
77        self
78    }
79
80    /// Runs the pipeline and returns one [`VideoFrame`] per requested timestamp.
81    ///
82    /// Timestamps are processed in ascending order. If `timestamps` is empty,
83    /// the file is never opened and `Ok(vec![])` is returned immediately.
84    ///
85    /// When the `parallel` feature is enabled, each timestamp is decoded in its
86    /// own thread via `rayon`. Each thread opens an independent [`VideoDecoder`];
87    /// no decoder context is shared. The output order matches the ascending
88    /// timestamp order regardless of which thread finishes first.
89    ///
90    /// # Errors
91    ///
92    /// Propagates [`PipelineError::Decode`] for any decoding or seek failure.
93    pub fn run(mut self) -> Result<Vec<VideoFrame>, PipelineError> {
94        if self.timestamps.is_empty() {
95            return Ok(vec![]);
96        }
97        decode_frames(&self.path, &mut self.timestamps)
98    }
99
100    /// Runs the pipeline, writes each frame as a JPEG to `output_dir`,
101    /// and returns the written paths in timestamp order.
102    ///
103    /// File names: `thumb_0000.jpg`, `thumb_0001.jpg`, … (zero-padded index).
104    /// When `.width()` is set, height is scaled proportionally.
105    ///
106    /// # Errors
107    ///
108    /// - [`PipelineError::NoOutput`]  — `output_dir` not configured
109    /// - [`PipelineError::Io`]        — directory creation failed
110    /// - [`PipelineError::Decode`]    — seek/decode failed
111    /// - [`PipelineError::Encode`]    — image write failed
112    pub fn run_to_files(mut self) -> Result<Vec<PathBuf>, PipelineError> {
113        let dir = self.output_dir.take().ok_or(PipelineError::NoOutput)?;
114
115        if self.timestamps.is_empty() {
116            return Ok(vec![]);
117        }
118
119        std::fs::create_dir_all(&dir)?;
120
121        let frames = decode_frames(&self.path, &mut self.timestamps)?;
122
123        let mut paths = Vec::with_capacity(frames.len());
124        for (i, frame) in frames.into_iter().enumerate() {
125            let fw = frame.width();
126            let fh = frame.height();
127
128            let (enc_w, enc_h) = match self.width {
129                Some(w) if fw > w => {
130                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
131                    let enc_h =
132                        ((f64::from(fh) * f64::from(w) / f64::from(fw)).round() as u32).max(1);
133                    (w, enc_h)
134                }
135                _ => (fw, fh),
136            };
137
138            let out_path = dir.join(format!("thumb_{i:04}.jpg"));
139
140            let mut builder = ff_encode::ImageEncoder::create(&out_path)
141                .width(enc_w)
142                .height(enc_h);
143            if let Some(q) = self.quality {
144                builder = builder.quality(q);
145            }
146            builder.build()?.encode(&frame)?;
147
148            log::info!(
149                "thumbnail written path={} width={enc_w} height={enc_h}",
150                out_path.display()
151            );
152            paths.push(out_path);
153        }
154
155        Ok(paths)
156    }
157}
158
159fn decode_frames(path: &str, timestamps: &mut [f64]) -> Result<Vec<VideoFrame>, PipelineError> {
160    timestamps.sort_by(f64::total_cmp);
161
162    #[cfg(feature = "parallel")]
163    {
164        use rayon::prelude::*;
165
166        log::info!(
167            "thumbnail pipeline starting parallel extraction path={} count={}",
168            path,
169            timestamps.len()
170        );
171
172        // par_iter on a slice is an IndexedParallelIterator: collect() preserves
173        // the original index order, so the output is already timestamp-sorted.
174        timestamps
175            .par_iter()
176            .map(|ts| {
177                let mut decoder = VideoDecoder::open(path).build()?;
178                decoder.seek(Duration::from_secs_f64(*ts), SeekMode::Keyframe)?;
179                let frame = decoder
180                    .decode_one()?
181                    .ok_or(PipelineError::FrameNotAvailable)?;
182                Ok(frame)
183            })
184            .collect()
185    }
186
187    #[cfg(not(feature = "parallel"))]
188    {
189        let mut decoder = VideoDecoder::open(path).build()?;
190        log::info!("thumbnail pipeline opened file path={path}");
191
192        let mut frames = Vec::with_capacity(timestamps.len());
193        for ts in timestamps.iter() {
194            decoder.seek(Duration::from_secs_f64(*ts), SeekMode::Keyframe)?;
195            let frame = decoder
196                .decode_one()?
197                .ok_or(PipelineError::FrameNotAvailable)?;
198            frames.push(frame);
199        }
200
201        Ok(frames)
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn new_should_store_path() {
211        let pipeline = ThumbnailPipeline::new("video.mp4");
212        assert_eq!(pipeline.path, "video.mp4");
213    }
214
215    #[test]
216    fn timestamps_should_store_timestamps() {
217        let pipeline = ThumbnailPipeline::new("video.mp4").timestamps(vec![1.0, 2.0, 3.0]);
218        assert_eq!(pipeline.timestamps, vec![1.0, 2.0, 3.0]);
219    }
220
221    #[test]
222    fn run_with_no_timestamps_should_return_empty_vec() {
223        let result = ThumbnailPipeline::new("nonexistent.mp4").run();
224        assert!(matches!(result, Ok(ref v) if v.is_empty()));
225    }
226
227    #[test]
228    fn timestamps_should_sort_ascending_before_run() {
229        let mut ts = [3.0_f64, 1.0, 2.0];
230        ts.sort_by(f64::total_cmp);
231        assert_eq!(ts, [1.0, 2.0, 3.0]);
232    }
233
234    #[test]
235    #[allow(clippy::float_cmp)]
236    fn timestamps_nan_should_sort_after_finite_values() {
237        let mut ts = [2.0_f64, f64::NAN, 1.0];
238        ts.sort_by(f64::total_cmp);
239        assert_eq!(ts[0], 1.0);
240        assert_eq!(ts[1], 2.0);
241        assert!(ts[2].is_nan());
242    }
243
244    #[cfg(feature = "parallel")]
245    #[test]
246    fn parallel_run_with_no_timestamps_should_return_empty_vec() {
247        let result = ThumbnailPipeline::new("nonexistent.mp4").run();
248        assert!(matches!(result, Ok(ref v) if v.is_empty()));
249    }
250
251    #[test]
252    fn output_dir_should_store_path() {
253        let pipeline = ThumbnailPipeline::new("video.mp4").output_dir("/tmp/thumbs");
254        assert_eq!(pipeline.output_dir, Some(PathBuf::from("/tmp/thumbs")));
255    }
256
257    #[test]
258    fn width_setter_should_store_value() {
259        let pipeline = ThumbnailPipeline::new("video.mp4").width(320);
260        assert_eq!(pipeline.width, Some(320));
261    }
262
263    #[test]
264    fn quality_setter_should_store_value() {
265        let pipeline = ThumbnailPipeline::new("video.mp4").quality(85);
266        assert_eq!(pipeline.quality, Some(85));
267    }
268
269    #[test]
270    fn run_to_files_without_output_dir_should_return_no_output_error() {
271        let result = ThumbnailPipeline::new("nonexistent.mp4")
272            .timestamps(vec![0.0])
273            .run_to_files();
274        assert!(matches!(result, Err(PipelineError::NoOutput)));
275    }
276
277    #[test]
278    fn run_to_files_with_empty_timestamps_and_no_dir_should_return_no_output_error() {
279        let result = ThumbnailPipeline::new("nonexistent.mp4").run_to_files();
280        assert!(matches!(result, Err(PipelineError::NoOutput)));
281    }
282}