yggdrasil/src/webp.rs

202 lines
6.1 KiB
Rust

#[cfg(feature = "server")]
use std::sync::LazyLock;
#[cfg(feature = "server")]
#[derive(Debug)]
pub enum WebpError {
Encode(String),
Decode(String),
}
#[cfg(feature = "server")]
impl std::fmt::Display for WebpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WebpError::Encode(msg) => write!(f, "WebP encode error: {}", msg),
WebpError::Decode(msg) => write!(f, "WebP decode error: {}", msg),
}
}
}
#[cfg(feature = "server")]
impl std::error::Error for WebpError {}
#[cfg(feature = "server")]
#[derive(Debug, Clone)]
pub struct WebpConfig {
pub quality: f32,
pub method: u8,
}
#[cfg(feature = "server")]
pub static WEBP_CONFIG: LazyLock<WebpConfig> = LazyLock::new(|| {
let (quality, quality_clamped) = std::env::var("WEBP_QUALITY")
.ok()
.and_then(|s| s.parse::<f32>().ok())
.map(|q| {
let clamped = q.clamp(0.0, 100.0);
(clamped, clamped != q)
})
.unwrap_or((85.0, false));
if quality_clamped {
tracing::warn!(
"WEBP_QUALITY was clamped from {} to {} (valid range: 0.0-100.0)",
std::env::var("WEBP_QUALITY").unwrap_or_default(),
quality
);
}
let (method, method_clamped) = std::env::var("WEBP_METHOD")
.ok()
.and_then(|s| s.parse::<u8>().ok())
.map(|m| {
let clamped = m.clamp(0, 6);
(clamped, clamped != m)
})
.unwrap_or((2, false));
if method_clamped {
tracing::warn!(
"WEBP_METHOD was clamped from {} to {} (valid range: 0-6)",
std::env::var("WEBP_METHOD").unwrap_or_default(),
method
);
}
tracing::info!("WebP config loaded: quality={}, method={}", quality, method);
WebpConfig { quality, method }
});
#[cfg(feature = "server")]
pub fn encode(img: &image::DynamicImage, quality: f32, method: u8) -> Result<Vec<u8>, WebpError> {
use zenwebp::{EncodeRequest, LossyConfig, PixelLayout};
let (width, height) = (img.width(), img.height());
let config = LossyConfig::new().with_quality(quality).with_method(method);
fn do_encode(
config: &LossyConfig,
pixels: &[u8],
layout: zenwebp::PixelLayout,
width: u32,
height: u32,
) -> Result<Vec<u8>, WebpError> {
EncodeRequest::lossy(config, pixels, layout, width, height)
.encode()
.map_err(|e| WebpError::Encode(e.to_string()))
}
match img {
image::DynamicImage::ImageRgba8(rgba) => {
do_encode(&config, rgba.as_raw(), PixelLayout::Rgba8, width, height)
}
image::DynamicImage::ImageRgb8(rgb) => {
do_encode(&config, rgb.as_raw(), PixelLayout::Rgb8, width, height)
}
_ => {
// Convert other formats to RGBA8
let rgba = img.to_rgba8();
do_encode(&config, rgba.as_raw(), PixelLayout::Rgba8, width, height)
}
}
}
#[cfg(feature = "server")]
pub fn decode(data: &[u8]) -> Result<image::DynamicImage, WebpError> {
use zenwebp::WebPDecoder;
let mut decoder = WebPDecoder::build(data)
.map_err(|e| WebpError::Decode(format!("Failed to build decoder: {}", e)))?;
let info = decoder.info();
let width = info.width;
let height = info.height;
let has_alpha = info.has_alpha;
let pixel_count = (width as u64) * (height as u64);
if pixel_count > crate::api::image::MAX_IMAGE_PIXELS as u64 {
return Err(WebpError::Decode(format!(
"Image dimensions {}x{} exceed maximum allowed pixels",
width, height
)));
}
let buf_size = decoder
.output_buffer_size()
.ok_or_else(|| WebpError::Decode("Image too large".to_string()))?;
let mut output = vec![0u8; buf_size];
decoder
.read_image(&mut output)
.map_err(|e| WebpError::Decode(format!("Failed to decode: {}", e)))?;
if has_alpha {
image::RgbaImage::from_raw(width, height, output)
.map(image::DynamicImage::ImageRgba8)
.ok_or_else(|| WebpError::Decode("Invalid RGBA dimensions".to_string()))
} else {
// For RGB output, the buffer is width * height * 3
image::RgbImage::from_raw(width, height, output)
.map(image::DynamicImage::ImageRgb8)
.ok_or_else(|| WebpError::Decode("Invalid RGB dimensions".to_string()))
}
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn encode_produces_non_empty_bytes() {
let img = image::DynamicImage::new_rgba8(10, 10);
let result = encode(&img, 85.0, 4).unwrap();
assert!(!result.is_empty());
}
#[test]
fn decode_roundtrip_rgba() {
let original = image::DynamicImage::new_rgba8(5, 5);
let encoded = encode(&original, 85.0, 4).unwrap();
let decoded = decode(&encoded).unwrap();
assert_eq!(decoded.width(), 5);
assert_eq!(decoded.height(), 5);
}
#[test]
fn decode_roundtrip_rgb() {
let original = image::DynamicImage::new_rgb8(5, 5);
let encoded = encode(&original, 85.0, 4).unwrap();
let decoded = decode(&encoded).unwrap();
assert_eq!(decoded.width(), 5);
assert_eq!(decoded.height(), 5);
}
#[test]
fn config_parses_valid_env_vars() {
// Note: LazyLock initializes once, so we can't easily test
// different env values in the same process.
// Test that the default config is reasonable
let config = WebpConfig { quality: 85.0, method: 2 };
assert!(config.quality >= 0.0 && config.quality <= 100.0);
assert!(config.method <= 6);
}
#[test]
fn config_clamping_logic() {
// Test the clamping logic independently
let quality = 150.0f32;
let clamped = quality.clamp(0.0, 100.0);
assert_eq!(clamped, 100.0);
let quality = -10.0f32;
let clamped = quality.clamp(0.0, 100.0);
assert_eq!(clamped, 0.0);
let method = 10u8;
let clamped = method.clamp(0, 6);
assert_eq!(clamped, 6);
}
}