Skip to main content

yggdrasil/
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/// 从环境变量读取的 WebP 全局配置,未设置时使用默认值。
52///
53/// - `WEBP_QUALITY`:默认 85.0,越界时 clamp 到 0.0–100.0。
54/// - `WEBP_METHOD`:默认 2,越界时 clamp 到 0–6。
55#[cfg(feature = "server")]
56pub static WEBP_CONFIG: LazyLock<WebpConfig> = LazyLock::new(|| {
57    let (quality, quality_clamped) = std::env::var("WEBP_QUALITY")
58        .ok()
59        .and_then(|s| s.parse::<f32>().ok())
60        .map(|q| {
61            let clamped = q.clamp(0.0, 100.0);
62            (clamped, clamped != q)
63        })
64        .unwrap_or((85.0, false));
65
66    if quality_clamped {
67        tracing::warn!(
68            "WEBP_QUALITY was clamped from {} to {} (valid range: 0.0-100.0)",
69            std::env::var("WEBP_QUALITY").unwrap_or_default(),
70            quality
71        );
72    }
73
74    let (method, method_clamped) = std::env::var("WEBP_METHOD")
75        .ok()
76        .and_then(|s| s.parse::<u8>().ok())
77        .map(|m| {
78            let clamped = m.clamp(0, 6);
79            (clamped, clamped != m)
80        })
81        .unwrap_or((2, false));
82
83    if method_clamped {
84        tracing::warn!(
85            "WEBP_METHOD was clamped from {} to {} (valid range: 0-6)",
86            std::env::var("WEBP_METHOD").unwrap_or_default(),
87            method
88        );
89    }
90
91    tracing::info!("WebP config loaded: quality={}, method={}", quality, method);
92    WebpConfig { quality, method }
93});
94
95/// 将 `image::DynamicImage` 编码为 WebP 字节流。
96///
97/// 直接处理 `Rgba8` 与 `Rgb8` 两种像素布局,其他格式先转换为 `Rgba8` 再编码。
98#[cfg(feature = "server")]
99pub fn encode(img: &image::DynamicImage, quality: f32, method: u8) -> Result<Vec<u8>, WebpError> {
100    use zenwebp::{EncodeRequest, LossyConfig, PixelLayout};
101
102    let (width, height) = (img.width(), img.height());
103    let config = LossyConfig::new().with_quality(quality).with_method(method);
104
105    fn do_encode(
106        config: &LossyConfig,
107        pixels: &[u8],
108        layout: zenwebp::PixelLayout,
109        width: u32,
110        height: u32,
111    ) -> Result<Vec<u8>, WebpError> {
112        EncodeRequest::lossy(config, pixels, layout, width, height)
113            .encode()
114            .map_err(|e| WebpError::Encode(e.to_string()))
115    }
116
117    match img {
118        image::DynamicImage::ImageRgba8(rgba) => {
119            do_encode(&config, rgba.as_raw(), PixelLayout::Rgba8, width, height)
120        }
121        image::DynamicImage::ImageRgb8(rgb) => {
122            do_encode(&config, rgb.as_raw(), PixelLayout::Rgb8, width, height)
123        }
124        _ => {
125            // 其他像素格式统一转换为 RGBA8 后再交给 zenwebp 编码
126            let rgba = img.to_rgba8();
127            do_encode(&config, rgba.as_raw(), PixelLayout::Rgba8, width, height)
128        }
129    }
130}
131
132/// 将 WebP 字节流解码为 `image::DynamicImage`。
133///
134/// 根据 alpha 通道信息决定返回 `ImageRgba8` 还是 `ImageRgb8`。
135/// 解码前会校验像素总数,防止超大图片导致内存问题。
136#[cfg(feature = "server")]
137pub fn decode(data: &[u8]) -> Result<image::DynamicImage, WebpError> {
138    use zenwebp::WebPDecoder;
139
140    let mut decoder = WebPDecoder::build(data)
141        .map_err(|e| WebpError::Decode(format!("Failed to build decoder: {}", e)))?;
142
143    let info = decoder.info();
144    let width = info.width;
145    let height = info.height;
146    let has_alpha = info.has_alpha;
147
148    let pixel_count = (width as u64) * (height as u64);
149
150    // 超过最大允许像素数时提前拒绝
151    if pixel_count > *crate::api::image::MAX_IMAGE_PIXELS as u64 {
152        return Err(WebpError::Decode(format!(
153            "Image dimensions {}x{} exceed maximum allowed pixels",
154            width, height
155        )));
156    }
157
158    let buf_size = decoder
159        .output_buffer_size()
160        .ok_or_else(|| WebpError::Decode("Image too large".to_string()))?;
161
162    let mut output = vec![0u8; buf_size];
163    decoder
164        .read_image(&mut output)
165        .map_err(|e| WebpError::Decode(format!("Failed to decode: {}", e)))?;
166
167    if has_alpha {
168        image::RgbaImage::from_raw(width, height, output)
169            .map(image::DynamicImage::ImageRgba8)
170            .ok_or_else(|| WebpError::Decode("Invalid RGBA dimensions".to_string()))
171    } else {
172        // 无 alpha 时,zenwebp 输出的是 width * height * 3 的 RGB 数据
173        image::RgbImage::from_raw(width, height, output)
174            .map(image::DynamicImage::ImageRgb8)
175            .ok_or_else(|| WebpError::Decode("Invalid RGB dimensions".to_string()))
176    }
177}
178
179#[cfg(all(test, feature = "server"))]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn encode_produces_non_empty_bytes() {
185        let img = image::DynamicImage::new_rgba8(10, 10);
186        let result = encode(&img, 85.0, 4).unwrap();
187        assert!(!result.is_empty());
188    }
189
190    #[test]
191    fn decode_roundtrip_rgba() {
192        let original = image::DynamicImage::new_rgba8(5, 5);
193        let encoded = encode(&original, 85.0, 4).unwrap();
194        let decoded = decode(&encoded).unwrap();
195        assert_eq!(decoded.width(), 5);
196        assert_eq!(decoded.height(), 5);
197    }
198
199    #[test]
200    fn decode_roundtrip_rgb() {
201        let original = image::DynamicImage::new_rgb8(5, 5);
202        let encoded = encode(&original, 85.0, 4).unwrap();
203        let decoded = decode(&encoded).unwrap();
204        assert_eq!(decoded.width(), 5);
205        assert_eq!(decoded.height(), 5);
206    }
207
208    #[test]
209    fn config_default_values_are_reasonable() {
210        let config = WebpConfig {
211            quality: 85.0,
212            method: 2,
213        };
214        assert!(config.quality >= 0.0 && config.quality <= 100.0);
215        assert!(config.method <= 6);
216    }
217
218    #[test]
219    fn config_clamping_logic() {
220        let quality = 150.0f32;
221        let clamped = quality.clamp(0.0, 100.0);
222        assert_eq!(clamped, 100.0);
223
224        let quality = -10.0f32;
225        let clamped = quality.clamp(0.0, 100.0);
226        assert_eq!(clamped, 0.0);
227
228        let method = 10u8;
229        let clamped = method.clamp(0, 6);
230        assert_eq!(clamped, 6);
231    }
232
233    #[test]
234    fn config_clamps_edge_cases() {
235        assert_eq!(0.0f32.clamp(0.0, 100.0), 0.0);
236        assert_eq!(100.0f32.clamp(0.0, 100.0), 100.0);
237        assert_eq!(0u8.clamp(0, 6), 0);
238        assert_eq!(6u8.clamp(0, 6), 6);
239    }
240
241    #[test]
242    fn webp_error_encode_display() {
243        let err = WebpError::Encode("boom".to_string());
244        assert_eq!(err.to_string(), "WebP encode error: boom");
245    }
246
247    #[test]
248    fn webp_error_decode_display() {
249        let err = WebpError::Decode("busted".to_string());
250        assert_eq!(err.to_string(), "WebP decode error: busted");
251    }
252
253    #[test]
254    fn webp_error_implements_std_error() {
255        // WebpError 必须实现 std::error::Error,才能在 ? 传播链中使用。
256        fn assert_error<T: std::error::Error>() {}
257        assert_error::<WebpError>();
258    }
259
260    #[test]
261    fn encode_converts_luma8_to_rgba() {
262        // Luma8(灰度)图像不在 encode 的快速路径中,应被转换为 RGBA8 后编码。
263        let img = image::DynamicImage::new_luma8(4, 4);
264        let result = encode(&img, 80.0, 2);
265        assert!(result.is_ok());
266        assert!(!result.unwrap().is_empty());
267    }
268
269    #[test]
270    fn encode_converts_luma_a8_to_rgba() {
271        // LumaA8(带 alpha 的灰度)同样走转换路径。
272        let img = image::DynamicImage::new_luma_a8(4, 4);
273        let result = encode(&img, 80.0, 2);
274        assert!(result.is_ok());
275        assert!(!result.unwrap().is_empty());
276    }
277
278    #[test]
279    fn encode_lower_quality_does_not_explode_on_solid_color() {
280        // 纯色图是 WebP 的极端情况(信息熵接近 0),确保高低质量都能编码成功
281        // 而非 panic,且产物是合法非空字节流。不假设低质量体积一定更小,
282        // 因为这依赖底层 libwebp 的量化策略,非确定性不变量。
283        let img = image::DynamicImage::new_rgb8(64, 64);
284        let high = encode(&img, 95.0, 4).unwrap();
285        let low = encode(&img, 10.0, 4).unwrap();
286        assert!(!high.is_empty());
287        assert!(!low.is_empty());
288        // 两者都应是合法的 WebP(能被本模块解码回来)。
289        assert!(decode(&high).is_ok());
290        assert!(decode(&low).is_ok());
291    }
292
293    #[test]
294    fn decode_invalid_bytes_returns_error() {
295        // 非 WebP 字节流应返回解码错误而非 panic。
296        let junk = b"this is definitely not a webp image";
297        let result = decode(junk);
298        assert!(result.is_err());
299    }
300
301    #[test]
302    fn decode_empty_bytes_returns_error() {
303        // 空字节流应返回解码错误而非 panic。
304        let result = decode(&[]);
305        assert!(result.is_err());
306    }
307
308    #[test]
309    fn decode_error_message_is_descriptive() {
310        // 解码错误的 Display 应包含 'WebP decode error' 前缀,便于日志排查。
311        let err = decode(b"not webp").unwrap_err();
312        assert!(err.to_string().starts_with("WebP decode error"));
313    }
314
315    #[test]
316    fn encode_decode_preserves_dimensions() {
317        // 编码再解码后,图像宽高应保持一致。
318        let original = image::DynamicImage::new_rgb8(16, 9);
319        let encoded = encode(&original, 85.0, 4).unwrap();
320        let decoded = decode(&encoded).unwrap();
321        assert_eq!(decoded.width(), 16);
322        assert_eq!(decoded.height(), 9);
323    }
324}