Files
RuDS/crates/bds-core/src/util/thumbnail.rs

330 lines
11 KiB
Rust

use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView, ImageFormat, ImageReader};
use std::fs;
use std::io::Cursor;
use std::path::Path;
/// Thumbnail size configuration.
pub struct ThumbnailSize {
pub name: &'static str,
pub width: u32,
pub height: u32,
pub format: ThumbnailFormat,
pub fit: ThumbnailFit,
}
#[derive(Clone, Copy, PartialEq)]
pub enum ThumbnailFormat {
Webp,
Jpeg,
}
#[derive(Clone, Copy, PartialEq)]
pub enum ThumbnailFit {
/// Scale to fit inside, no enlargement.
Inside,
/// Scale to fill, crop center (cover).
Cover,
/// Scale to fit, letterbox with black background.
Contain,
}
/// Standard thumbnail sizes matching spec: small/medium/large are
/// width-constrained aspect-preserving; AI is letterboxed on black.
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
ThumbnailSize { name: "small", width: 150, height: 150, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "medium", width: 400, height: 400, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "large", width: 800, height: 800, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain },
];
/// Generate a single thumbnail from a source image file.
pub fn generate_thumbnail(
source: &Path,
dest: &Path,
size: &ThumbnailSize,
quality: u8,
) -> Result<(), String> {
let img = load_and_orient(source)?;
let resized = match size.fit {
ThumbnailFit::Inside => {
let (orig_w, orig_h) = img.dimensions();
// Don't enlarge
if orig_w <= size.width && orig_h <= size.height {
img.clone()
} else {
img.resize(size.width, size.height, FilterType::Lanczos3)
}
}
ThumbnailFit::Cover => {
img.resize_to_fill(size.width, size.height, FilterType::Lanczos3)
}
ThumbnailFit::Contain => {
// Resize to fit, then place on black background
let resized = img.resize(size.width, size.height, FilterType::Lanczos3);
let (rw, rh) = resized.dimensions();
let mut canvas = DynamicImage::new_rgb8(size.width, size.height);
let offset_x = (size.width - rw) / 2;
let offset_y = (size.height - rh) / 2;
image::imageops::overlay(&mut canvas, &resized, offset_x as i64, offset_y as i64);
canvas
}
};
// Ensure parent directory exists
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
}
match size.format {
ThumbnailFormat::Webp => {
// Encode as WebP via image crate
let mut buf = Cursor::new(Vec::new());
resized
.write_to(&mut buf, ImageFormat::WebP)
.map_err(|e| format!("WebP encode: {e}"))?;
let _ = quality; // image crate WebP encoder doesn't expose quality directly
fs::write(dest, buf.into_inner()).map_err(|e| format!("write: {e}"))?;
}
ThumbnailFormat::Jpeg => {
let rgb = resized.to_rgb8();
let mut buf = Cursor::new(Vec::new());
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
encoder
.encode_image(&rgb)
.map_err(|e| format!("JPEG encode: {e}"))?;
fs::write(dest, buf.into_inner()).map_err(|e| format!("write: {e}"))?;
}
}
Ok(())
}
/// Generate all standard thumbnails for a media item.
pub fn generate_all_thumbnails(
source: &Path,
thumbnails_dir: &Path,
media_id: &str,
) -> Result<Vec<String>, String> {
let mut paths = Vec::new();
let prefix = &media_id[..2.min(media_id.len())];
for size in THUMBNAIL_SIZES {
let ext = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
let dest = thumbnails_dir
.join(prefix)
.join(format!("{media_id}-{}.{ext}", size.name));
let quality = if size.format == ThumbnailFormat::Jpeg { 85 } else { 80 };
generate_thumbnail(source, &dest, size, quality)?;
paths.push(dest.to_string_lossy().to_string());
}
Ok(paths)
}
/// Load an image and apply EXIF orientation.
fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
let reader = ImageReader::open(path)
.map_err(|e| format!("open image: {e}"))?
.with_guessed_format()
.map_err(|e| format!("guess format: {e}"))?;
let mut img = reader.decode().map_err(|e| format!("decode image: {e}"))?;
// Try to read EXIF orientation from JPEG files
if let Ok(data) = fs::read(path) {
if let Some(orientation) = read_exif_orientation(&data) {
img = apply_orientation(img, orientation);
}
}
Ok(img)
}
/// Read EXIF orientation tag from raw file bytes.
/// Returns orientation value 1-8, or None if not found.
fn read_exif_orientation(data: &[u8]) -> Option<u16> {
if data.len() < 12 {
return None;
}
// Must be JPEG: FF D8
if data[0] != 0xFF || data[1] != 0xD8 {
return None;
}
let mut pos = 2;
while pos + 4 < data.len() {
if data[pos] != 0xFF {
break;
}
let marker = data[pos + 1];
let len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize;
if marker == 0xE1 && pos + 10 < data.len() {
// Check for "Exif\0\0"
if &data[pos + 4..pos + 10] == b"Exif\0\0" {
let tiff_start = pos + 10;
return parse_tiff_orientation(data, tiff_start);
}
}
pos += 2 + len;
}
None
}
fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
if tiff_start + 8 > data.len() {
return None;
}
let is_le = data[tiff_start] == b'I' && data[tiff_start + 1] == b'I';
let read_u16 = |offset: usize| -> Option<u16> {
if offset + 2 > data.len() { return None; }
if is_le {
Some(u16::from_le_bytes([data[offset], data[offset + 1]]))
} else {
Some(u16::from_be_bytes([data[offset], data[offset + 1]]))
}
};
let read_u32 = |offset: usize| -> Option<u32> {
if offset + 4 > data.len() { return None; }
if is_le {
Some(u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]))
} else {
Some(u32::from_be_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]))
}
};
let ifd_offset = read_u32(tiff_start + 4)? as usize;
let ifd_pos = tiff_start + ifd_offset;
let entry_count = read_u16(ifd_pos)? as usize;
for i in 0..entry_count {
let entry_pos = ifd_pos + 2 + i * 12;
if entry_pos + 12 > data.len() { break; }
let tag = read_u16(entry_pos)?;
if tag == 0x0112 {
// Orientation tag
return read_u16(entry_pos + 8);
}
}
None
}
fn apply_orientation(img: DynamicImage, orientation: u16) -> DynamicImage {
match orientation {
1 => img, // Normal
2 => img.fliph(), // Mirrored horizontal
3 => img.rotate180(), // Rotated 180
4 => img.flipv(), // Mirrored vertical
5 => img.rotate90().fliph(), // Mirrored horizontal + 270 CW
6 => img.rotate90(), // Rotated 90 CW
7 => img.rotate270().fliph(), // Mirrored horizontal + 90 CW
8 => img.rotate270(), // Rotated 270 CW
_ => img,
}
}
/// Extract image dimensions from a file (header-only, no full decode).
pub fn image_dimensions(path: &Path) -> Result<(u32, u32), String> {
let reader = ImageReader::open(path)
.map_err(|e| format!("open: {e}"))?
.with_guessed_format()
.map_err(|e| format!("format: {e}"))?;
let (w, h) = reader
.into_dimensions()
.map_err(|e| format!("dimensions: {e}"))?;
Ok((w, h))
}
/// Detect MIME type from file extension.
pub fn mime_from_extension(ext: &str) -> &'static str {
match ext.to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"tiff" | "tif" => "image/tiff",
"bmp" => "image/bmp",
"heic" => "image/heic",
"heif" => "image/heif",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_png(dir: &Path) -> std::path::PathBuf {
let path = dir.join("test.png");
// Create a small 100x80 red PNG
let img = DynamicImage::new_rgb8(100, 80);
img.save(&path).unwrap();
path
}
#[test]
fn generate_small_thumbnail() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let dest = dir.path().join("thumb.webp");
let size = &THUMBNAIL_SIZES[0]; // small: 150 Inside
generate_thumbnail(&source, &dest, size, 80).unwrap();
assert!(dest.exists());
let (w, h) = image_dimensions(&dest).unwrap();
// 100x80 fits inside 150x150 without enlargement
assert_eq!(w, 100);
assert_eq!(h, 80);
}
#[test]
fn generate_ai_thumbnail_contain() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let dest = dir.path().join("thumb-ai.jpg");
let size = &THUMBNAIL_SIZES[3]; // ai: 448x448 contain
generate_thumbnail(&source, &dest, size, 85).unwrap();
assert!(dest.exists());
let (w, h) = image_dimensions(&dest).unwrap();
assert_eq!(w, 448);
assert_eq!(h, 448);
}
#[test]
fn generate_all() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let thumb_dir = dir.path().join("thumbnails");
let paths = generate_all_thumbnails(&source, &thumb_dir, "ab123456-test-uuid").unwrap();
assert_eq!(paths.len(), 4); // 4 thumbnails (no source copy)
for p in &paths {
assert!(Path::new(p).exists(), "thumbnail missing: {p}");
}
}
#[test]
fn mime_detection() {
assert_eq!(mime_from_extension("jpg"), "image/jpeg");
assert_eq!(mime_from_extension("PNG"), "image/png");
assert_eq!(mime_from_extension("webp"), "image/webp");
assert_eq!(mime_from_extension("xyz"), "application/octet-stream");
}
#[test]
fn dimensions_extraction() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let (w, h) = image_dimensions(&source).unwrap();
assert_eq!(w, 100);
assert_eq!(h, 80);
}
}