feat: thumbnail generator plugin with configurable size/format
- ThumbnailGeneratorPort in domain (bytes + config → resized bytes) - adapters-thumbnail: ImageThumbnailGenerator using image crate - ThumbnailGeneratorPlugin reads width/height/format/profile from step config - PostgresDerivativeRepository + 012_derivatives migration - Seeded in extract_metadata pipeline as step 2 (300x300 webp) - Standalone generate_derivative pipeline for on-demand use
This commit is contained in:
9
crates/adapters/thumbnail/Cargo.toml
Normal file
9
crates/adapters/thumbnail/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "adapters-thumbnail"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
image = "0.25"
|
||||
57
crates/adapters/thumbnail/src/lib.rs
Normal file
57
crates/adapters/thumbnail/src/lib.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use bytes::Bytes;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{ThumbnailGeneratorPort, ThumbnailOutput},
|
||||
};
|
||||
use image::{DynamicImage, ImageFormat, load_from_memory};
|
||||
use std::io::Cursor;
|
||||
|
||||
pub struct ImageThumbnailGenerator;
|
||||
|
||||
impl ThumbnailGeneratorPort for ImageThumbnailGenerator {
|
||||
fn generate(
|
||||
&self,
|
||||
source: &Bytes,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: &str,
|
||||
) -> Result<ThumbnailOutput, DomainError> {
|
||||
let img = load_from_memory(source)
|
||||
.map_err(|e| DomainError::Internal(format!("failed to decode image: {e}")))?;
|
||||
|
||||
let thumb = img.thumbnail(width, height);
|
||||
let (img_format, mime) = parse_format(format)?;
|
||||
|
||||
let encoded = encode(&thumb, img_format)?;
|
||||
let actual_width = thumb.width();
|
||||
let actual_height = thumb.height();
|
||||
|
||||
Ok(ThumbnailOutput {
|
||||
bytes: Bytes::from(encoded),
|
||||
width: actual_width,
|
||||
height: actual_height,
|
||||
mime_type: mime.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_format(s: &str) -> Result<(ImageFormat, &'static str), DomainError> {
|
||||
match s {
|
||||
"jpeg" | "jpg" => Ok((ImageFormat::Jpeg, "image/jpeg")),
|
||||
"webp" => Ok((ImageFormat::WebP, "image/webp")),
|
||||
"png" => Ok((ImageFormat::Png, "image/png")),
|
||||
other => Err(DomainError::Validation(format!(
|
||||
"unsupported thumbnail format: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(img: &DynamicImage, format: ImageFormat) -> Result<Vec<u8>, DomainError> {
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, format)
|
||||
.map_err(|e| DomainError::Internal(format!("failed to encode thumbnail: {e}")))?;
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
66
crates/adapters/thumbnail/src/tests.rs
Normal file
66
crates/adapters/thumbnail/src/tests.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::ImageThumbnailGenerator;
|
||||
use bytes::Bytes;
|
||||
use domain::ports::ThumbnailGeneratorPort as _;
|
||||
|
||||
fn make_test_png() -> Bytes {
|
||||
use image::{ImageFormat, RgbImage};
|
||||
use std::io::Cursor;
|
||||
|
||||
let img = RgbImage::new(100, 200);
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, ImageFormat::Png).unwrap();
|
||||
Bytes::from(buf.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_jpeg_thumbnail() {
|
||||
let generator = ImageThumbnailGenerator;
|
||||
let source = make_test_png();
|
||||
|
||||
let out = generator.generate(&source, 50, 50, "jpeg").unwrap();
|
||||
|
||||
assert!(out.width <= 50);
|
||||
assert!(out.height <= 50);
|
||||
assert_eq!(out.mime_type, "image/jpeg");
|
||||
assert!(!out.bytes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_webp_thumbnail() {
|
||||
let generator = ImageThumbnailGenerator;
|
||||
let source = make_test_png();
|
||||
|
||||
let out = generator.generate(&source, 30, 30, "webp").unwrap();
|
||||
|
||||
assert!(out.width <= 30);
|
||||
assert!(out.height <= 30);
|
||||
assert_eq!(out.mime_type, "image/webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_aspect_ratio() {
|
||||
let generator = ImageThumbnailGenerator;
|
||||
let source = make_test_png(); // 100x200
|
||||
|
||||
let out = generator.generate(&source, 50, 50, "png").unwrap();
|
||||
|
||||
// 100x200 → fits in 50x50 → 25x50
|
||||
assert_eq!(out.width, 25);
|
||||
assert_eq!(out.height, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_format() {
|
||||
let generator = ImageThumbnailGenerator;
|
||||
let source = make_test_png();
|
||||
|
||||
let result = generator.generate(&source, 50, 50, "bmp");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_input() {
|
||||
let generator = ImageThumbnailGenerator;
|
||||
let result = generator.generate(&Bytes::from_static(b"not an image"), 50, 50, "jpeg");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user