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}