Files
xevion.dev/src/media_processing.rs

227 lines
6.7 KiB
Rust

//! Image processing utilities for media uploads.
//!
//! Handles resizing, format conversion, and blurhash generation.
use image::{DynamicImage, GenericImageView, ImageFormat, imageops::FilterType};
use std::io::Cursor;
/// Target widths for image variants
pub const THUMB_WIDTH: u32 = 300;
pub const MEDIUM_WIDTH: u32 = 800;
pub const FULL_WIDTH: u32 = 1600;
/// Result of processing an uploaded image
#[derive(Debug)]
pub struct ProcessedImage {
pub thumb: ImageVariant,
pub medium: ImageVariant,
pub full: ImageVariant,
pub original: OriginalImage,
pub blurhash: String,
}
/// A processed image variant
#[derive(Debug)]
pub struct ImageVariant {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
}
/// Original image info
#[derive(Debug)]
pub struct OriginalImage {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
pub mime: String,
}
/// Errors that can occur during image processing
#[derive(Debug, thiserror::Error)]
pub enum ProcessingError {
#[error("Failed to decode image: {0}")]
DecodeError(String),
#[error("Failed to encode image: {0}")]
EncodeError(String),
#[error("Unsupported image format: {0}")]
UnsupportedFormat(String),
#[error("Image too small: minimum {min}px, got {actual}px")]
TooSmall { min: u32, actual: u32 },
#[error("BlurHash generation failed: {0}")]
BlurHashError(String),
}
/// Detect image format from bytes and filename
pub fn detect_format(
data: &[u8],
filename: &str,
) -> Result<(ImageFormat, &'static str), ProcessingError> {
// Try to detect from magic bytes first
if let Ok(format) = image::guess_format(data) {
let mime = match format {
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Png => "image/png",
ImageFormat::Gif => "image/gif",
ImageFormat::WebP => "image/webp",
ImageFormat::Avif => "image/avif",
_ => return Err(ProcessingError::UnsupportedFormat(format!("{format:?}"))),
};
return Ok((format, mime));
}
// Fall back to extension
let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => Ok((ImageFormat::Jpeg, "image/jpeg")),
"png" => Ok((ImageFormat::Png, "image/png")),
"gif" => Ok((ImageFormat::Gif, "image/gif")),
"webp" => Ok((ImageFormat::WebP, "image/webp")),
"avif" => Ok((ImageFormat::Avif, "image/avif")),
_ => Err(ProcessingError::UnsupportedFormat(ext)),
}
}
/// Process an uploaded image into all required variants
pub fn process_image(data: &[u8], filename: &str) -> Result<ProcessedImage, ProcessingError> {
// Detect format and decode
let (format, mime) = detect_format(data, filename)?;
let img = image::load_from_memory_with_format(data, format)
.map_err(|e| ProcessingError::DecodeError(e.to_string()))?;
let (orig_width, orig_height) = img.dimensions();
// Minimum size check - at least thumbnail size
let min_dim = orig_width.min(orig_height);
if min_dim < THUMB_WIDTH {
return Err(ProcessingError::TooSmall {
min: THUMB_WIDTH,
actual: min_dim,
});
}
// Generate variants (only resize if larger than target)
let thumb = resize_to_webp(&img, THUMB_WIDTH)?;
let medium = resize_to_webp(&img, MEDIUM_WIDTH)?;
let full = resize_to_webp(&img, FULL_WIDTH)?;
// Generate blurhash from thumbnail for efficiency
let blurhash = generate_blurhash(&img, 4, 3)?;
// Keep original as-is (preserve format)
let original = OriginalImage {
data: data.to_vec(),
width: orig_width,
height: orig_height,
mime: mime.to_string(),
};
Ok(ProcessedImage {
thumb,
medium,
full,
original,
blurhash,
})
}
/// Resize image to target width (maintaining aspect ratio) and encode as WebP
fn resize_to_webp(img: &DynamicImage, target_width: u32) -> Result<ImageVariant, ProcessingError> {
let (orig_width, orig_height) = img.dimensions();
// Only resize if larger than target
let (resized, width, height) = if orig_width > target_width {
let ratio = target_width as f64 / orig_width as f64;
let target_height = (orig_height as f64 * ratio).round() as u32;
let resized = img.resize(target_width, target_height, FilterType::Lanczos3);
(resized, target_width, target_height)
} else {
(img.clone(), orig_width, orig_height)
};
// Encode as WebP
let mut buf = Cursor::new(Vec::new());
resized
.write_to(&mut buf, ImageFormat::WebP)
.map_err(|e| ProcessingError::EncodeError(e.to_string()))?;
Ok(ImageVariant {
data: buf.into_inner(),
width,
height,
})
}
/// Generate a BlurHash string from an image
fn generate_blurhash(
img: &DynamicImage,
x_components: u32,
y_components: u32,
) -> Result<String, ProcessingError> {
// Resize to small size for efficient blurhash computation
let small = img.resize(32, 32, FilterType::Triangle);
let rgba = small.to_rgba8();
let (w, h) = rgba.dimensions();
let hash = blurhash::encode(x_components, y_components, w, h, rgba.as_raw())
.map_err(|e| ProcessingError::BlurHashError(format!("{e:?}")))?;
Ok(hash)
}
/// Check if a MIME type is a supported image format
pub fn is_supported_image(mime: &str) -> bool {
matches!(
mime,
"image/jpeg" | "image/png" | "image/gif" | "image/webp" | "image/avif"
)
}
/// Check if a MIME type is a supported video format
pub fn is_supported_video(mime: &str) -> bool {
matches!(mime, "video/mp4" | "video/webm" | "video/quicktime")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_format_jpeg() {
// JPEG magic bytes
let data = [0xFF, 0xD8, 0xFF, 0xE0];
let (format, mime) = detect_format(&data, "test.jpg").unwrap();
assert_eq!(format, ImageFormat::Jpeg);
assert_eq!(mime, "image/jpeg");
}
#[test]
fn test_detect_format_png() {
// PNG magic bytes
let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let (format, mime) = detect_format(&data, "test.png").unwrap();
assert_eq!(format, ImageFormat::Png);
assert_eq!(mime, "image/png");
}
#[test]
fn test_is_supported_image() {
assert!(is_supported_image("image/jpeg"));
assert!(is_supported_image("image/png"));
assert!(!is_supported_image("text/plain"));
assert!(!is_supported_image("video/mp4"));
}
#[test]
fn test_is_supported_video() {
assert!(is_supported_video("video/mp4"));
assert!(is_supported_video("video/webm"));
assert!(!is_supported_video("image/jpeg"));
}
}