Skip to main content

yggdrasil/infra/
webp.rs

1//! WebP 编解码模块。
2//!
3//! 本模块仅在 `server` feature 启用时编译。
4//!
5//! ## `zenwebp` 与 `image` crate 的分工
6//!
7//! - `image` crate:负责通用图像格式(JPEG、PNG、GIF 等)的解码、缩放、旋转以及
8//!   像素格式转换(`DynamicImage`、`RgbaImage`、`RgbImage`)。本项目特意禁用了 `image`
9//!   的 `webp` feature,因为它不支持 WebP 编码,且解码能力有限。
10//! - `zenwebp`:专门负责 WebP 格式的有损编码与解码。所有需要输出 WebP 或读取 WebP
11//!   字节流的场景都通过 `zenwebp` 完成。
12//!
13//! 简言之:`image` 处理“除 WebP 外的图像操作”,`zenwebp` 处理“WebP 专有编解码”。
14
15#[cfg(feature = "server")]
16use std::sync::LazyLock;
17
18/// WebP 编解码过程中可能产生的错误。
19#[cfg(feature = "server")]
20#[derive(Debug)]
21pub enum WebpError {
22    /// 编码失败。
23    Encode(String),
24    /// 解码失败。
25    Decode(String),
26}
27
28#[cfg(feature = "server")]
29impl std::fmt::Display for WebpError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            WebpError::Encode(msg) => write!(f, "WebP encode error: {}", msg),
33            WebpError::Decode(msg) => write!(f, "WebP decode error: {}", msg),
34        }
35    }
36}
37
38#[cfg(feature = "server")]
39impl std::error::Error for WebpError {}
40
41/// WebP 有损编码配置。
42#[cfg(feature = "server")]
43#[derive(Debug, Clone)]
44pub struct WebpConfig {
45    /// 质量系数,范围 0.0–100.0。
46    pub quality: f32,
47    /// 编码方法,范围 0–6,数值越大压缩率越高但越慢。
48    pub method: u8,
49}
50
51/// 从启动期加载的配置([`crate::config::webp`])读取的 WebP 全局配置。
52///
53/// 值由 main.rs 在启动时从 settings 表加载并写入 `config::WEBP_CFG`;
54/// 未设置时(如单元测试)回退默认值 quality=85.0、method=2。
55/// 修改面板值后需重启进程生效。
56#[cfg(feature = "server")]
57pub static WEBP_CONFIG: LazyLock<WebpConfig> = LazyLock::new(|| {
58    let cfg = crate::config::webp();
59    tracing::info!(
60        "WebP config loaded from DB: quality={}, method={}",
61        cfg.quality,
62        cfg.method
63    );
64    WebpConfig {
65        quality: cfg.quality,
66        method: cfg.method as u8,
67    }
68});
69
70/// 将 `image::DynamicImage` 编码为 WebP 字节流。
71///
72/// 直接处理 `Rgba8` 与 `Rgb8` 两种像素布局,其他格式先转换为 `Rgba8` 再编码。
73#[cfg(feature = "server")]
74pub fn encode(img: &image::DynamicImage, quality: f32, method: u8) -> Result<Vec<u8>, WebpError> {
75    use zenwebp::{EncodeRequest, LossyConfig, PixelLayout};
76
77    let (width, height) = (img.width(), img.height());
78    let config = LossyConfig::new().with_quality(quality).with_method(method);
79
80    fn do_encode(
81        config: &LossyConfig,
82        pixels: &[u8],
83        layout: zenwebp::PixelLayout,
84        width: u32,
85        height: u32,
86    ) -> Result<Vec<u8>, WebpError> {
87        EncodeRequest::lossy(config, pixels, layout, width, height)
88            .encode()
89            .map_err(|e| WebpError::Encode(e.to_string()))
90    }
91
92    match img {
93        image::DynamicImage::ImageRgba8(rgba) => {
94            do_encode(&config, rgba.as_raw(), PixelLayout::Rgba8, width, height)
95        }
96        image::DynamicImage::ImageRgb8(rgb) => {
97            do_encode(&config, rgb.as_raw(), PixelLayout::Rgb8, width, height)
98        }
99        _ => {
100            // 其他像素格式统一转换为 RGBA8 后再交给 zenwebp 编码
101            let rgba = img.to_rgba8();
102            do_encode(&config, rgba.as_raw(), PixelLayout::Rgba8, width, height)
103        }
104    }
105}
106
107/// 将 WebP 字节流解码为 `image::DynamicImage`。
108///
109/// 根据 alpha 通道信息决定返回 `ImageRgba8` 还是 `ImageRgb8`。
110/// 解码前会校验像素总数,防止超大图片导致内存问题。
111#[cfg(feature = "server")]
112pub fn decode(data: &[u8]) -> Result<image::DynamicImage, WebpError> {
113    use zenwebp::WebPDecoder;
114
115    let mut decoder = WebPDecoder::build(data)
116        .map_err(|e| WebpError::Decode(format!("Failed to build decoder: {}", e)))?;
117
118    let info = decoder.info();
119    let width = info.width;
120    let height = info.height;
121    let has_alpha = info.has_alpha;
122
123    let pixel_count = (width as u64) * (height as u64);
124
125    // 超过最大允许像素数时提前拒绝
126    if pixel_count > *crate::api::image::MAX_IMAGE_PIXELS as u64 {
127        return Err(WebpError::Decode(format!(
128            "Image dimensions {}x{} exceed maximum allowed pixels",
129            width, height
130        )));
131    }
132
133    let buf_size = decoder
134        .output_buffer_size()
135        .ok_or_else(|| WebpError::Decode("Image too large".to_string()))?;
136
137    let mut output = vec![0u8; buf_size];
138    decoder
139        .read_image(&mut output)
140        .map_err(|e| WebpError::Decode(format!("Failed to decode: {}", e)))?;
141
142    if has_alpha {
143        image::RgbaImage::from_raw(width, height, output)
144            .map(image::DynamicImage::ImageRgba8)
145            .ok_or_else(|| WebpError::Decode("Invalid RGBA dimensions".to_string()))
146    } else {
147        // 无 alpha 时,zenwebp 输出的是 width * height * 3 的 RGB 数据
148        image::RgbImage::from_raw(width, height, output)
149            .map(image::DynamicImage::ImageRgb8)
150            .ok_or_else(|| WebpError::Decode("Invalid RGB dimensions".to_string()))
151    }
152}
153
154#[cfg(all(test, feature = "server"))]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn encode_produces_non_empty_bytes() {
160        let img = image::DynamicImage::new_rgba8(10, 10);
161        let result = encode(&img, 85.0, 4).unwrap();
162        assert!(!result.is_empty());
163    }
164
165    #[test]
166    fn decode_roundtrip_rgba() {
167        let original = image::DynamicImage::new_rgba8(5, 5);
168        let encoded = encode(&original, 85.0, 4).unwrap();
169        let decoded = decode(&encoded).unwrap();
170        assert_eq!(decoded.width(), 5);
171        assert_eq!(decoded.height(), 5);
172    }
173
174    #[test]
175    fn decode_roundtrip_rgb() {
176        let original = image::DynamicImage::new_rgb8(5, 5);
177        let encoded = encode(&original, 85.0, 4).unwrap();
178        let decoded = decode(&encoded).unwrap();
179        assert_eq!(decoded.width(), 5);
180        assert_eq!(decoded.height(), 5);
181    }
182
183    #[test]
184    fn config_default_values_are_reasonable() {
185        let config = WebpConfig {
186            quality: 85.0,
187            method: 2,
188        };
189        assert!(config.quality >= 0.0 && config.quality <= 100.0);
190        assert!(config.method <= 6);
191    }
192
193    #[test]
194    fn config_clamping_logic() {
195        let quality = 150.0f32;
196        let clamped = quality.clamp(0.0, 100.0);
197        assert_eq!(clamped, 100.0);
198
199        let quality = -10.0f32;
200        let clamped = quality.clamp(0.0, 100.0);
201        assert_eq!(clamped, 0.0);
202
203        let method = 10u8;
204        let clamped = method.clamp(0, 6);
205        assert_eq!(clamped, 6);
206    }
207
208    #[test]
209    fn config_clamps_edge_cases() {
210        assert_eq!(0.0f32.clamp(0.0, 100.0), 0.0);
211        assert_eq!(100.0f32.clamp(0.0, 100.0), 100.0);
212        assert_eq!(0u8.clamp(0, 6), 0);
213        assert_eq!(6u8.clamp(0, 6), 6);
214    }
215
216    #[test]
217    fn webp_error_encode_display() {
218        let err = WebpError::Encode("boom".to_string());
219        assert_eq!(err.to_string(), "WebP encode error: boom");
220    }
221
222    #[test]
223    fn webp_error_decode_display() {
224        let err = WebpError::Decode("busted".to_string());
225        assert_eq!(err.to_string(), "WebP decode error: busted");
226    }
227
228    #[test]
229    fn webp_error_implements_std_error() {
230        // WebpError 必须实现 std::error::Error,才能在 ? 传播链中使用。
231        fn assert_error<T: std::error::Error>() {}
232        assert_error::<WebpError>();
233    }
234
235    #[test]
236    fn encode_converts_luma8_to_rgba() {
237        // Luma8(灰度)图像不在 encode 的快速路径中,应被转换为 RGBA8 后编码。
238        let img = image::DynamicImage::new_luma8(4, 4);
239        let result = encode(&img, 80.0, 2);
240        assert!(result.is_ok());
241        assert!(!result.unwrap().is_empty());
242    }
243
244    #[test]
245    fn encode_converts_luma_a8_to_rgba() {
246        // LumaA8(带 alpha 的灰度)同样走转换路径。
247        let img = image::DynamicImage::new_luma_a8(4, 4);
248        let result = encode(&img, 80.0, 2);
249        assert!(result.is_ok());
250        assert!(!result.unwrap().is_empty());
251    }
252
253    #[test]
254    fn encode_lower_quality_does_not_explode_on_solid_color() {
255        // 纯色图是 WebP 的极端情况(信息熵接近 0),确保高低质量都能编码成功
256        // 而非 panic,且产物是合法非空字节流。不假设低质量体积一定更小,
257        // 因为这依赖底层 libwebp 的量化策略,非确定性不变量。
258        let img = image::DynamicImage::new_rgb8(64, 64);
259        let high = encode(&img, 95.0, 4).unwrap();
260        let low = encode(&img, 10.0, 4).unwrap();
261        assert!(!high.is_empty());
262        assert!(!low.is_empty());
263        // 两者都应是合法的 WebP(能被本模块解码回来)。
264        assert!(decode(&high).is_ok());
265        assert!(decode(&low).is_ok());
266    }
267
268    #[test]
269    fn decode_invalid_bytes_returns_error() {
270        // 非 WebP 字节流应返回解码错误而非 panic。
271        let junk = b"this is definitely not a webp image";
272        let result = decode(junk);
273        assert!(result.is_err());
274    }
275
276    #[test]
277    fn decode_empty_bytes_returns_error() {
278        // 空字节流应返回解码错误而非 panic。
279        let result = decode(&[]);
280        assert!(result.is_err());
281    }
282
283    #[test]
284    fn decode_error_message_is_descriptive() {
285        // 解码错误的 Display 应包含 'WebP decode error' 前缀,便于日志排查。
286        let err = decode(b"not webp").unwrap_err();
287        assert!(err.to_string().starts_with("WebP decode error"));
288    }
289
290    #[test]
291    fn encode_decode_preserves_dimensions() {
292        // 编码再解码后,图像宽高应保持一致。
293        let original = image::DynamicImage::new_rgb8(16, 9);
294        let encoded = encode(&original, 85.0, 4).unwrap();
295        let decoded = decode(&encoded).unwrap();
296        assert_eq!(decoded.width(), 16);
297        assert_eq!(decoded.height(), 9);
298    }
299}