Skip to main content

oximedia_transcode/
thumbnail.rs

1//! Thumbnail and preview image generation.
2//!
3//! This module provides configuration structures and utilities for generating
4//! thumbnail images or sprite sheets from video content. Actual pixel decoding
5//! is handled by the caller; this module focuses on timestamp selection, sizing,
6//! and nearest-neighbour scaling.
7
8#![allow(dead_code)]
9
10/// Output format for generated thumbnails.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ThumbnailFormat {
13    /// JPEG (lossy, small file size).
14    Jpeg,
15    /// PNG (lossless).
16    Png,
17    /// WebP (modern, good compression).
18    Webp,
19}
20
21impl ThumbnailFormat {
22    /// Returns the conventional file extension for this format.
23    #[must_use]
24    pub fn extension(&self) -> &'static str {
25        match self {
26            ThumbnailFormat::Jpeg => "jpg",
27            ThumbnailFormat::Png => "png",
28            ThumbnailFormat::Webp => "webp",
29        }
30    }
31
32    /// Returns the MIME type for this format.
33    #[must_use]
34    pub fn mime_type(&self) -> &'static str {
35        match self {
36            ThumbnailFormat::Jpeg => "image/jpeg",
37            ThumbnailFormat::Png => "image/png",
38            ThumbnailFormat::Webp => "image/webp",
39        }
40    }
41}
42
43/// Strategy for selecting thumbnail timestamps.
44#[derive(Debug, Clone)]
45pub enum ThumbnailStrategy {
46    /// Thumbnails at a fixed interval in milliseconds.
47    FixedInterval,
48    /// Thumbnails at detected scene-change points (caller must supply timestamps).
49    SceneChange,
50    /// Thumbnails evenly distributed across the duration.
51    Uniform,
52    /// Thumbnails at specific caller-supplied timestamps (in milliseconds).
53    AtTimestamps(Vec<u64>),
54}
55
56/// Configuration for thumbnail generation.
57#[derive(Debug, Clone)]
58pub struct ThumbnailConfig {
59    /// Width of each thumbnail in pixels.
60    pub width: u32,
61    /// Height of each thumbnail in pixels.
62    pub height: u32,
63    /// Output image format.
64    pub format: ThumbnailFormat,
65    /// Quality hint (0–100). Interpretation depends on the format.
66    pub quality: u8,
67    /// Number of thumbnails to generate (ignored for `AtTimestamps`).
68    pub count: usize,
69    /// Strategy for selecting frame timestamps.
70    pub interval_strategy: ThumbnailStrategy,
71}
72
73impl ThumbnailConfig {
74    /// Creates a sensible default config suitable for web use (320×180 JPEG).
75    #[must_use]
76    pub fn default_web() -> Self {
77        Self {
78            width: 320,
79            height: 180,
80            format: ThumbnailFormat::Jpeg,
81            quality: 80,
82            count: 10,
83            interval_strategy: ThumbnailStrategy::Uniform,
84        }
85    }
86
87    /// Creates a config appropriate for building a sprite sheet with the given thumbnail count.
88    #[must_use]
89    pub fn sprite_sheet(count: usize) -> Self {
90        Self {
91            width: 160,
92            height: 90,
93            format: ThumbnailFormat::Jpeg,
94            quality: 70,
95            count,
96            interval_strategy: ThumbnailStrategy::Uniform,
97        }
98    }
99
100    /// Returns `true` if the configured resolution is non-zero.
101    #[must_use]
102    pub fn is_valid(&self) -> bool {
103        self.width > 0 && self.height > 0 && self.count > 0
104    }
105}
106
107/// A single generated thumbnail.
108#[derive(Debug, Clone)]
109pub struct Thumbnail {
110    /// Timestamp in the source video (milliseconds).
111    pub timestamp_ms: u64,
112    /// Width of this thumbnail in pixels.
113    pub width: u32,
114    /// Height of this thumbnail in pixels.
115    pub height: u32,
116    /// Raw pixel data (RGBA, row-major).
117    pub data: Vec<u8>,
118}
119
120impl Thumbnail {
121    /// Creates a new thumbnail with the given parameters.
122    #[must_use]
123    pub fn new(timestamp_ms: u64, width: u32, height: u32, data: Vec<u8>) -> Self {
124        Self {
125            timestamp_ms,
126            width,
127            height,
128            data,
129        }
130    }
131
132    /// Returns the number of pixels in this thumbnail.
133    #[must_use]
134    pub fn pixel_count(&self) -> usize {
135        (self.width * self.height) as usize
136    }
137
138    /// Returns the expected byte length for RGBA data.
139    #[must_use]
140    pub fn expected_byte_len(&self) -> usize {
141        self.pixel_count() * 4
142    }
143}
144
145/// Computes a list of timestamps (in milliseconds) at which to capture thumbnails.
146///
147/// # Arguments
148///
149/// * `duration_ms` - Total content duration in milliseconds.
150/// * `strategy` - The selection strategy.
151/// * `fps` - Frame rate of the source (used to snap timestamps to frame boundaries).
152///   Pass `0.0` to skip frame-snapping.
153#[must_use]
154pub fn compute_thumbnail_timestamps(
155    duration_ms: u64,
156    strategy: &ThumbnailStrategy,
157    fps: f64,
158) -> Vec<u64> {
159    if duration_ms == 0 {
160        return Vec::new();
161    }
162
163    let snap = |ts: f64| -> u64 {
164        if fps > 0.0 {
165            let frame_ms = 1000.0 / fps;
166            ((ts / frame_ms).round() * frame_ms) as u64
167        } else {
168            ts as u64
169        }
170    };
171
172    match strategy {
173        ThumbnailStrategy::AtTimestamps(ts) => {
174            ts.iter().filter(|&&t| t <= duration_ms).copied().collect()
175        }
176
177        ThumbnailStrategy::Uniform => {
178            // Will return config.count timestamps; here we use duration_ms to infer count
179            // We default to 10 when called from the generic form without count.
180            // Callers that know the count should use compute_uniform_timestamps.
181            compute_uniform_timestamps(duration_ms, 10, fps)
182        }
183
184        ThumbnailStrategy::FixedInterval => {
185            // Default: one thumbnail every 10 seconds
186            let interval_ms = 10_000u64;
187            let mut ts = Vec::new();
188            let mut t = 0u64;
189            while t <= duration_ms {
190                ts.push(snap(t as f64));
191                t += interval_ms;
192            }
193            ts
194        }
195
196        ThumbnailStrategy::SceneChange => {
197            // Scene-change timestamps must be provided by the caller.
198            // In the generic form, return empty (caller supplies via AtTimestamps).
199            Vec::new()
200        }
201    }
202}
203
204/// Computes `count` uniformly spaced timestamps across `duration_ms`.
205#[must_use]
206pub fn compute_uniform_timestamps(duration_ms: u64, count: usize, fps: f64) -> Vec<u64> {
207    if count == 0 || duration_ms == 0 {
208        return Vec::new();
209    }
210
211    let snap = |ts: f64| -> u64 {
212        if fps > 0.0 {
213            let frame_ms = 1000.0 / fps;
214            ((ts / frame_ms).round() * frame_ms) as u64
215        } else {
216            ts as u64
217        }
218    };
219
220    if count == 1 {
221        return vec![snap(duration_ms as f64 / 2.0)];
222    }
223
224    (0..count)
225        .map(|i| {
226            let t = (duration_ms as f64 * i as f64) / (count - 1) as f64;
227            snap(t).min(duration_ms)
228        })
229        .collect()
230}
231
232/// Scales a source image buffer to the destination dimensions using nearest-neighbour sampling.
233///
234/// The buffers are expected to be RGBA (4 bytes per pixel), stored row-major.
235///
236/// Returns the scaled pixel data, or an empty `Vec` if any dimension is zero.
237#[allow(clippy::too_many_arguments)]
238#[must_use]
239pub fn scale_thumbnail(src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32) -> Vec<u8> {
240    if src_w == 0 || src_h == 0 || dst_w == 0 || dst_h == 0 {
241        return Vec::new();
242    }
243
244    let expected_len = (src_w * src_h * 4) as usize;
245    if src.len() < expected_len {
246        return Vec::new();
247    }
248
249    let mut dst = vec![0u8; (dst_w * dst_h * 4) as usize];
250
251    for dy in 0..dst_h {
252        for dx in 0..dst_w {
253            // Nearest-neighbour mapping
254            let sx = (f64::from(dx) * f64::from(src_w) / f64::from(dst_w)) as u32;
255            let sy = (f64::from(dy) * f64::from(src_h) / f64::from(dst_h)) as u32;
256
257            let src_idx = ((sy * src_w + sx) * 4) as usize;
258            let dst_idx = ((dy * dst_w + dx) * 4) as usize;
259
260            if src_idx + 3 < src.len() && dst_idx + 3 < dst.len() {
261                dst[dst_idx] = src[src_idx];
262                dst[dst_idx + 1] = src[src_idx + 1];
263                dst[dst_idx + 2] = src[src_idx + 2];
264                dst[dst_idx + 3] = src[src_idx + 3];
265            }
266        }
267    }
268
269    dst
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_thumbnail_format_extension() {
278        assert_eq!(ThumbnailFormat::Jpeg.extension(), "jpg");
279        assert_eq!(ThumbnailFormat::Png.extension(), "png");
280        assert_eq!(ThumbnailFormat::Webp.extension(), "webp");
281    }
282
283    #[test]
284    fn test_thumbnail_format_mime_type() {
285        assert_eq!(ThumbnailFormat::Jpeg.mime_type(), "image/jpeg");
286        assert_eq!(ThumbnailFormat::Png.mime_type(), "image/png");
287        assert_eq!(ThumbnailFormat::Webp.mime_type(), "image/webp");
288    }
289
290    #[test]
291    fn test_thumbnail_config_default_web() {
292        let cfg = ThumbnailConfig::default_web();
293        assert_eq!(cfg.width, 320);
294        assert_eq!(cfg.height, 180);
295        assert_eq!(cfg.format, ThumbnailFormat::Jpeg);
296        assert_eq!(cfg.count, 10);
297        assert!(cfg.is_valid());
298    }
299
300    #[test]
301    fn test_thumbnail_config_sprite_sheet() {
302        let cfg = ThumbnailConfig::sprite_sheet(20);
303        assert_eq!(cfg.width, 160);
304        assert_eq!(cfg.height, 90);
305        assert_eq!(cfg.count, 20);
306        assert!(cfg.is_valid());
307    }
308
309    #[test]
310    fn test_thumbnail_pixel_count() {
311        let thumb = Thumbnail::new(0, 160, 90, vec![0; 160 * 90 * 4]);
312        assert_eq!(thumb.pixel_count(), 14400);
313        assert_eq!(thumb.expected_byte_len(), 57600);
314    }
315
316    #[test]
317    fn test_compute_timestamps_at_timestamps() {
318        let strategy = ThumbnailStrategy::AtTimestamps(vec![1000, 2000, 3000]);
319        let ts = compute_thumbnail_timestamps(5000, &strategy, 0.0);
320        assert_eq!(ts, vec![1000, 2000, 3000]);
321    }
322
323    #[test]
324    fn test_compute_timestamps_at_timestamps_filters_out_of_range() {
325        let strategy = ThumbnailStrategy::AtTimestamps(vec![1000, 2000, 9999]);
326        let ts = compute_thumbnail_timestamps(5000, &strategy, 0.0);
327        assert_eq!(ts, vec![1000, 2000]);
328    }
329
330    #[test]
331    fn test_compute_timestamps_zero_duration() {
332        let ts = compute_thumbnail_timestamps(0, &ThumbnailStrategy::Uniform, 24.0);
333        assert!(ts.is_empty());
334    }
335
336    #[test]
337    fn test_compute_uniform_timestamps_count() {
338        let ts = compute_uniform_timestamps(60_000, 5, 0.0);
339        assert_eq!(ts.len(), 5);
340        // First should be 0, last should be 60000
341        assert_eq!(ts[0], 0);
342        assert_eq!(ts[4], 60_000);
343    }
344
345    #[test]
346    fn test_compute_uniform_timestamps_single() {
347        let ts = compute_uniform_timestamps(10_000, 1, 0.0);
348        assert_eq!(ts.len(), 1);
349        assert_eq!(ts[0], 5000);
350    }
351
352    #[test]
353    fn test_compute_fixed_interval_timestamps() {
354        // 30 seconds → timestamps at 0, 10000, 20000, 30000
355        let ts = compute_thumbnail_timestamps(30_000, &ThumbnailStrategy::FixedInterval, 0.0);
356        assert_eq!(ts, vec![0, 10_000, 20_000, 30_000]);
357    }
358
359    #[test]
360    fn test_scale_thumbnail_identity() {
361        // 2x2 RGBA image (identity scale)
362        let src = vec![
363            255, 0, 0, 255, // pixel (0,0): red
364            0, 255, 0, 255, // pixel (1,0): green
365            0, 0, 255, 255, // pixel (0,1): blue
366            255, 255, 0, 255, // pixel (1,1): yellow
367        ];
368        let dst = scale_thumbnail(&src, 2, 2, 2, 2);
369        assert_eq!(dst, src);
370    }
371
372    #[test]
373    fn test_scale_thumbnail_upscale() {
374        // 1x1 → 2x2: all pixels should be the same
375        let src = vec![100u8, 150, 200, 255];
376        let dst = scale_thumbnail(&src, 1, 1, 2, 2);
377        assert_eq!(dst.len(), 16);
378        // All four pixels should replicate the source
379        assert_eq!(&dst[0..4], &[100, 150, 200, 255]);
380        assert_eq!(&dst[4..8], &[100, 150, 200, 255]);
381    }
382
383    #[test]
384    fn test_scale_thumbnail_zero_dimensions() {
385        let src = vec![255u8; 16];
386        assert!(scale_thumbnail(&src, 0, 2, 4, 4).is_empty());
387        assert!(scale_thumbnail(&src, 2, 2, 0, 4).is_empty());
388    }
389
390    #[test]
391    fn test_scale_thumbnail_undersized_src() {
392        // Supply less data than expected → empty result
393        let src = vec![255u8; 4]; // only 1 pixel, but claiming 4x4
394        let dst = scale_thumbnail(&src, 4, 4, 2, 2);
395        assert!(dst.is_empty());
396    }
397}