yggdrasil/src/webp.rs
xfy ff2694c5a3
Some checks failed
CI / check (push) Failing after 6m23s
CI / build (push) Has been skipped
feat(image): make size/pixel limits configurable, double defaults
将 MAX_IMAGE_DIMENSION / MAX_IMAGE_PIXELS 从编译期 const 改为运行时
LazyLock(启动读环境变量),默认值 x2。

- 默认值:单边 4096→8192,总像素 25M→50M
- clamp 策略:只设下限防危险小值(DIMENSION≥512,PIXELS≥1M),
  超下限 clamp + warn;无上限,完全信任运维
- 内存影响:默认 50M 像素对应 ~200MB/图解码缓冲(max_alloc)
- 模式复用 WEBP_CONFIG:env→parse→(val,clamped)→clamp→warn→info
- 所有引用点解引用(*MAX_...),含 upload.rs/webp.rs 跨文件
- check_upload_dimensions 超限文案改为动态读取上限,不再硬编码数值
- 测试改用 *MAX_IMAGE_DIMENSION+1,数值无关

.env.example 与 AGENTS.md 同步更新环境变量说明。
2026-06-24 13:54:36 +08:00

325 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! WebP 编解码模块。
//!
//! 本模块仅在 `server` feature 启用时编译。
//!
//! ## `zenwebp` 与 `image` crate 的分工
//!
//! - `image` crate负责通用图像格式JPEG、PNG、GIF 等)的解码、缩放、旋转以及
//! 像素格式转换(`DynamicImage`、`RgbaImage`、`RgbImage`)。本项目特意禁用了 `image`
//! 的 `webp` feature因为它不支持 WebP 编码,且解码能力有限。
//! - `zenwebp`:专门负责 WebP 格式的有损编码与解码。所有需要输出 WebP 或读取 WebP
//! 字节流的场景都通过 `zenwebp` 完成。
//!
//! 简言之:`image` 处理“除 WebP 外的图像操作”,`zenwebp` 处理“WebP 专有编解码”。
#[cfg(feature = "server")]
use std::sync::LazyLock;
/// WebP 编解码过程中可能产生的错误。
#[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 {}
/// WebP 有损编码配置。
#[cfg(feature = "server")]
#[derive(Debug, Clone)]
pub struct WebpConfig {
/// 质量系数,范围 0.0100.0。
pub quality: f32,
/// 编码方法,范围 06数值越大压缩率越高但越慢。
pub method: u8,
}
/// 从环境变量读取的 WebP 全局配置,未设置时使用默认值。
///
/// - `WEBP_QUALITY`:默认 85.0,越界时 clamp 到 0.0100.0。
/// - `WEBP_METHOD`:默认 2越界时 clamp 到 06。
#[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 }
});
/// 将 `image::DynamicImage` 编码为 WebP 字节流。
///
/// 直接处理 `Rgba8` 与 `Rgb8` 两种像素布局,其他格式先转换为 `Rgba8` 再编码。
#[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)
}
_ => {
// 其他像素格式统一转换为 RGBA8 后再交给 zenwebp 编码
let rgba = img.to_rgba8();
do_encode(&config, rgba.as_raw(), PixelLayout::Rgba8, width, height)
}
}
}
/// 将 WebP 字节流解码为 `image::DynamicImage`。
///
/// 根据 alpha 通道信息决定返回 `ImageRgba8` 还是 `ImageRgb8`。
/// 解码前会校验像素总数,防止超大图片导致内存问题。
#[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 {
// 无 alpha 时zenwebp 输出的是 width * height * 3 的 RGB 数据
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_default_values_are_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() {
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);
}
#[test]
fn config_clamps_edge_cases() {
assert_eq!(0.0f32.clamp(0.0, 100.0), 0.0);
assert_eq!(100.0f32.clamp(0.0, 100.0), 100.0);
assert_eq!(0u8.clamp(0, 6), 0);
assert_eq!(6u8.clamp(0, 6), 6);
}
#[test]
fn webp_error_encode_display() {
let err = WebpError::Encode("boom".to_string());
assert_eq!(err.to_string(), "WebP encode error: boom");
}
#[test]
fn webp_error_decode_display() {
let err = WebpError::Decode("busted".to_string());
assert_eq!(err.to_string(), "WebP decode error: busted");
}
#[test]
fn webp_error_implements_std_error() {
// WebpError 必须实现 std::error::Error才能在 ? 传播链中使用。
fn assert_error<T: std::error::Error>() {}
assert_error::<WebpError>();
}
#[test]
fn encode_converts_luma8_to_rgba() {
// Luma8灰度图像不在 encode 的快速路径中,应被转换为 RGBA8 后编码。
let img = image::DynamicImage::new_luma8(4, 4);
let result = encode(&img, 80.0, 2);
assert!(result.is_ok());
assert!(!result.unwrap().is_empty());
}
#[test]
fn encode_converts_luma_a8_to_rgba() {
// LumaA8带 alpha 的灰度)同样走转换路径。
let img = image::DynamicImage::new_luma_a8(4, 4);
let result = encode(&img, 80.0, 2);
assert!(result.is_ok());
assert!(!result.unwrap().is_empty());
}
#[test]
fn encode_lower_quality_does_not_explode_on_solid_color() {
// 纯色图是 WebP 的极端情况(信息熵接近 0确保高低质量都能编码成功
// 而非 panic且产物是合法非空字节流。不假设低质量体积一定更小
// 因为这依赖底层 libwebp 的量化策略,非确定性不变量。
let img = image::DynamicImage::new_rgb8(64, 64);
let high = encode(&img, 95.0, 4).unwrap();
let low = encode(&img, 10.0, 4).unwrap();
assert!(!high.is_empty());
assert!(!low.is_empty());
// 两者都应是合法的 WebP能被本模块解码回来
assert!(decode(&high).is_ok());
assert!(decode(&low).is_ok());
}
#[test]
fn decode_invalid_bytes_returns_error() {
// 非 WebP 字节流应返回解码错误而非 panic。
let junk = b"this is definitely not a webp image";
let result = decode(junk);
assert!(result.is_err());
}
#[test]
fn decode_empty_bytes_returns_error() {
// 空字节流应返回解码错误而非 panic。
let result = decode(&[]);
assert!(result.is_err());
}
#[test]
fn decode_error_message_is_descriptive() {
// 解码错误的 Display 应包含 'WebP decode error' 前缀,便于日志排查。
let err = decode(b"not webp").unwrap_err();
assert!(err.to_string().starts_with("WebP decode error"));
}
#[test]
fn encode_decode_preserves_dimensions() {
// 编码再解码后,图像宽高应保持一致。
let original = image::DynamicImage::new_rgb8(16, 9);
let encoded = encode(&original, 85.0, 4).unwrap();
let decoded = decode(&encoded).unwrap();
assert_eq!(decoded.width(), 16);
assert_eq!(decoded.height(), 9);
}
}