Skip to main content

ff_encode/preview/
mod.rs

1//! Preview generation — sprite sheets and animated GIFs.
2//!
3//! [`SpriteSheet`] samples evenly-spaced frames from a video and tiles them
4//! into a single PNG image suitable for video-player scrub-bar hover previews.
5//!
6//! [`GifPreview`] generates an animated GIF from a configurable time range
7//! using FFmpeg's two-pass `palettegen` + `paletteuse` approach.
8
9mod error;
10mod preview_inner;
11
12pub use error::PreviewImageError;
13
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17/// Generates a thumbnail sprite sheet from a video file.
18///
19/// Frames are sampled at evenly-spaced intervals across the full video
20/// duration and tiled into a single PNG image of size
21/// `cols × frame_width` × `rows × frame_height`.
22///
23/// # Examples
24///
25/// ```ignore
26/// use ff_encode::SpriteSheet;
27///
28/// SpriteSheet::new("video.mp4")
29///     .cols(5)
30///     .rows(4)
31///     .frame_width(160)
32///     .frame_height(90)
33///     .output("sprites.png")
34///     .run()?;
35/// ```
36pub struct SpriteSheet {
37    input: PathBuf,
38    cols: u32,
39    rows: u32,
40    frame_width: u32,
41    frame_height: u32,
42    output: PathBuf,
43}
44
45impl SpriteSheet {
46    /// Creates a new `SpriteSheet` for the given input file.
47    ///
48    /// Defaults: `cols=10`, `rows=10`, `frame_width=160`, `frame_height=90`,
49    /// no output path set.
50    pub fn new(input: impl AsRef<Path>) -> Self {
51        Self {
52            input: input.as_ref().to_path_buf(),
53            cols: 10,
54            rows: 10,
55            frame_width: 160,
56            frame_height: 90,
57            output: PathBuf::new(),
58        }
59    }
60
61    /// Sets the number of columns in the sprite grid (default: 10).
62    #[must_use]
63    pub fn cols(self, n: u32) -> Self {
64        Self { cols: n, ..self }
65    }
66
67    /// Sets the number of rows in the sprite grid (default: 10).
68    #[must_use]
69    pub fn rows(self, n: u32) -> Self {
70        Self { rows: n, ..self }
71    }
72
73    /// Sets the width of each individual thumbnail frame in pixels (default: 160).
74    #[must_use]
75    pub fn frame_width(self, w: u32) -> Self {
76        Self {
77            frame_width: w,
78            ..self
79        }
80    }
81
82    /// Sets the height of each individual thumbnail frame in pixels (default: 90).
83    #[must_use]
84    pub fn frame_height(self, h: u32) -> Self {
85        Self {
86            frame_height: h,
87            ..self
88        }
89    }
90
91    /// Sets the output path for the generated PNG file.
92    #[must_use]
93    pub fn output(self, path: impl AsRef<Path>) -> Self {
94        Self {
95            output: path.as_ref().to_path_buf(),
96            ..self
97        }
98    }
99
100    /// Runs the sprite sheet generation.
101    ///
102    /// Output image dimensions: `cols × frame_width` × `rows × frame_height`.
103    ///
104    /// # Errors
105    ///
106    /// - [`PreviewImageError::OperationFailed`] — `cols` or `rows` is zero,
107    ///   `frame_width` or `frame_height` is zero, or `output` path is not set.
108    /// - [`PreviewImageError::Ffmpeg`] — any FFmpeg filter graph or encoding call fails.
109    pub fn run(self) -> Result<(), PreviewImageError> {
110        if self.cols == 0 || self.rows == 0 {
111            return Err(PreviewImageError::OperationFailed {
112                reason: "cols/rows must be > 0".to_string(),
113            });
114        }
115        if self.frame_width == 0 || self.frame_height == 0 {
116            return Err(PreviewImageError::OperationFailed {
117                reason: "frame_width/frame_height must be > 0".to_string(),
118            });
119        }
120        if self.output.as_os_str().is_empty() {
121            return Err(PreviewImageError::OperationFailed {
122                reason: "output path not set".to_string(),
123            });
124        }
125        preview_inner::generate_sprite_sheet(
126            &self.input,
127            self.cols,
128            self.rows,
129            self.frame_width,
130            self.frame_height,
131            &self.output,
132        )
133    }
134}
135
136/// Generates an animated GIF preview from a configurable time range.
137///
138/// Uses FFmpeg's two-pass `palettegen` + `paletteuse` approach for
139/// high-quality colour fidelity within GIF's 256-colour limit.
140///
141/// # Examples
142///
143/// ```ignore
144/// use ff_encode::GifPreview;
145/// use std::time::Duration;
146///
147/// GifPreview::new("video.mp4")
148///     .start(Duration::from_secs(10))
149///     .duration(Duration::from_secs(3))
150///     .fps(15.0)
151///     .width(480)
152///     .output("preview.gif")
153///     .run()?;
154/// ```
155pub struct GifPreview {
156    input: PathBuf,
157    start: Duration,
158    duration: Duration,
159    fps: f64,
160    width: u32,
161    output: PathBuf,
162}
163
164impl GifPreview {
165    /// Creates a new `GifPreview` for the given input file.
166    ///
167    /// Defaults: `start=0s`, `duration=3s`, `fps=10.0`, `width=320`,
168    /// no output path set.
169    pub fn new(input: impl AsRef<Path>) -> Self {
170        Self {
171            input: input.as_ref().to_path_buf(),
172            start: Duration::ZERO,
173            duration: Duration::from_secs(3),
174            fps: 10.0,
175            width: 320,
176            output: PathBuf::new(),
177        }
178    }
179
180    /// Sets the start time within the video (default: 0s).
181    #[must_use]
182    pub fn start(self, t: Duration) -> Self {
183        Self { start: t, ..self }
184    }
185
186    /// Sets the duration of the GIF clip (default: 3s).
187    #[must_use]
188    pub fn duration(self, d: Duration) -> Self {
189        Self {
190            duration: d,
191            ..self
192        }
193    }
194
195    /// Sets the output frame rate in frames per second (default: 10.0).
196    #[must_use]
197    pub fn fps(self, fps: f64) -> Self {
198        Self { fps, ..self }
199    }
200
201    /// Sets the output width in pixels (default: 320). Height is scaled
202    /// proportionally, rounded to an even number.
203    #[must_use]
204    pub fn width(self, w: u32) -> Self {
205        Self { width: w, ..self }
206    }
207
208    /// Sets the output path for the generated GIF file.
209    ///
210    /// The path must have a `.gif` extension.
211    #[must_use]
212    pub fn output(self, path: impl AsRef<Path>) -> Self {
213        Self {
214            output: path.as_ref().to_path_buf(),
215            ..self
216        }
217    }
218
219    /// Runs the GIF generation.
220    ///
221    /// # Errors
222    ///
223    /// - [`PreviewImageError::OperationFailed`] — output path not set, output
224    ///   extension is not `.gif`, `fps` ≤ 0, or `width` is zero.
225    /// - [`PreviewImageError::Ffmpeg`] — any FFmpeg filter graph or encoding call fails.
226    pub fn run(self) -> Result<(), PreviewImageError> {
227        if self.output.as_os_str().is_empty() {
228            return Err(PreviewImageError::OperationFailed {
229                reason: "output path not set".to_string(),
230            });
231        }
232        if self.output.extension().and_then(|e| e.to_str()) != Some("gif") {
233            return Err(PreviewImageError::OperationFailed {
234                reason: "output path must have .gif extension".to_string(),
235            });
236        }
237        if self.fps <= 0.0 {
238            return Err(PreviewImageError::OperationFailed {
239                reason: "fps must be positive".to_string(),
240            });
241        }
242        if self.width == 0 {
243            return Err(PreviewImageError::OperationFailed {
244                reason: "width must be > 0".to_string(),
245            });
246        }
247        preview_inner::generate_gif_preview(
248            &self.input,
249            self.start,
250            self.duration,
251            self.fps,
252            self.width,
253            &self.output,
254        )
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn sprite_sheet_zero_cols_should_return_media_operation_failed() {
264        let result = SpriteSheet::new("irrelevant.mp4")
265            .cols(0)
266            .output("out.png")
267            .run();
268        assert!(
269            matches!(result, Err(PreviewImageError::OperationFailed { .. })),
270            "expected OperationFailed for cols=0, got {result:?}"
271        );
272    }
273
274    #[test]
275    fn sprite_sheet_zero_frame_width_should_return_media_operation_failed() {
276        let result = SpriteSheet::new("irrelevant.mp4")
277            .frame_width(0)
278            .output("out.png")
279            .run();
280        assert!(
281            matches!(result, Err(PreviewImageError::OperationFailed { .. })),
282            "expected OperationFailed for frame_width=0, got {result:?}"
283        );
284    }
285
286    #[test]
287    fn sprite_sheet_missing_output_should_return_media_operation_failed() {
288        let result = SpriteSheet::new("irrelevant.mp4").run();
289        assert!(
290            matches!(result, Err(PreviewImageError::OperationFailed { .. })),
291            "expected OperationFailed for empty output path, got {result:?}"
292        );
293    }
294
295    #[test]
296    fn gif_preview_non_gif_extension_should_return_media_operation_failed() {
297        let result = GifPreview::new("irrelevant.mp4").output("out.mp4").run();
298        assert!(
299            matches!(result, Err(PreviewImageError::OperationFailed { .. })),
300            "expected OperationFailed for non-.gif extension, got {result:?}"
301        );
302    }
303
304    #[test]
305    fn gif_preview_missing_output_should_return_media_operation_failed() {
306        let result = GifPreview::new("irrelevant.mp4").run();
307        assert!(
308            matches!(result, Err(PreviewImageError::OperationFailed { .. })),
309            "expected OperationFailed for missing output path, got {result:?}"
310        );
311    }
312
313    #[test]
314    fn gif_preview_zero_fps_should_return_media_operation_failed() {
315        let result = GifPreview::new("irrelevant.mp4")
316            .fps(0.0)
317            .output("out.gif")
318            .run();
319        assert!(
320            matches!(result, Err(PreviewImageError::OperationFailed { .. })),
321            "expected OperationFailed for fps=0, got {result:?}"
322        );
323    }
324}