Skip to main content

unbundle/
thumbnail.rs

1//! Thumbnail generation utilities.
2//!
3//! Provides helpers for extracting scaled thumbnails and compositing them
4//! into contact-sheet grids. These promote common patterns from user code
5//! into the library API.
6
7use std::time::Duration;
8
9use image::imageops::FilterType;
10use image::{DynamicImage, GenericImage};
11
12use crate::configuration::ExtractOptions;
13use crate::error::UnbundleError;
14use crate::unbundle::MediaFile;
15use crate::video::FrameRange;
16
17/// Options for thumbnail grid generation.
18///
19/// Controls grid layout, thumbnail dimensions, and spacing.
20///
21/// # Example
22///
23/// ```no_run
24/// use unbundle::{MediaFile, ThumbnailHandle, ThumbnailOptions, UnbundleError};
25///
26/// let mut unbundler = MediaFile::open("input.mp4")?;
27/// let config = ThumbnailOptions::new(4, 4).with_thumbnail_width(320);
28/// let grid = ThumbnailHandle::grid(&mut unbundler, &config)?;
29/// grid.save("contact_sheet.png")?;
30/// # Ok::<(), UnbundleError>(())
31/// ```
32#[derive(Debug, Clone)]
33#[must_use]
34pub struct ThumbnailOptions {
35    /// Number of columns in the grid.
36    pub columns: u32,
37    /// Number of rows in the grid.
38    pub rows: u32,
39    /// Target width for each thumbnail in pixels.
40    ///
41    /// The height is computed automatically to preserve aspect ratio.
42    pub thumbnail_width: u32,
43}
44
45impl ThumbnailOptions {
46    /// Create new thumbnail options.
47    ///
48    /// `columns` and `rows` define the grid dimensions. Thumbnail width
49    /// defaults to 320 pixels.
50    pub fn new(columns: u32, rows: u32) -> Self {
51        Self {
52            columns,
53            rows,
54            thumbnail_width: 320,
55        }
56    }
57
58    /// Set the target width for each thumbnail.
59    ///
60    /// Height is derived automatically from the video's aspect ratio.
61    pub fn with_thumbnail_width(mut self, width: u32) -> Self {
62        self.thumbnail_width = width;
63        self
64    }
65}
66
67/// Thumbnail generation utilities.
68///
69/// All methods are stateless functions that accept a
70/// [`MediaFile`] reference.
71///
72/// # Example
73///
74/// ```no_run
75/// use std::time::Duration;
76///
77/// use unbundle::{MediaFile, ThumbnailHandle, ThumbnailOptions, UnbundleError};
78///
79/// let mut unbundler = MediaFile::open("input.mp4")?;
80///
81/// // Single thumbnail at 10 seconds, max 640px on longest edge
82/// let thumb = ThumbnailHandle::at_timestamp(
83///     &mut unbundler,
84///     Duration::from_secs(10),
85///     640,
86/// )?;
87/// thumb.save("thumb.jpg")?;
88///
89/// // Contact-sheet grid
90/// let config = ThumbnailOptions::new(4, 4);
91/// let grid = ThumbnailHandle::grid(&mut unbundler, &config)?;
92/// grid.save("grid.png")?;
93/// # Ok::<(), UnbundleError>(())
94/// ```
95pub struct ThumbnailHandle;
96
97impl ThumbnailHandle {
98    /// Extract a single thumbnail at a timestamp, scaled to fit within
99    /// `max_dimension` on its longest edge.
100    ///
101    /// Preserves the video's aspect ratio. For example, a 1920×1080 frame
102    /// with `max_dimension = 640` produces a 640×360 thumbnail.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`UnbundleError::NoVideoStream`] if the file has no video,
107    /// [`UnbundleError::InvalidTimestamp`] if the timestamp exceeds the
108    /// duration, or decoding errors.
109    pub fn at_timestamp(
110        unbundler: &mut MediaFile,
111        timestamp: Duration,
112        max_dimension: u32,
113    ) -> Result<DynamicImage, UnbundleError> {
114        log::debug!(
115            "Generating thumbnail at {:?} (max_dim={})",
116            timestamp,
117            max_dimension
118        );
119        let image = unbundler.video().frame_at(timestamp)?;
120        let (width, height) = (image.width(), image.height());
121        let (thumb_width, thumb_height) = fit_dimensions(width, height, max_dimension);
122        Ok(image.resize_exact(thumb_width, thumb_height, FilterType::Triangle))
123    }
124
125    /// Extract a single thumbnail at a frame number, scaled to fit within
126    /// `max_dimension` on its longest edge.
127    ///
128    /// # Errors
129    ///
130    /// Same as [`at_timestamp`](ThumbnailHandle::at_timestamp).
131    pub fn at_frame(
132        unbundler: &mut MediaFile,
133        frame_number: u64,
134        max_dimension: u32,
135    ) -> Result<DynamicImage, UnbundleError> {
136        let image = unbundler.video().frame(frame_number)?;
137        let (width, height) = (image.width(), image.height());
138        let (thumb_width, thumb_height) = fit_dimensions(width, height, max_dimension);
139        Ok(image.resize_exact(thumb_width, thumb_height, FilterType::Triangle))
140    }
141
142    /// Generate a thumbnail contact-sheet grid.
143    ///
144    /// Extracts `columns × rows` frames at evenly-spaced intervals across
145    /// the video, scales them to the configured thumbnail width (preserving
146    /// aspect ratio), and composites them into a single image.
147    ///
148    /// # Errors
149    ///
150    /// Returns [`UnbundleError::NoVideoStream`] if the file has no video, or
151    /// decoding / image errors.
152    ///
153    /// # Example
154    ///
155    /// ```no_run
156    /// use unbundle::{MediaFile, ThumbnailHandle, ThumbnailOptions, UnbundleError};
157    ///
158    /// let mut unbundler = MediaFile::open("input.mp4")?;
159    /// let config = ThumbnailOptions::new(4, 4).with_thumbnail_width(240);
160    /// let grid = ThumbnailHandle::grid(&mut unbundler, &config)?;
161    /// grid.save("contact_sheet.png")?;
162    /// # Ok::<(), UnbundleError>(())
163    /// ```
164    pub fn grid(
165        unbundler: &mut MediaFile,
166        config: &ThumbnailOptions,
167    ) -> Result<DynamicImage, UnbundleError> {
168        Self::grid_with_options(unbundler, config, &ExtractOptions::default())
169    }
170
171    /// Generate a thumbnail grid with progress/cancellation support.
172    ///
173    /// Like [`grid`](ThumbnailHandle::grid) but accepts an
174    /// [`ExtractOptions`] for progress callbacks and cancellation.
175    pub fn grid_with_options(
176        unbundler: &mut MediaFile,
177        config: &ThumbnailOptions,
178        extraction_config: &ExtractOptions,
179    ) -> Result<DynamicImage, UnbundleError> {
180        log::debug!(
181            "Generating {}x{} thumbnail grid (thumb_width={})",
182            config.columns,
183            config.rows,
184            config.thumbnail_width
185        );
186        let video_metadata = unbundler
187            .metadata
188            .video
189            .as_ref()
190            .ok_or(UnbundleError::NoVideoStream)?
191            .clone();
192
193        let total_thumbnails = config.columns * config.rows;
194        let frame_count = video_metadata.frame_count;
195
196        // Compute evenly-spaced frame numbers.
197        let step = if frame_count > total_thumbnails as u64 {
198            frame_count / total_thumbnails as u64
199        } else {
200            1
201        };
202        let frame_numbers: Vec<u64> = (0..total_thumbnails as u64)
203            .map(|index| index * step)
204            .filter(|number| *number < frame_count)
205            .collect();
206
207        let frames = unbundler
208            .video()
209            .frames_with_options(FrameRange::Specific(frame_numbers), extraction_config)?;
210
211        // Compute thumbnail dimensions preserving aspect ratio.
212        let scale_factor = config.thumbnail_width as f64 / video_metadata.width as f64;
213        let scaled_width = config.thumbnail_width;
214        let scaled_height = (video_metadata.height as f64 * scale_factor).round() as u32;
215
216        // Composite the grid.
217        let grid_width = scaled_width * config.columns;
218        let grid_height = scaled_height * config.rows;
219        let mut grid = DynamicImage::new_rgb8(grid_width, grid_height);
220
221        for (index, frame) in frames.iter().enumerate() {
222            let column = (index as u32) % config.columns;
223            let row = (index as u32) / config.columns;
224            if row >= config.rows {
225                break;
226            }
227
228            let thumbnail = frame.resize_exact(scaled_width, scaled_height, FilterType::Triangle);
229
230            let x = column * scaled_width;
231            let y = row * scaled_height;
232            // copy_from can fail if dimensions mismatch — should not happen here.
233            let _ = grid.copy_from(&thumbnail, x, y);
234        }
235
236        Ok(grid)
237    }
238
239    /// Extract a "smart" thumbnail that avoids black or near-uniform frames.
240    ///
241    /// Samples `sample_count` frames evenly across the video and picks the
242    /// one with the highest pixel variance (most visual detail). The chosen
243    /// frame is then scaled to fit within `max_dimension`.
244    ///
245    /// This is useful for generating representative thumbnails without
246    /// relying on a fixed timestamp that might land on a fade-to-black or
247    /// title card.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`UnbundleError::NoVideoStream`] if the file has no video, or
252    /// decoding errors.
253    ///
254    /// # Example
255    ///
256    /// ```no_run
257    /// use unbundle::{MediaFile, ThumbnailHandle, UnbundleError};
258    ///
259    /// let mut unbundler = MediaFile::open("input.mp4")?;
260    /// let thumb = ThumbnailHandle::smart(&mut unbundler, 20, 640)?;
261    /// thumb.save("smart_thumb.jpg")?;
262    /// # Ok::<(), UnbundleError>(())
263    /// ```
264    pub fn smart(
265        unbundler: &mut MediaFile,
266        sample_count: u32,
267        max_dimension: u32,
268    ) -> Result<DynamicImage, UnbundleError> {
269        Self::smart_with_options(
270            unbundler,
271            sample_count,
272            max_dimension,
273            &ExtractOptions::default(),
274        )
275    }
276
277    /// Extract a smart thumbnail with progress/cancellation support.
278    ///
279    /// Like [`smart`](ThumbnailHandle::smart) but accepts an
280    /// [`ExtractOptions`] for progress callbacks and cancellation.
281    pub fn smart_with_options(
282        unbundler: &mut MediaFile,
283        sample_count: u32,
284        max_dimension: u32,
285        extraction_config: &ExtractOptions,
286    ) -> Result<DynamicImage, UnbundleError> {
287        log::debug!(
288            "Generating smart thumbnail (samples={}, max_dim={})",
289            sample_count,
290            max_dimension
291        );
292        let video_metadata = unbundler
293            .metadata
294            .video
295            .as_ref()
296            .ok_or(UnbundleError::NoVideoStream)?
297            .clone();
298
299        let frame_count = video_metadata.frame_count;
300        let count = (sample_count as u64).min(frame_count).max(1);
301
302        let step = if frame_count > count {
303            frame_count / count
304        } else {
305            1
306        };
307        let frame_numbers: Vec<u64> = (0..count)
308            .map(|i| i * step)
309            .filter(|n| *n < frame_count)
310            .collect();
311
312        // Extract with a small resolution for fast variance computation.
313        // We use the caller's config for cancellation/progress support.
314        let frames = unbundler.video().frames_with_options(
315            FrameRange::Specific(frame_numbers.clone()),
316            extraction_config,
317        )?;
318
319        // Find the frame with highest pixel variance.
320        let mut best_index = 0;
321        let mut best_variance: f64 = -1.0;
322
323        for (index, frame) in frames.iter().enumerate() {
324            let variance = pixel_variance(frame);
325            if variance > best_variance {
326                best_variance = variance;
327                best_index = index;
328            }
329        }
330
331        // Re-extract the winning frame at full resolution.
332        let best_frame_number = frame_numbers.get(best_index).copied().unwrap_or(0);
333        let full_image = unbundler.video().frame(best_frame_number)?;
334        let (width, height) = (full_image.width(), full_image.height());
335        let (thumb_width, thumb_height) = fit_dimensions(width, height, max_dimension);
336
337        Ok(full_image.resize_exact(thumb_width, thumb_height, FilterType::Triangle))
338    }
339}
340
341/// Compute dimensions that fit within `max_dimension` preserving aspect ratio.
342fn fit_dimensions(width: u32, height: u32, max_dimension: u32) -> (u32, u32) {
343    if width == 0 || height == 0 {
344        return (max_dimension, max_dimension);
345    }
346    let scale = max_dimension as f64 / width.max(height) as f64;
347    let new_width = ((width as f64) * scale).round() as u32;
348    let new_height = ((height as f64) * scale).round() as u32;
349    (new_width.max(1), new_height.max(1))
350}
351
352/// Compute the pixel variance of an image (higher = more visual detail).
353///
354/// Uses the grayscale luminance for speed. Returns the variance of pixel
355/// values across the entire image.
356fn pixel_variance(image: &DynamicImage) -> f64 {
357    let gray = image.to_luma8();
358    let pixels = gray.as_raw();
359    if pixels.is_empty() {
360        return 0.0;
361    }
362    let count = pixels.len() as f64;
363    let mean: f64 = pixels.iter().map(|&p| p as f64).sum::<f64>() / count;
364    let variance: f64 = pixels
365        .iter()
366        .map(|&p| {
367            let diff = p as f64 - mean;
368            diff * diff
369        })
370        .sum::<f64>()
371        / count;
372    variance
373}