yggdrasil/src/webp.rs
xfy 19ffcada4a test: 移除脆弱断言与凑数测试(review 修正)
对本次新增测试自审后的清理:

1. webp: encode_lower_quality_produces_smaller_or_equal_bytes
   原断言“低质量体积 <= 高质量体积”是 WebP 的统计趋势而非
   确定性不变量,依赖底层 libwebp 量化策略,纯色图上高低差异
   接近 0,未来库升级易变为 flaky。改为验证两个质量档位都能
   成功编码并产生可被本模块解码的合法 WebP。

2. theme: theme_is_copy
   Copy 语义由编译器在编译期保证,运行期断言永真,不测任何
   运行逻辑,属凑数。

3. webp: webp_config_default_quality_and_method
   构造 struct 再断言字段等于构造时填入的值,是同义反复。

净减 2 个测试(292 → 290),全部通过。
2026-06-15 11:26:24 +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);
}
}