use image::{ImageDecoder as _, ImageReader};
use modkit_macros::domain_model;
use std::io::Cursor;
use crate::config::ThumbnailConfig;
#[domain_model]
pub struct Thumbnail {
pub bytes: Vec<u8>,
pub width: u32,
pub height: u32,
}
#[allow(clippy::cognitive_complexity)]
pub fn generate(cfg: &ThumbnailConfig, raw: &[u8]) -> Option<Thumbnail> {
if raw.len() > cfg.max_decode_bytes {
tracing::debug!(
raw_len = raw.len(),
max = cfg.max_decode_bytes,
"thumbnail skipped: compressed input exceeds max_decode_bytes"
);
return None;
}
let reader = ImageReader::new(Cursor::new(raw))
.with_guessed_format()
.ok()?;
let (w, h) = match reader.into_dimensions() {
Ok(dims) => dims,
Err(e) => {
tracing::debug!(error = %e, "thumbnail skipped: cannot read dimensions");
return None;
}
};
if u64::from(w) * u64::from(h) > cfg.max_pixels {
tracing::debug!(
pixels = u64::from(w) * u64::from(h),
max = cfg.max_pixels,
"thumbnail skipped: source exceeds max_pixels"
);
return None;
}
let decoded_estimate = u64::from(w) * u64::from(h) * 4;
if decoded_estimate > cfg.max_decode_bytes as u64 {
tracing::debug!(
decoded_estimate,
max = cfg.max_decode_bytes,
"thumbnail skipped: estimated decoded size exceeds max_decode_bytes"
);
return None;
}
let reader = ImageReader::new(Cursor::new(raw))
.with_guessed_format()
.ok()?;
let mut reader = reader;
let mut limits = image::Limits::default();
limits.max_alloc = Some(cfg.max_decode_bytes as u64);
reader.limits(limits);
let mut decoder = match reader.into_decoder() {
Ok(d) => d,
Err(e) => {
tracing::debug!(error = %e, "thumbnail skipped: decoder creation failed");
return None;
}
};
let orientation = decoder
.orientation()
.unwrap_or(image::metadata::Orientation::NoTransforms);
let mut img: image::DynamicImage = match image::DynamicImage::from_decoder(decoder) {
Ok(img) => img,
Err(e) => {
tracing::debug!(error = %e, "thumbnail skipped: decode failed");
return None;
}
};
img.apply_orientation(orientation);
let resized = img.thumbnail(cfg.width, cfg.height);
let tw = resized.width();
let th = resized.height();
let mut buf = Cursor::new(Vec::new());
if let Err(e) = resized.write_to(&mut buf, image::ImageFormat::WebP) {
tracing::debug!(error = %e, "thumbnail skipped: WebP encode failed");
return None;
}
let webp_bytes = buf.into_inner();
if webp_bytes.len() > cfg.max_bytes {
tracing::debug!(
encoded_len = webp_bytes.len(),
max = cfg.max_bytes,
"thumbnail skipped: encoded size exceeds max_bytes"
);
return None;
}
Some(Thumbnail {
bytes: webp_bytes,
width: tw,
height: th,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> ThumbnailConfig {
ThumbnailConfig::default()
}
#[test]
fn generates_thumbnail_from_valid_png() {
let mut buf = Cursor::new(Vec::new());
let img = image::RgbImage::from_pixel(2, 2, image::Rgb([255, 0, 0]));
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let result = generate(&test_config(), buf.get_ref());
assert!(result.is_some());
let thumb = result.unwrap();
assert!(thumb.width <= 128);
assert!(thumb.height <= 128);
assert!(!thumb.bytes.is_empty());
assert!(thumb.bytes.len() >= 12, "WebP output too short");
assert_eq!(&thumb.bytes[0..4], b"RIFF", "missing RIFF header");
assert_eq!(&thumb.bytes[8..12], b"WEBP", "missing WEBP signature");
}
#[test]
fn returns_none_for_corrupt_data() {
let result = generate(&test_config(), b"not an image");
assert!(result.is_none());
}
#[test]
fn returns_none_when_source_exceeds_decode_limit() {
let mut cfg = test_config();
cfg.max_decode_bytes = 10; let mut buf = Cursor::new(Vec::new());
let img = image::RgbImage::from_pixel(2, 2, image::Rgb([0, 0, 0]));
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let result = generate(&cfg, buf.get_ref());
assert!(result.is_none());
}
#[test]
fn respects_max_pixels_limit() {
let mut cfg = test_config();
cfg.max_pixels = 1; let mut buf = Cursor::new(Vec::new());
let img = image::RgbImage::from_pixel(2, 2, image::Rgb([0, 0, 0]));
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let result = generate(&cfg, buf.get_ref());
assert!(result.is_none());
}
#[test]
fn returns_none_when_encoded_output_exceeds_max_bytes() {
let mut cfg = test_config();
cfg.max_bytes = 1; let mut buf = Cursor::new(Vec::new());
let img = image::RgbImage::from_pixel(2, 2, image::Rgb([0, 0, 0]));
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let result = generate(&cfg, buf.get_ref());
assert!(result.is_none());
}
#[test]
fn resizes_large_image() {
let mut buf = Cursor::new(Vec::new());
let img = image::RgbImage::from_pixel(1000, 500, image::Rgb([0, 128, 255]));
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let result = generate(&test_config(), buf.get_ref());
assert!(result.is_some());
let thumb = result.unwrap();
assert_eq!(thumb.width, 128);
assert_eq!(thumb.height, 64);
}
}