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