1#[cfg(feature = "server")]
16use std::sync::LazyLock;
17
18#[cfg(feature = "server")]
20#[derive(Debug)]
21pub enum WebpError {
22 Encode(String),
24 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#[cfg(feature = "server")]
43#[derive(Debug, Clone)]
44pub struct WebpConfig {
45 pub quality: f32,
47 pub method: u8,
49}
50
51#[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#[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 let rgba = img.to_rgba8();
127 do_encode(&config, rgba.as_raw(), PixelLayout::Rgba8, width, height)
128 }
129 }
130}
131
132#[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 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 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 fn assert_error<T: std::error::Error>() {}
257 assert_error::<WebpError>();
258 }
259
260 #[test]
261 fn encode_converts_luma8_to_rgba() {
262 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 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 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 assert!(decode(&high).is_ok());
290 assert!(decode(&low).is_ok());
291 }
292
293 #[test]
294 fn decode_invalid_bytes_returns_error() {
295 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 let result = decode(&[]);
305 assert!(result.is_err());
306 }
307
308 #[test]
309 fn decode_error_message_is_descriptive() {
310 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 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}