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// ─── Sprite sheet ─────────────────────────────────────────────────────────────
273
274/// A sprite sheet: a single wide RGBA image that tiles multiple thumbnails
275/// left-to-right, top-to-bottom.
276///
277/// Each cell in the sprite sheet corresponds to one thumbnail at a known
278/// timestamp.  The sheet is intended for use with an accompanying VTT file
279/// so that video players can display scrubber previews.
280#[derive(Debug, Clone)]
281pub struct SpriteSheet {
282    /// Width of each individual thumbnail cell in pixels.
283    pub cell_width: u32,
284    /// Height of each individual thumbnail cell in pixels.
285    pub cell_height: u32,
286    /// Number of columns in the sheet.
287    pub cols: u32,
288    /// Number of rows in the sheet.
289    pub rows: u32,
290    /// Total width of the assembled image (`cols * cell_width`).
291    pub sheet_width: u32,
292    /// Total height of the assembled image (`rows * cell_height`).
293    pub sheet_height: u32,
294    /// Raw RGBA pixel data (row-major, top-to-bottom).
295    pub data: Vec<u8>,
296    /// Timestamps (ms) for each cell, in row-major order.
297    pub timestamps_ms: Vec<u64>,
298}
299
300impl SpriteSheet {
301    /// Assembles a sprite sheet from a collection of [`Thumbnail`]s.
302    ///
303    /// Thumbnails are laid out left-to-right, top-to-bottom.  All thumbnails
304    /// must have the same dimensions; if they differ they are silently scaled
305    /// to the first thumbnail's dimensions using nearest-neighbour sampling.
306    ///
307    /// Returns `None` when `thumbnails` is empty.
308    #[must_use]
309    pub fn from_thumbnails(thumbnails: &[Thumbnail], cols: u32) -> Option<Self> {
310        if thumbnails.is_empty() || cols == 0 {
311            return None;
312        }
313
314        let cell_w = thumbnails[0].width;
315        let cell_h = thumbnails[0].height;
316        if cell_w == 0 || cell_h == 0 {
317            return None;
318        }
319
320        let count = thumbnails.len() as u32;
321        let rows = (count + cols - 1) / cols; // ceiling division
322        let sheet_w = cols * cell_w;
323        let sheet_h = rows * cell_h;
324
325        let mut sheet = vec![0u8; (sheet_w * sheet_h * 4) as usize];
326        let mut timestamps = Vec::with_capacity(thumbnails.len());
327
328        for (idx, thumb) in thumbnails.iter().enumerate() {
329            timestamps.push(thumb.timestamp_ms);
330
331            let col = idx as u32 % cols;
332            let row = idx as u32 / cols;
333
334            // Scale thumb to cell dimensions if needed.
335            let cell_data = if thumb.width == cell_w && thumb.height == cell_h {
336                thumb.data.clone()
337            } else {
338                scale_thumbnail(&thumb.data, thumb.width, thumb.height, cell_w, cell_h)
339            };
340
341            if cell_data.len() < (cell_w * cell_h * 4) as usize {
342                continue; // skip malformed thumbnail
343            }
344
345            // Copy cell_data into the correct position in the sheet.
346            let dest_x = col * cell_w;
347            let dest_y = row * cell_h;
348
349            for cy in 0..cell_h {
350                let src_row_start = (cy * cell_w * 4) as usize;
351                let src_row_end = src_row_start + (cell_w * 4) as usize;
352                let dest_row_start = ((dest_y + cy) * sheet_w * 4 + dest_x * 4) as usize;
353                let dest_row_end = dest_row_start + (cell_w * 4) as usize;
354
355                if src_row_end <= cell_data.len() && dest_row_end <= sheet.len() {
356                    sheet[dest_row_start..dest_row_end]
357                        .copy_from_slice(&cell_data[src_row_start..src_row_end]);
358                }
359            }
360        }
361
362        Some(SpriteSheet {
363            cell_width: cell_w,
364            cell_height: cell_h,
365            cols,
366            rows,
367            sheet_width: sheet_w,
368            sheet_height: sheet_h,
369            data: sheet,
370            timestamps_ms: timestamps,
371        })
372    }
373
374    /// Returns the pixel coordinate `(x, y)` of the top-left corner of the
375    /// cell at `idx` within the sprite sheet.
376    #[must_use]
377    pub fn cell_origin(&self, idx: usize) -> (u32, u32) {
378        let col = idx as u32 % self.cols;
379        let row = idx as u32 / self.cols;
380        (col * self.cell_width, row * self.cell_height)
381    }
382
383    /// Generates a WebVTT cue file (`.vtt`) for this sprite sheet.
384    ///
385    /// # Arguments
386    ///
387    /// * `sprite_url` – URL of the sprite sheet image referenced from the VTT.
388    ///
389    /// The returned string is a complete WebVTT document that can be written
390    /// to disk and served alongside the sprite sheet image.
391    #[must_use]
392    pub fn to_vtt(&self, sprite_url: &str) -> String {
393        let mut vtt = String::from("WEBVTT\n\n");
394
395        for (idx, &ts_ms) in self.timestamps_ms.iter().enumerate() {
396            let next_ts_ms = self
397                .timestamps_ms
398                .get(idx + 1)
399                .copied()
400                .unwrap_or(ts_ms + 1_000); // 1-second window for the last frame
401
402            let (x, y) = self.cell_origin(idx);
403
404            let start = format_vtt_time(ts_ms);
405            let end = format_vtt_time(next_ts_ms);
406
407            vtt.push_str(&format!(
408                "{start} --> {end}\n{sprite_url}#xywh={x},{y},{w},{h}\n\n",
409                w = self.cell_width,
410                h = self.cell_height,
411            ));
412        }
413
414        vtt
415    }
416
417    /// Returns the total number of cells in the sprite sheet.
418    #[must_use]
419    pub fn cell_count(&self) -> usize {
420        self.timestamps_ms.len()
421    }
422
423    /// Returns the total byte length of the raw RGBA data.
424    #[must_use]
425    pub fn byte_len(&self) -> usize {
426        self.data.len()
427    }
428}
429
430/// Formats a millisecond timestamp as a WebVTT time string (`HH:MM:SS.mmm`).
431#[must_use]
432pub fn format_vtt_time(ms: u64) -> String {
433    let total_secs = ms / 1_000;
434    let millis = ms % 1_000;
435    let secs = total_secs % 60;
436    let mins = (total_secs / 60) % 60;
437    let hours = total_secs / 3_600;
438    format!("{hours:02}:{mins:02}:{secs:02}.{millis:03}")
439}
440
441/// Configuration for generating a sprite sheet from a video.
442#[derive(Debug, Clone)]
443pub struct SpriteSheetConfig {
444    /// Width of each thumbnail cell.
445    pub cell_width: u32,
446    /// Height of each thumbnail cell.
447    pub cell_height: u32,
448    /// Number of columns in the sprite sheet.
449    pub cols: u32,
450    /// Total number of thumbnails to generate.
451    pub count: usize,
452    /// Strategy for selecting timestamps.
453    pub strategy: ThumbnailStrategy,
454    /// JPEG quality (0-100, used when encoding the sprite sheet to JPEG).
455    pub quality: u8,
456}
457
458impl SpriteSheetConfig {
459    /// Returns a sensible default for web video players (160×90, 5 cols, 100 frames).
460    #[must_use]
461    pub fn default_web() -> Self {
462        Self {
463            cell_width: 160,
464            cell_height: 90,
465            cols: 5,
466            count: 100,
467            strategy: ThumbnailStrategy::Uniform,
468            quality: 70,
469        }
470    }
471
472    /// Returns a high-density config suitable for long-form content.
473    #[must_use]
474    pub fn high_density(count: usize) -> Self {
475        Self {
476            cell_width: 120,
477            cell_height: 68,
478            cols: 10,
479            count,
480            strategy: ThumbnailStrategy::Uniform,
481            quality: 65,
482        }
483    }
484
485    /// Returns `true` if the configuration is valid (all dimensions > 0).
486    #[must_use]
487    pub fn is_valid(&self) -> bool {
488        self.cell_width > 0 && self.cell_height > 0 && self.cols > 0 && self.count > 0
489    }
490
491    /// Computes the timestamps for this sprite sheet given a content duration.
492    #[must_use]
493    pub fn compute_timestamps(&self, duration_ms: u64, fps: f64) -> Vec<u64> {
494        match &self.strategy {
495            ThumbnailStrategy::Uniform => compute_uniform_timestamps(duration_ms, self.count, fps),
496            other => compute_thumbnail_timestamps(duration_ms, other, fps),
497        }
498    }
499}
500
501// ─── Smart thumbnail selection ────────────────────────────────────────────────
502
503/// Computes the spatial variance of an RGBA thumbnail's luminance.
504///
505/// Higher variance indicates a more "interesting" or visually complex frame.
506/// Used by [`select_smart_thumbnails`] to pick representative frames.
507#[must_use]
508pub fn thumbnail_variance(thumb: &Thumbnail) -> f64 {
509    let pixel_count = thumb.pixel_count();
510    if pixel_count == 0 || thumb.data.len() < pixel_count * 4 {
511        return 0.0;
512    }
513
514    // Compute luminance using BT.709 coefficients
515    let mut sum = 0.0;
516    let mut sum_sq = 0.0;
517
518    for i in 0..pixel_count {
519        let offset = i * 4;
520        let r = f64::from(thumb.data[offset]);
521        let g = f64::from(thumb.data[offset + 1]);
522        let b = f64::from(thumb.data[offset + 2]);
523        let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
524        sum += lum;
525        sum_sq += lum * lum;
526    }
527
528    let n = pixel_count as f64;
529    let mean = sum / n;
530    (sum_sq / n) - (mean * mean)
531}
532
533/// Selects the `count` most visually interesting thumbnails from a set,
534/// ranked by spatial luminance variance.
535///
536/// Returns indices into the input slice, sorted by decreasing variance.
537#[must_use]
538pub fn select_smart_thumbnails(thumbnails: &[Thumbnail], count: usize) -> Vec<usize> {
539    if thumbnails.is_empty() || count == 0 {
540        return Vec::new();
541    }
542
543    let mut scored: Vec<(usize, f64)> = thumbnails
544        .iter()
545        .enumerate()
546        .map(|(i, t)| (i, thumbnail_variance(t)))
547        .collect();
548
549    // Sort by variance descending
550    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
551
552    scored.iter().take(count).map(|&(i, _)| i).collect()
553}
554
555// ─── Animated thumbnail ───────────────────────────────────────────────────────
556
557/// An animated thumbnail consisting of multiple frames with timing.
558///
559/// Represents a short looping preview (e.g. 2–5 seconds) from key moments
560/// in a video. The frames are raw RGBA with per-frame duration.
561#[derive(Debug, Clone)]
562pub struct AnimatedThumbnail {
563    /// Width of each frame in pixels.
564    pub width: u32,
565    /// Height of each frame in pixels.
566    pub height: u32,
567    /// Frames with their display durations.
568    pub frames: Vec<AnimatedFrame>,
569    /// Total duration of the animation in milliseconds.
570    pub total_duration_ms: u64,
571    /// Number of times to loop (0 = infinite).
572    pub loop_count: u32,
573}
574
575/// A single frame in an animated thumbnail.
576#[derive(Debug, Clone)]
577pub struct AnimatedFrame {
578    /// Raw RGBA pixel data (row-major).
579    pub data: Vec<u8>,
580    /// Display duration for this frame in milliseconds.
581    pub duration_ms: u64,
582    /// Source timestamp in the original video (milliseconds).
583    pub source_timestamp_ms: u64,
584}
585
586impl AnimatedThumbnail {
587    /// Creates a new animated thumbnail from a list of source thumbnails.
588    ///
589    /// Each thumbnail is assigned a uniform frame duration.
590    /// Returns `None` if `thumbnails` is empty or dimensions are zero.
591    #[must_use]
592    pub fn from_thumbnails(
593        thumbnails: &[Thumbnail],
594        frame_duration_ms: u64,
595        loop_count: u32,
596    ) -> Option<Self> {
597        if thumbnails.is_empty() || frame_duration_ms == 0 {
598            return None;
599        }
600
601        let width = thumbnails[0].width;
602        let height = thumbnails[0].height;
603        if width == 0 || height == 0 {
604            return None;
605        }
606
607        let mut frames = Vec::with_capacity(thumbnails.len());
608        let mut total_ms = 0u64;
609
610        for thumb in thumbnails {
611            let data = if thumb.width == width && thumb.height == height {
612                thumb.data.clone()
613            } else {
614                scale_thumbnail(&thumb.data, thumb.width, thumb.height, width, height)
615            };
616
617            if data.len() < (width * height * 4) as usize {
618                continue;
619            }
620
621            frames.push(AnimatedFrame {
622                data,
623                duration_ms: frame_duration_ms,
624                source_timestamp_ms: thumb.timestamp_ms,
625            });
626            total_ms += frame_duration_ms;
627        }
628
629        if frames.is_empty() {
630            return None;
631        }
632
633        Some(Self {
634            width,
635            height,
636            frames,
637            total_duration_ms: total_ms,
638            loop_count,
639        })
640    }
641
642    /// Returns the number of frames in the animation.
643    #[must_use]
644    pub fn frame_count(&self) -> usize {
645        self.frames.len()
646    }
647
648    /// Returns the total byte size of all frame data.
649    #[must_use]
650    pub fn total_byte_size(&self) -> usize {
651        self.frames.iter().map(|f| f.data.len()).sum()
652    }
653
654    /// Creates an animated thumbnail from the most interesting frames,
655    /// selected by luminance variance.
656    #[must_use]
657    pub fn from_smart_selection(
658        thumbnails: &[Thumbnail],
659        max_frames: usize,
660        frame_duration_ms: u64,
661        loop_count: u32,
662    ) -> Option<Self> {
663        let indices = select_smart_thumbnails(thumbnails, max_frames);
664        if indices.is_empty() {
665            return None;
666        }
667
668        // Sort selected indices by timestamp for temporal coherence
669        let mut sorted_indices = indices;
670        sorted_indices.sort_by_key(|&i| thumbnails[i].timestamp_ms);
671
672        let selected: Vec<Thumbnail> = sorted_indices
673            .iter()
674            .map(|&i| thumbnails[i].clone())
675            .collect();
676
677        Self::from_thumbnails(&selected, frame_duration_ms, loop_count)
678    }
679}
680
681// ─── Configurable thumbnail quality ──────────────────────────────────────────
682
683/// Quality profile for thumbnail generation.
684#[derive(Debug, Clone, Copy, PartialEq, Eq)]
685pub enum ThumbnailQualityProfile {
686    /// Low quality: smaller file size, faster generation.
687    Low,
688    /// Medium quality: balanced quality and size.
689    Medium,
690    /// High quality: larger files, better visual fidelity.
691    High,
692}
693
694impl ThumbnailQualityProfile {
695    /// Returns the JPEG quality value for this profile.
696    #[must_use]
697    pub fn jpeg_quality(self) -> u8 {
698        match self {
699            Self::Low => 50,
700            Self::Medium => 75,
701            Self::High => 92,
702        }
703    }
704
705    /// Returns the recommended thumbnail dimensions `(width, height)`.
706    #[must_use]
707    pub fn dimensions(self) -> (u32, u32) {
708        match self {
709            Self::Low => (120, 68),
710            Self::Medium => (240, 135),
711            Self::High => (480, 270),
712        }
713    }
714
715    /// Returns the recommended sprite sheet columns.
716    #[must_use]
717    pub fn sprite_cols(self) -> u32 {
718        match self {
719            Self::Low => 10,
720            Self::Medium => 5,
721            Self::High => 4,
722        }
723    }
724}
725
726/// Extended thumbnail configuration with quality profile support.
727#[derive(Debug, Clone)]
728pub struct ThumbnailExtConfig {
729    /// Base configuration.
730    pub base: ThumbnailConfig,
731    /// Quality profile.
732    pub quality_profile: ThumbnailQualityProfile,
733    /// Whether to generate a sprite sheet.
734    pub generate_sprite_sheet: bool,
735    /// Whether to generate a WebVTT file for the sprite sheet.
736    pub generate_vtt: bool,
737    /// Whether to generate an animated preview.
738    pub generate_animated: bool,
739    /// Frame duration for animated thumbnails (milliseconds).
740    pub animated_frame_duration_ms: u64,
741    /// Maximum number of frames in the animated thumbnail.
742    pub animated_max_frames: usize,
743}
744
745impl ThumbnailExtConfig {
746    /// Creates a new extended config from a quality profile.
747    #[must_use]
748    pub fn from_profile(profile: ThumbnailQualityProfile, count: usize) -> Self {
749        let (w, h) = profile.dimensions();
750        Self {
751            base: ThumbnailConfig {
752                width: w,
753                height: h,
754                format: ThumbnailFormat::Jpeg,
755                quality: profile.jpeg_quality(),
756                count,
757                interval_strategy: ThumbnailStrategy::Uniform,
758            },
759            quality_profile: profile,
760            generate_sprite_sheet: true,
761            generate_vtt: true,
762            generate_animated: false,
763            animated_frame_duration_ms: 200,
764            animated_max_frames: 15,
765        }
766    }
767
768    /// Enables animated thumbnail generation.
769    #[must_use]
770    pub fn with_animated(mut self, max_frames: usize, frame_duration_ms: u64) -> Self {
771        self.generate_animated = true;
772        self.animated_max_frames = max_frames;
773        self.animated_frame_duration_ms = frame_duration_ms;
774        self
775    }
776
777    /// Disables sprite sheet generation.
778    #[must_use]
779    pub fn without_sprite_sheet(mut self) -> Self {
780        self.generate_sprite_sheet = false;
781        self.generate_vtt = false;
782        self
783    }
784}
785
786/// Generates a WebVTT thumbnail track file from a sprite sheet and its URL.
787///
788/// This is a convenience wrapper around `SpriteSheet::to_vtt` that also
789/// handles duration-based gap filling for the last cue.
790#[must_use]
791pub fn generate_vtt_track(
792    sprite_sheet: &SpriteSheet,
793    sprite_url: &str,
794    total_duration_ms: u64,
795) -> String {
796    let mut vtt = String::from("WEBVTT\n\n");
797
798    for (idx, &ts_ms) in sprite_sheet.timestamps_ms.iter().enumerate() {
799        let next_ts_ms = sprite_sheet
800            .timestamps_ms
801            .get(idx + 1)
802            .copied()
803            .unwrap_or(total_duration_ms);
804
805        let (x, y) = sprite_sheet.cell_origin(idx);
806        let start = format_vtt_time(ts_ms);
807        let end = format_vtt_time(next_ts_ms);
808
809        vtt.push_str(&format!(
810            "{start} --> {end}\n{sprite_url}#xywh={x},{y},{w},{h}\n\n",
811            w = sprite_sheet.cell_width,
812            h = sprite_sheet.cell_height,
813        ));
814    }
815
816    vtt
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    #[test]
824    fn test_thumbnail_format_extension() {
825        assert_eq!(ThumbnailFormat::Jpeg.extension(), "jpg");
826        assert_eq!(ThumbnailFormat::Png.extension(), "png");
827        assert_eq!(ThumbnailFormat::Webp.extension(), "webp");
828    }
829
830    #[test]
831    fn test_thumbnail_format_mime_type() {
832        assert_eq!(ThumbnailFormat::Jpeg.mime_type(), "image/jpeg");
833        assert_eq!(ThumbnailFormat::Png.mime_type(), "image/png");
834        assert_eq!(ThumbnailFormat::Webp.mime_type(), "image/webp");
835    }
836
837    #[test]
838    fn test_thumbnail_config_default_web() {
839        let cfg = ThumbnailConfig::default_web();
840        assert_eq!(cfg.width, 320);
841        assert_eq!(cfg.height, 180);
842        assert_eq!(cfg.format, ThumbnailFormat::Jpeg);
843        assert_eq!(cfg.count, 10);
844        assert!(cfg.is_valid());
845    }
846
847    #[test]
848    fn test_thumbnail_config_sprite_sheet() {
849        let cfg = ThumbnailConfig::sprite_sheet(20);
850        assert_eq!(cfg.width, 160);
851        assert_eq!(cfg.height, 90);
852        assert_eq!(cfg.count, 20);
853        assert!(cfg.is_valid());
854    }
855
856    #[test]
857    fn test_thumbnail_pixel_count() {
858        let thumb = Thumbnail::new(0, 160, 90, vec![0; 160 * 90 * 4]);
859        assert_eq!(thumb.pixel_count(), 14400);
860        assert_eq!(thumb.expected_byte_len(), 57600);
861    }
862
863    #[test]
864    fn test_compute_timestamps_at_timestamps() {
865        let strategy = ThumbnailStrategy::AtTimestamps(vec![1000, 2000, 3000]);
866        let ts = compute_thumbnail_timestamps(5000, &strategy, 0.0);
867        assert_eq!(ts, vec![1000, 2000, 3000]);
868    }
869
870    #[test]
871    fn test_compute_timestamps_at_timestamps_filters_out_of_range() {
872        let strategy = ThumbnailStrategy::AtTimestamps(vec![1000, 2000, 9999]);
873        let ts = compute_thumbnail_timestamps(5000, &strategy, 0.0);
874        assert_eq!(ts, vec![1000, 2000]);
875    }
876
877    #[test]
878    fn test_compute_timestamps_zero_duration() {
879        let ts = compute_thumbnail_timestamps(0, &ThumbnailStrategy::Uniform, 24.0);
880        assert!(ts.is_empty());
881    }
882
883    #[test]
884    fn test_compute_uniform_timestamps_count() {
885        let ts = compute_uniform_timestamps(60_000, 5, 0.0);
886        assert_eq!(ts.len(), 5);
887        // First should be 0, last should be 60000
888        assert_eq!(ts[0], 0);
889        assert_eq!(ts[4], 60_000);
890    }
891
892    #[test]
893    fn test_compute_uniform_timestamps_single() {
894        let ts = compute_uniform_timestamps(10_000, 1, 0.0);
895        assert_eq!(ts.len(), 1);
896        assert_eq!(ts[0], 5000);
897    }
898
899    #[test]
900    fn test_compute_fixed_interval_timestamps() {
901        // 30 seconds → timestamps at 0, 10000, 20000, 30000
902        let ts = compute_thumbnail_timestamps(30_000, &ThumbnailStrategy::FixedInterval, 0.0);
903        assert_eq!(ts, vec![0, 10_000, 20_000, 30_000]);
904    }
905
906    #[test]
907    fn test_scale_thumbnail_identity() {
908        // 2x2 RGBA image (identity scale)
909        let src = vec![
910            255, 0, 0, 255, // pixel (0,0): red
911            0, 255, 0, 255, // pixel (1,0): green
912            0, 0, 255, 255, // pixel (0,1): blue
913            255, 255, 0, 255, // pixel (1,1): yellow
914        ];
915        let dst = scale_thumbnail(&src, 2, 2, 2, 2);
916        assert_eq!(dst, src);
917    }
918
919    #[test]
920    fn test_scale_thumbnail_upscale() {
921        // 1x1 → 2x2: all pixels should be the same
922        let src = vec![100u8, 150, 200, 255];
923        let dst = scale_thumbnail(&src, 1, 1, 2, 2);
924        assert_eq!(dst.len(), 16);
925        // All four pixels should replicate the source
926        assert_eq!(&dst[0..4], &[100, 150, 200, 255]);
927        assert_eq!(&dst[4..8], &[100, 150, 200, 255]);
928    }
929
930    #[test]
931    fn test_scale_thumbnail_zero_dimensions() {
932        let src = vec![255u8; 16];
933        assert!(scale_thumbnail(&src, 0, 2, 4, 4).is_empty());
934        assert!(scale_thumbnail(&src, 2, 2, 0, 4).is_empty());
935    }
936
937    #[test]
938    fn test_scale_thumbnail_undersized_src() {
939        // Supply less data than expected → empty result
940        let src = vec![255u8; 4]; // only 1 pixel, but claiming 4x4
941        let dst = scale_thumbnail(&src, 4, 4, 2, 2);
942        assert!(dst.is_empty());
943    }
944
945    // ── SpriteSheet tests ─────────────────────────────────────────────────────
946
947    #[test]
948    fn test_sprite_sheet_from_thumbnails_empty() {
949        assert!(SpriteSheet::from_thumbnails(&[], 5).is_none());
950    }
951
952    #[test]
953    fn test_sprite_sheet_from_thumbnails_zero_cols() {
954        let thumb = Thumbnail::new(0, 160, 90, vec![0u8; 160 * 90 * 4]);
955        assert!(SpriteSheet::from_thumbnails(&[thumb], 0).is_none());
956    }
957
958    #[test]
959    fn test_sprite_sheet_single_thumbnail() {
960        let data = vec![200u8; 4 * 4 * 4]; // 4×4 RGBA
961        let thumb = Thumbnail::new(1000, 4, 4, data.clone());
962        let sheet = SpriteSheet::from_thumbnails(&[thumb], 1).expect("sheet ok");
963
964        assert_eq!(sheet.cols, 1);
965        assert_eq!(sheet.rows, 1);
966        assert_eq!(sheet.sheet_width, 4);
967        assert_eq!(sheet.sheet_height, 4);
968        assert_eq!(sheet.cell_count(), 1);
969        assert_eq!(sheet.timestamps_ms[0], 1000);
970    }
971
972    #[test]
973    fn test_sprite_sheet_four_thumbnails_two_cols() {
974        let data = vec![128u8; 2 * 2 * 4]; // 2×2 RGBA cells
975        let thumbs: Vec<Thumbnail> = (0..4)
976            .map(|i| Thumbnail::new(i * 1000, 2, 2, data.clone()))
977            .collect();
978
979        let sheet = SpriteSheet::from_thumbnails(&thumbs, 2).expect("sheet ok");
980
981        assert_eq!(sheet.cols, 2);
982        assert_eq!(sheet.rows, 2);
983        assert_eq!(sheet.sheet_width, 4); // 2 cols × 2 pixels
984        assert_eq!(sheet.sheet_height, 4); // 2 rows × 2 pixels
985        assert_eq!(sheet.cell_count(), 4);
986    }
987
988    #[test]
989    fn test_sprite_sheet_cell_origin() {
990        let data = vec![0u8; 10 * 10 * 4];
991        let thumbs: Vec<Thumbnail> = (0..6)
992            .map(|i| Thumbnail::new(i * 1000, 10, 10, data.clone()))
993            .collect();
994        let sheet = SpriteSheet::from_thumbnails(&thumbs, 3).expect("sheet ok");
995
996        // (col=0, row=0): (0,0)
997        assert_eq!(sheet.cell_origin(0), (0, 0));
998        // (col=1, row=0): (10, 0)
999        assert_eq!(sheet.cell_origin(1), (10, 0));
1000        // (col=0, row=1): (0, 10)
1001        assert_eq!(sheet.cell_origin(3), (0, 10));
1002        // (col=2, row=1): (20, 10)
1003        assert_eq!(sheet.cell_origin(5), (20, 10));
1004    }
1005
1006    #[test]
1007    fn test_sprite_sheet_vtt_basic() {
1008        let data = vec![0u8; 4 * 4 * 4];
1009        let thumbs = vec![
1010            Thumbnail::new(0, 4, 4, data.clone()),
1011            Thumbnail::new(10_000, 4, 4, data.clone()),
1012        ];
1013        let sheet = SpriteSheet::from_thumbnails(&thumbs, 2).expect("sheet ok");
1014        let vtt = sheet.to_vtt("https://cdn.example.com/sprites.jpg");
1015
1016        assert!(vtt.starts_with("WEBVTT\n\n"));
1017        assert!(vtt.contains("xywh=0,0,4,4")); // first cell at (0,0)
1018        assert!(vtt.contains("xywh=4,0,4,4")); // second cell at (4,0)
1019        assert!(vtt.contains("00:00:00.000 --> 00:00:10.000"));
1020        assert!(vtt.contains("00:00:10.000 --> 00:00:11.000"));
1021    }
1022
1023    #[test]
1024    fn test_format_vtt_time_basic() {
1025        assert_eq!(format_vtt_time(0), "00:00:00.000");
1026        assert_eq!(format_vtt_time(1_000), "00:00:01.000");
1027        assert_eq!(format_vtt_time(61_500), "00:01:01.500");
1028        assert_eq!(format_vtt_time(3_600_000), "01:00:00.000");
1029    }
1030
1031    #[test]
1032    fn test_format_vtt_time_millis() {
1033        assert_eq!(format_vtt_time(123), "00:00:00.123");
1034        assert_eq!(format_vtt_time(1_234), "00:00:01.234");
1035    }
1036
1037    #[test]
1038    fn test_sprite_sheet_config_default_web() {
1039        let cfg = SpriteSheetConfig::default_web();
1040        assert!(cfg.is_valid());
1041        assert_eq!(cfg.cell_width, 160);
1042        assert_eq!(cfg.cell_height, 90);
1043        assert_eq!(cfg.cols, 5);
1044        assert_eq!(cfg.count, 100);
1045    }
1046
1047    #[test]
1048    fn test_sprite_sheet_config_high_density() {
1049        let cfg = SpriteSheetConfig::high_density(200);
1050        assert!(cfg.is_valid());
1051        assert_eq!(cfg.count, 200);
1052        assert_eq!(cfg.cols, 10);
1053    }
1054
1055    #[test]
1056    fn test_sprite_sheet_config_timestamps() {
1057        let cfg = SpriteSheetConfig::default_web();
1058        // 100 seconds of content at 24fps
1059        let ts = cfg.compute_timestamps(100_000, 24.0);
1060        assert_eq!(ts.len(), cfg.count);
1061        // First timestamp should be at or near 0.
1062        assert!(ts[0] < 1000);
1063    }
1064
1065    #[test]
1066    fn test_sprite_sheet_byte_len() {
1067        let data = vec![255u8; 4 * 4 * 4];
1068        let thumbs = vec![Thumbnail::new(0, 4, 4, data)];
1069        let sheet = SpriteSheet::from_thumbnails(&thumbs, 1).expect("sheet ok");
1070        // 4×4 RGBA = 64 bytes
1071        assert_eq!(sheet.byte_len(), 4 * 4 * 4);
1072    }
1073
1074    #[test]
1075    fn test_sprite_sheet_pixel_composition() {
1076        // Red cell at (0,0), Blue cell at (1,0).
1077        let red = vec![
1078            255u8, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255,
1079        ]; // 2×2 red RGBA
1080        let blue = vec![
1081            0u8, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255,
1082        ]; // 2×2 blue RGBA
1083
1084        let thumbs = vec![
1085            Thumbnail::new(0, 2, 2, red.clone()),
1086            Thumbnail::new(1000, 2, 2, blue.clone()),
1087        ];
1088        let sheet = SpriteSheet::from_thumbnails(&thumbs, 2).expect("sheet ok");
1089        assert_eq!(sheet.sheet_width, 4);
1090        assert_eq!(sheet.sheet_height, 2);
1091
1092        // First pixel (0,0) should be red
1093        assert_eq!(sheet.data[0], 255, "R channel at (0,0)");
1094        assert_eq!(sheet.data[1], 0, "G channel at (0,0)");
1095        assert_eq!(sheet.data[2], 0, "B channel at (0,0)");
1096
1097        // Pixel at (2,0) = first pixel of blue cell
1098        let blue_offset = 2 * 4; // x=2, y=0, 4 bytes per pixel, stride=sheet_width*4=16
1099        assert_eq!(sheet.data[blue_offset], 0, "R channel at (2,0)");
1100        assert_eq!(sheet.data[blue_offset + 2], 255, "B channel at (2,0)");
1101    }
1102
1103    // ── Smart thumbnail selection tests ──────────────────────────────────────
1104
1105    #[test]
1106    fn test_thumbnail_variance_flat_image() {
1107        // All same colour → variance near zero
1108        let data = vec![128u8, 128, 128, 255].repeat(4); // 2×2
1109        let thumb = Thumbnail::new(0, 2, 2, data);
1110        let v = thumbnail_variance(&thumb);
1111        assert!(v.abs() < 1.0);
1112    }
1113
1114    #[test]
1115    fn test_thumbnail_variance_high_contrast() {
1116        // Black and white checkerboard → high variance
1117        let mut data = Vec::with_capacity(4 * 4);
1118        data.extend_from_slice(&[0, 0, 0, 255]); // black
1119        data.extend_from_slice(&[255, 255, 255, 255]); // white
1120        data.extend_from_slice(&[255, 255, 255, 255]); // white
1121        data.extend_from_slice(&[0, 0, 0, 255]); // black
1122        let thumb = Thumbnail::new(0, 2, 2, data);
1123        let v = thumbnail_variance(&thumb);
1124        assert!(v > 1000.0);
1125    }
1126
1127    #[test]
1128    fn test_thumbnail_variance_empty() {
1129        let thumb = Thumbnail::new(0, 0, 0, Vec::new());
1130        assert!((thumbnail_variance(&thumb)).abs() < 1e-6);
1131    }
1132
1133    #[test]
1134    fn test_select_smart_thumbnails_empty() {
1135        assert!(select_smart_thumbnails(&[], 5).is_empty());
1136    }
1137
1138    #[test]
1139    fn test_select_smart_thumbnails_picks_interesting() {
1140        let flat = vec![128u8, 128, 128, 255].repeat(4); // low variance
1141        let high_contrast = {
1142            let mut d = Vec::with_capacity(16);
1143            d.extend_from_slice(&[0, 0, 0, 255]);
1144            d.extend_from_slice(&[255, 255, 255, 255]);
1145            d.extend_from_slice(&[255, 255, 255, 255]);
1146            d.extend_from_slice(&[0, 0, 0, 255]);
1147            d
1148        };
1149
1150        let thumbs = vec![
1151            Thumbnail::new(0, 2, 2, flat.clone()),
1152            Thumbnail::new(1000, 2, 2, high_contrast),
1153            Thumbnail::new(2000, 2, 2, flat),
1154        ];
1155
1156        let selected = select_smart_thumbnails(&thumbs, 1);
1157        assert_eq!(selected.len(), 1);
1158        assert_eq!(selected[0], 1); // The high-contrast one
1159    }
1160
1161    #[test]
1162    fn test_select_smart_thumbnails_count_capped() {
1163        let data = vec![128u8, 128, 128, 255].repeat(4);
1164        let thumbs: Vec<Thumbnail> = (0..3)
1165            .map(|i| Thumbnail::new(i * 1000, 2, 2, data.clone()))
1166            .collect();
1167
1168        let selected = select_smart_thumbnails(&thumbs, 10);
1169        assert_eq!(selected.len(), 3); // Can't pick more than available
1170    }
1171
1172    // ── Animated thumbnail tests ─────────────────────────────────────────────
1173
1174    #[test]
1175    fn test_animated_thumbnail_empty() {
1176        assert!(AnimatedThumbnail::from_thumbnails(&[], 100, 0).is_none());
1177    }
1178
1179    #[test]
1180    fn test_animated_thumbnail_zero_duration() {
1181        let thumb = Thumbnail::new(0, 2, 2, vec![0u8; 16]);
1182        assert!(AnimatedThumbnail::from_thumbnails(&[thumb], 0, 0).is_none());
1183    }
1184
1185    #[test]
1186    fn test_animated_thumbnail_basic() {
1187        let data = vec![128u8; 4 * 4 * 4]; // 4×4 RGBA
1188        let thumbs: Vec<Thumbnail> = (0..3)
1189            .map(|i| Thumbnail::new(i * 1000, 4, 4, data.clone()))
1190            .collect();
1191
1192        let anim = AnimatedThumbnail::from_thumbnails(&thumbs, 200, 0)
1193            .expect("should create animated thumbnail");
1194        assert_eq!(anim.frame_count(), 3);
1195        assert_eq!(anim.total_duration_ms, 600);
1196        assert_eq!(anim.width, 4);
1197        assert_eq!(anim.height, 4);
1198        assert!(anim.total_byte_size() > 0);
1199    }
1200
1201    #[test]
1202    fn test_animated_thumbnail_smart_selection() {
1203        let flat = vec![128u8, 128, 128, 255].repeat(4);
1204        let interesting = {
1205            let mut d = Vec::with_capacity(16);
1206            d.extend_from_slice(&[0, 0, 0, 255]);
1207            d.extend_from_slice(&[255, 255, 255, 255]);
1208            d.extend_from_slice(&[200, 100, 50, 255]);
1209            d.extend_from_slice(&[50, 100, 200, 255]);
1210            d
1211        };
1212
1213        let thumbs = vec![
1214            Thumbnail::new(0, 2, 2, flat.clone()),
1215            Thumbnail::new(1000, 2, 2, interesting.clone()),
1216            Thumbnail::new(2000, 2, 2, flat.clone()),
1217            Thumbnail::new(3000, 2, 2, interesting),
1218            Thumbnail::new(4000, 2, 2, flat),
1219        ];
1220
1221        let anim =
1222            AnimatedThumbnail::from_smart_selection(&thumbs, 2, 300, 0).expect("should select");
1223        assert_eq!(anim.frame_count(), 2);
1224        assert_eq!(anim.total_duration_ms, 600);
1225    }
1226
1227    // ── Quality profile tests ────────────────────────────────────────────────
1228
1229    #[test]
1230    fn test_quality_profile_jpeg_quality() {
1231        assert!(
1232            ThumbnailQualityProfile::Low.jpeg_quality()
1233                < ThumbnailQualityProfile::Medium.jpeg_quality()
1234        );
1235        assert!(
1236            ThumbnailQualityProfile::Medium.jpeg_quality()
1237                < ThumbnailQualityProfile::High.jpeg_quality()
1238        );
1239    }
1240
1241    #[test]
1242    fn test_quality_profile_dimensions() {
1243        let (lw, lh) = ThumbnailQualityProfile::Low.dimensions();
1244        let (mw, mh) = ThumbnailQualityProfile::Medium.dimensions();
1245        let (hw, hh) = ThumbnailQualityProfile::High.dimensions();
1246        assert!(lw < mw);
1247        assert!(mw < hw);
1248        assert!(lh < mh);
1249        assert!(mh < hh);
1250    }
1251
1252    #[test]
1253    fn test_thumbnail_ext_config_from_profile() {
1254        let cfg = ThumbnailExtConfig::from_profile(ThumbnailQualityProfile::Medium, 50);
1255        assert_eq!(cfg.base.count, 50);
1256        assert_eq!(cfg.base.width, 240);
1257        assert!(cfg.generate_sprite_sheet);
1258        assert!(cfg.generate_vtt);
1259        assert!(!cfg.generate_animated);
1260    }
1261
1262    #[test]
1263    fn test_thumbnail_ext_config_with_animated() {
1264        let cfg = ThumbnailExtConfig::from_profile(ThumbnailQualityProfile::High, 100)
1265            .with_animated(10, 150);
1266        assert!(cfg.generate_animated);
1267        assert_eq!(cfg.animated_max_frames, 10);
1268        assert_eq!(cfg.animated_frame_duration_ms, 150);
1269    }
1270
1271    #[test]
1272    fn test_thumbnail_ext_config_without_sprite() {
1273        let cfg = ThumbnailExtConfig::from_profile(ThumbnailQualityProfile::Low, 20)
1274            .without_sprite_sheet();
1275        assert!(!cfg.generate_sprite_sheet);
1276        assert!(!cfg.generate_vtt);
1277    }
1278
1279    // ── generate_vtt_track tests ─────────────────────────────────────────────
1280
1281    #[test]
1282    fn test_generate_vtt_track_basic() {
1283        let data = vec![0u8; 4 * 4 * 4];
1284        let thumbs = vec![
1285            Thumbnail::new(0, 4, 4, data.clone()),
1286            Thumbnail::new(5_000, 4, 4, data),
1287        ];
1288        let sheet = SpriteSheet::from_thumbnails(&thumbs, 2).expect("sheet ok");
1289        let vtt = generate_vtt_track(&sheet, "sprites.jpg", 10_000);
1290
1291        assert!(vtt.starts_with("WEBVTT"));
1292        assert!(vtt.contains("00:00:00.000 --> 00:00:05.000"));
1293        assert!(vtt.contains("00:00:05.000 --> 00:00:10.000"));
1294        assert!(vtt.contains("xywh=0,0,4,4"));
1295        assert!(vtt.contains("xywh=4,0,4,4"));
1296    }
1297
1298    #[test]
1299    fn test_generate_vtt_track_single_thumb() {
1300        let data = vec![0u8; 4 * 4 * 4];
1301        let thumbs = vec![Thumbnail::new(0, 4, 4, data)];
1302        let sheet = SpriteSheet::from_thumbnails(&thumbs, 1).expect("sheet ok");
1303        let vtt = generate_vtt_track(&sheet, "s.jpg", 60_000);
1304
1305        assert!(vtt.contains("00:00:00.000 --> 00:01:00.000"));
1306    }
1307}