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")]
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#[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 let rgba = img.to_rgba8();
102 do_encode(&config, rgba.as_raw(), PixelLayout::Rgba8, width, height)
103 }
104 }
105}
106
107#[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 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 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 fn assert_error<T: std::error::Error>() {}
232 assert_error::<WebpError>();
233 }
234
235 #[test]
236 fn encode_converts_luma8_to_rgba() {
237 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 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 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 assert!(decode(&high).is_ok());
265 assert!(decode(&low).is_ok());
266 }
267
268 #[test]
269 fn decode_invalid_bytes_returns_error() {
270 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 let result = decode(&[]);
280 assert!(result.is_err());
281 }
282
283 #[test]
284 fn decode_error_message_is_descriptive() {
285 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 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}