#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_sign_loss)]
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ThumbnailError {
#[error("source dimensions {width}x{height} are invalid (must be > 0)")]
InvalidSourceDimensions {
width: u32,
height: u32,
},
#[error("thumbnail target size {width}x{height} is invalid (must be > 0)")]
InvalidTargetSize {
width: u32,
height: u32,
},
#[error("buffer length {actual} does not match {width}x{height}x{channels}")]
BufferMismatch {
actual: usize,
width: u32,
height: u32,
channels: u32,
},
#[error("video duration must be positive for time-based sampling")]
ZeroDuration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum QualityPreset {
Draft,
Standard,
High,
Lossless,
}
impl QualityPreset {
pub fn quality_value(self) -> u8 {
match self {
Self::Draft => 50,
Self::Standard => 75,
Self::High => 90,
Self::Lossless => 100,
}
}
}
impl Default for QualityPreset {
fn default() -> Self {
Self::Standard
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormatHint {
Jpeg,
WebP,
Png,
Raw,
}
impl Default for FormatHint {
fn default() -> Self {
Self::Jpeg
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThumbScaleAlgorithm {
NearestNeighbor,
Bilinear,
}
impl Default for ThumbScaleAlgorithm {
fn default() -> Self {
Self::Bilinear
}
}
#[derive(Debug, Clone)]
pub struct ThumbSize {
pub max_width: u32,
pub max_height: u32,
pub preset: QualityPreset,
pub label: Option<String>,
}
impl ThumbSize {
pub fn new(max_width: u32, max_height: u32) -> Self {
Self {
max_width,
max_height,
preset: QualityPreset::default(),
label: None,
}
}
pub fn with_preset(mut self, preset: QualityPreset) -> Self {
self.preset = preset;
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn compute_dimensions(&self, src_w: u32, src_h: u32) -> (u32, u32) {
if src_w == 0 || src_h == 0 || self.max_width == 0 || self.max_height == 0 {
return (0, 0);
}
let scale_w = self.max_width as f64 / src_w as f64;
let scale_h = self.max_height as f64 / src_h as f64;
let scale = scale_w.min(scale_h).min(1.0); let out_w = ((src_w as f64 * scale).round() as u32).max(1);
let out_h = ((src_h as f64 * scale).round() as u32).max(1);
(out_w, out_h)
}
}
#[derive(Debug, Clone)]
pub struct GeneratedThumbnail {
pub width: u32,
pub height: u32,
pub data: Vec<u8>,
pub preset: QualityPreset,
pub format: FormatHint,
pub label: Option<String>,
}
impl GeneratedThumbnail {
pub fn pixel_count(&self) -> usize {
self.width as usize * self.height as usize
}
pub fn aspect_ratio(&self) -> f64 {
if self.height == 0 {
0.0
} else {
self.width as f64 / self.height as f64
}
}
}
#[derive(Debug, Clone)]
pub struct ThumbnailGeneratorConfig {
pub algorithm: ThumbScaleAlgorithm,
pub format: FormatHint,
pub channels: u32,
}
impl Default for ThumbnailGeneratorConfig {
fn default() -> Self {
Self {
algorithm: ThumbScaleAlgorithm::Bilinear,
format: FormatHint::Jpeg,
channels: 3,
}
}
}
impl ThumbnailGeneratorConfig {
pub fn new(channels: u32) -> Self {
Self {
channels,
..Default::default()
}
}
pub fn with_algorithm(mut self, algorithm: ThumbScaleAlgorithm) -> Self {
self.algorithm = algorithm;
self
}
pub fn with_format(mut self, format: FormatHint) -> Self {
self.format = format;
self
}
}
pub struct ThumbnailGenerator {
config: ThumbnailGeneratorConfig,
}
impl ThumbnailGenerator {
pub fn new(config: ThumbnailGeneratorConfig) -> Self {
Self { config }
}
pub fn generate(
&self,
pixels: &[u8],
src_w: u32,
src_h: u32,
size: &ThumbSize,
) -> Result<GeneratedThumbnail, ThumbnailError> {
self.validate_source(pixels, src_w, src_h)?;
if size.max_width == 0 || size.max_height == 0 {
return Err(ThumbnailError::InvalidTargetSize {
width: size.max_width,
height: size.max_height,
});
}
let (dst_w, dst_h) = size.compute_dimensions(src_w, src_h);
let data = self.scale_pixels(pixels, src_w, src_h, dst_w, dst_h);
Ok(GeneratedThumbnail {
width: dst_w,
height: dst_h,
data,
preset: size.preset,
format: self.config.format,
label: size.label.clone(),
})
}
pub fn generate_multi(
&self,
pixels: &[u8],
src_w: u32,
src_h: u32,
sizes: &[ThumbSize],
) -> Result<Vec<GeneratedThumbnail>, ThumbnailError> {
self.validate_source(pixels, src_w, src_h)?;
sizes
.iter()
.map(|s| self.generate(pixels, src_w, src_h, s))
.collect()
}
fn validate_source(&self, pixels: &[u8], src_w: u32, src_h: u32) -> Result<(), ThumbnailError> {
if src_w == 0 || src_h == 0 {
return Err(ThumbnailError::InvalidSourceDimensions {
width: src_w,
height: src_h,
});
}
let expected = (src_w * src_h * self.config.channels) as usize;
if pixels.len() != expected {
return Err(ThumbnailError::BufferMismatch {
actual: pixels.len(),
width: src_w,
height: src_h,
channels: self.config.channels,
});
}
Ok(())
}
fn scale_pixels(
&self,
pixels: &[u8],
src_w: u32,
src_h: u32,
dst_w: u32,
dst_h: u32,
) -> Vec<u8> {
let ch = self.config.channels as usize;
let mut out = vec![0u8; (dst_w * dst_h) as usize * ch];
for dy in 0..dst_h {
for dx in 0..dst_w {
let dst_idx = (dy * dst_w + dx) as usize * ch;
match self.config.algorithm {
ThumbScaleAlgorithm::NearestNeighbor => {
let sx = (dx * src_w / dst_w).min(src_w - 1);
let sy = (dy * src_h / dst_h).min(src_h - 1);
let src_idx = (sy * src_w + sx) as usize * ch;
out[dst_idx..dst_idx + ch].copy_from_slice(&pixels[src_idx..src_idx + ch]);
}
ThumbScaleAlgorithm::Bilinear => {
let src_x_f = (dx as f64 + 0.5) * (src_w as f64 / dst_w as f64) - 0.5;
let src_y_f = (dy as f64 + 0.5) * (src_h as f64 / dst_h as f64) - 0.5;
let sx0 = src_x_f.floor().clamp(0.0, (src_w - 1) as f64) as u32;
let sy0 = src_y_f.floor().clamp(0.0, (src_h - 1) as f64) as u32;
let sx1 = (sx0 + 1).min(src_w - 1);
let sy1 = (sy0 + 1).min(src_h - 1);
let tx = src_x_f - src_x_f.floor();
let ty = src_y_f - src_y_f.floor();
for c in 0..ch {
let p00 = pixels[((sy0 * src_w + sx0) as usize) * ch + c] as f64;
let p10 = pixels[((sy0 * src_w + sx1) as usize) * ch + c] as f64;
let p01 = pixels[((sy1 * src_w + sx0) as usize) * ch + c] as f64;
let p11 = pixels[((sy1 * src_w + sx1) as usize) * ch + c] as f64;
let v = p00 * (1.0 - tx) * (1.0 - ty)
+ p10 * tx * (1.0 - ty)
+ p01 * (1.0 - tx) * ty
+ p11 * tx * ty;
out[dst_idx + c] = v.round().clamp(0.0, 255.0) as u8;
}
}
}
}
}
out
}
}
pub fn sample_frame_numbers(total_frames: u64, count: usize) -> Vec<u64> {
if count == 0 || total_frames == 0 {
return Vec::new();
}
if count == 1 {
return vec![total_frames / 2];
}
(0..count)
.map(|i| {
let fraction = (i as f64 + 0.5) / count as f64;
((fraction * total_frames as f64) as u64).min(total_frames - 1)
})
.collect()
}
pub fn timestamp_to_frame(
timestamp_secs: f64,
fps_num: u32,
fps_den: u32,
) -> Result<u64, ThumbnailError> {
if fps_num == 0 || fps_den == 0 {
return Err(ThumbnailError::ZeroDuration);
}
let fps = fps_num as f64 / fps_den as f64;
Ok((timestamp_secs * fps).floor() as u64)
}
pub fn sample_timestamps(duration_secs: f64, count: usize) -> Result<Vec<f64>, ThumbnailError> {
if duration_secs <= 0.0 {
return Err(ThumbnailError::ZeroDuration);
}
if count == 0 {
return Ok(Vec::new());
}
Ok((0..count)
.map(|i| {
let fraction = (i as f64 + 0.5) / count as f64;
fraction * duration_secs
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_pixels(w: u32, h: u32, channels: u32) -> Vec<u8> {
(0..(w * h * channels) as usize)
.map(|i| (i % 256) as u8)
.collect()
}
#[test]
fn test_compute_dimensions_no_upscale() {
let s = ThumbSize::new(512, 512);
let (w, h) = s.compute_dimensions(100, 100);
assert_eq!((w, h), (100, 100));
}
#[test]
fn test_compute_dimensions_landscape() {
let s = ThumbSize::new(128, 128);
let (w, h) = s.compute_dimensions(320, 240);
assert_eq!(w, 128);
assert_eq!(h, 96); }
#[test]
fn test_compute_dimensions_zero_source() {
let s = ThumbSize::new(128, 128);
let (w, h) = s.compute_dimensions(0, 100);
assert_eq!((w, h), (0, 0));
}
#[test]
fn test_quality_preset_values() {
assert_eq!(QualityPreset::Draft.quality_value(), 50);
assert_eq!(QualityPreset::Standard.quality_value(), 75);
assert_eq!(QualityPreset::High.quality_value(), 90);
assert_eq!(QualityPreset::Lossless.quality_value(), 100);
}
#[test]
fn test_quality_preset_ordering() {
assert!(QualityPreset::Draft < QualityPreset::Standard);
assert!(QualityPreset::High > QualityPreset::Standard);
assert!(QualityPreset::Lossless > QualityPreset::High);
}
#[test]
fn test_generate_basic() {
let pixels = make_pixels(640, 480, 3);
let gen = ThumbnailGenerator::new(ThumbnailGeneratorConfig::new(3));
let size = ThumbSize::new(160, 120).with_label("small");
let thumb = gen.generate(&pixels, 640, 480, &size).unwrap();
assert_eq!(thumb.width, 160);
assert_eq!(thumb.height, 120);
assert_eq!(thumb.data.len(), 160 * 120 * 3);
assert_eq!(thumb.label.as_deref(), Some("small"));
}
#[test]
fn test_generate_zero_source_error() {
let gen = ThumbnailGenerator::new(ThumbnailGeneratorConfig::new(3));
let size = ThumbSize::new(160, 120);
let err = gen.generate(&[], 0, 480, &size).unwrap_err();
assert!(matches!(
err,
ThumbnailError::InvalidSourceDimensions { .. }
));
}
#[test]
fn test_generate_buffer_mismatch_error() {
let gen = ThumbnailGenerator::new(ThumbnailGeneratorConfig::new(3));
let size = ThumbSize::new(160, 120);
let err = gen.generate(&[0u8; 100], 640, 480, &size).unwrap_err();
assert!(matches!(err, ThumbnailError::BufferMismatch { .. }));
}
#[test]
fn test_generate_zero_target_error() {
let pixels = make_pixels(640, 480, 3);
let gen = ThumbnailGenerator::new(ThumbnailGeneratorConfig::new(3));
let size = ThumbSize::new(0, 120);
let err = gen.generate(&pixels, 640, 480, &size).unwrap_err();
assert!(matches!(err, ThumbnailError::InvalidTargetSize { .. }));
}
#[test]
fn test_generate_multi_count() {
let pixels = make_pixels(1920, 1080, 3);
let gen = ThumbnailGenerator::new(ThumbnailGeneratorConfig::new(3));
let sizes = vec![
ThumbSize::new(1280, 720),
ThumbSize::new(640, 360),
ThumbSize::new(320, 180),
];
let thumbs = gen.generate_multi(&pixels, 1920, 1080, &sizes).unwrap();
assert_eq!(thumbs.len(), 3);
}
#[test]
fn test_generate_multi_aspect_ratio_preserved() {
let pixels = make_pixels(1920, 1080, 3);
let gen = ThumbnailGenerator::new(ThumbnailGeneratorConfig::new(3));
let sizes = vec![ThumbSize::new(640, 640)]; let thumbs = gen.generate_multi(&pixels, 1920, 1080, &sizes).unwrap();
let t = &thumbs[0];
let ratio = t.aspect_ratio();
assert!(
(ratio - 1920.0 / 1080.0).abs() < 0.01,
"aspect_ratio={ratio}"
);
}
#[test]
fn test_nearest_neighbor_solid_color() {
let pixels = vec![200u8, 100u8, 50u8].repeat(64 * 64);
let cfg =
ThumbnailGeneratorConfig::new(3).with_algorithm(ThumbScaleAlgorithm::NearestNeighbor);
let gen = ThumbnailGenerator::new(cfg);
let size = ThumbSize::new(32, 32);
let thumb = gen.generate(&pixels, 64, 64, &size).unwrap();
for i in 0..32 * 32usize {
assert_eq!(thumb.data[i * 3], 200);
assert_eq!(thumb.data[i * 3 + 1], 100);
assert_eq!(thumb.data[i * 3 + 2], 50);
}
}
#[test]
fn test_sample_frame_numbers_basic() {
let frames = sample_frame_numbers(100, 4);
assert_eq!(frames.len(), 4);
for &f in &frames {
assert!(f < 100, "frame {f} out of bounds");
}
for w in frames.windows(2) {
assert!(w[0] <= w[1], "frames not sorted: {:?}", frames);
}
}
#[test]
fn test_sample_frame_numbers_zero_count() {
assert!(sample_frame_numbers(100, 0).is_empty());
}
#[test]
fn test_sample_frame_numbers_single() {
let frames = sample_frame_numbers(100, 1);
assert_eq!(frames, vec![50]);
}
#[test]
fn test_timestamp_to_frame_basic() {
let f = timestamp_to_frame(2.0, 30, 1).unwrap();
assert_eq!(f, 60);
}
#[test]
fn test_timestamp_to_frame_zero_fps_error() {
let err = timestamp_to_frame(1.0, 0, 1).unwrap_err();
assert!(matches!(err, ThumbnailError::ZeroDuration));
}
#[test]
fn test_sample_timestamps_basic() {
let ts = sample_timestamps(10.0, 4).unwrap();
assert_eq!(ts.len(), 4);
for &t in &ts {
assert!(t >= 0.0 && t < 10.0, "timestamp {t} out of range");
}
}
#[test]
fn test_sample_timestamps_zero_duration_error() {
let err = sample_timestamps(0.0, 4).unwrap_err();
assert!(matches!(err, ThumbnailError::ZeroDuration));
}
#[test]
fn test_sample_timestamps_zero_count() {
let ts = sample_timestamps(60.0, 0).unwrap();
assert!(ts.is_empty());
}
}