1use std::io::Cursor;
2use std::time::Duration;
3
4use image::imageops::FilterType;
5use image::{DynamicImage, ImageReader};
6use sha2::{Digest, Sha256};
7
8const MAX_DOWNLOAD_BYTES: u64 = 5 * 1024 * 1024;
10
11const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(15);
13
14const THUMB_WIDTH: u32 = 256;
16const THUMB_HEIGHT: u32 = 256;
17
18const MAX_OUTPUT_BYTES: usize = 100 * 1024;
20
21const WEBP_QUALITY: f32 = 80.0;
23
24const WEBP_QUALITY_RETRY: f32 = 60.0;
26
27const KEY_HEX_LEN: usize = 32;
29
30const USER_AGENT: &str = "weave-image/0.2 (+https://github.com/redberrythread/weave)";
32
33#[derive(Debug, Clone)]
35pub struct ThumbnailResult {
36 pub key: String,
38 pub data: Vec<u8>,
40}
41
42#[derive(Debug)]
44pub enum Error {
45 Download(String),
46 TooLarge(u64),
47 Decode(String),
48 Encode(String),
49}
50
51impl std::fmt::Display for Error {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 Self::Download(msg) => write!(f, "download failed: {msg}"),
55 Self::TooLarge(size) => write!(
56 f,
57 "source too large: {size} bytes (max {MAX_DOWNLOAD_BYTES})"
58 ),
59 Self::Decode(msg) => write!(f, "image decode failed: {msg}"),
60 Self::Encode(msg) => write!(f, "webp encode failed: {msg}"),
61 }
62 }
63}
64
65impl std::error::Error for Error {}
66
67#[must_use]
71pub fn thumbnail_key(source_url: &str) -> String {
72 let mut hasher = Sha256::new();
73 hasher.update(source_url.as_bytes());
74 let hash = hasher.finalize();
75 let hex = hex_encode(&hash);
76 format!("thumbnails/{}.webp", &hex[..KEY_HEX_LEN])
77}
78
79pub fn download(url: &str) -> Result<Vec<u8>, Error> {
88 let rt = tokio::runtime::Builder::new_current_thread()
89 .enable_all()
90 .build()
91 .map_err(|e| Error::Download(e.to_string()))?;
92
93 rt.block_on(download_async(url))
94}
95
96pub async fn download_async(url: &str) -> Result<Vec<u8>, Error> {
105 let client = reqwest::Client::builder()
106 .timeout(DOWNLOAD_TIMEOUT)
107 .user_agent(USER_AGENT)
108 .build()
109 .map_err(|e| Error::Download(e.to_string()))?;
110
111 let response = client
112 .get(url)
113 .send()
114 .await
115 .map_err(|e| Error::Download(e.to_string()))?;
116
117 if !response.status().is_success() {
118 return Err(Error::Download(format!("HTTP {}", response.status())));
119 }
120
121 if let Some(len) = response.content_length()
122 && len > MAX_DOWNLOAD_BYTES
123 {
124 return Err(Error::TooLarge(len));
125 }
126
127 let bytes = response
128 .bytes()
129 .await
130 .map_err(|e| Error::Download(e.to_string()))?;
131
132 if bytes.len() as u64 > MAX_DOWNLOAD_BYTES {
133 return Err(Error::TooLarge(bytes.len() as u64));
134 }
135
136 Ok(bytes.to_vec())
137}
138
139pub fn resize_to_webp(bytes: &[u8]) -> Result<Vec<u8>, Error> {
146 let img = decode_image(bytes)?;
147 let thumb = cover_crop(&img, THUMB_WIDTH, THUMB_HEIGHT);
148 encode_webp(&thumb)
149}
150
151pub fn process_thumbnail(url: &str) -> Result<ThumbnailResult, Error> {
157 let key = thumbnail_key(url);
158 let bytes = download(url)?;
159 let data = resize_to_webp(&bytes)?;
160 Ok(ThumbnailResult { key, data })
161}
162
163fn decode_image(bytes: &[u8]) -> Result<DynamicImage, Error> {
164 let cursor = Cursor::new(bytes);
165 let reader = ImageReader::new(cursor)
166 .with_guessed_format()
167 .map_err(|e| Error::Decode(e.to_string()))?;
168 reader.decode().map_err(|e| Error::Decode(e.to_string()))
169}
170
171fn cover_crop(img: &DynamicImage, width: u32, height: u32) -> DynamicImage {
173 let (iw, ih) = (img.width(), img.height());
174 let target_ratio = f64::from(width) / f64::from(height);
175 let source_ratio = f64::from(iw) / f64::from(ih);
176
177 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
178 let cropped = if source_ratio > target_ratio {
179 let new_w = (f64::from(ih) * target_ratio) as u32;
180 let x = (iw - new_w) / 2;
181 img.crop_imm(x, 0, new_w, ih)
182 } else {
183 let new_h = (f64::from(iw) / target_ratio) as u32;
184 let y = (ih - new_h) / 2;
185 img.crop_imm(0, y, iw, new_h)
186 };
187
188 cropped.resize_exact(width, height, FilterType::Lanczos3)
189}
190
191fn encode_webp(img: &DynamicImage) -> Result<Vec<u8>, Error> {
192 let data = encode_webp_lossy(img, WEBP_QUALITY)?;
193 if data.len() <= MAX_OUTPUT_BYTES {
194 return Ok(data);
195 }
196
197 let data = encode_webp_lossy(img, WEBP_QUALITY_RETRY)?;
198 if data.len() > MAX_OUTPUT_BYTES {
199 return Err(Error::Encode(format!(
200 "output too large: {} bytes (max {MAX_OUTPUT_BYTES})",
201 data.len()
202 )));
203 }
204
205 Ok(data)
206}
207
208#[allow(
209 unsafe_code,
210 clippy::cast_possible_truncation,
211 clippy::cast_possible_wrap
212)]
213fn encode_webp_lossy(img: &DynamicImage, quality: f32) -> Result<Vec<u8>, Error> {
214 let rgba = img.to_rgba8();
215 let (width, height) = (rgba.width(), rgba.height());
216 let stride = width * 4;
217 let mut output: *mut u8 = std::ptr::null_mut();
218
219 let size = unsafe {
220 libwebp_sys::WebPEncodeRGBA(
221 rgba.as_raw().as_ptr(),
222 width as i32,
223 height as i32,
224 stride as i32,
225 quality,
226 &raw mut output,
227 )
228 };
229
230 if size == 0 || output.is_null() {
231 return Err(Error::Encode("libwebp encoding failed".to_string()));
232 }
233
234 let data = unsafe { std::slice::from_raw_parts(output, size) }.to_vec();
235 unsafe { libwebp_sys::WebPFree(output.cast()) };
236
237 Ok(data)
238}
239
240fn hex_encode(bytes: &[u8]) -> String {
241 bytes
242 .iter()
243 .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
244 use std::fmt::Write;
245 let _ = write!(s, "{b:02x}");
246 s
247 })
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use image::ImageFormat;
254
255 #[test]
256 fn thumbnail_key_is_deterministic() {
257 let key1 = thumbnail_key("https://example.com/photo.jpg");
258 let key2 = thumbnail_key("https://example.com/photo.jpg");
259 assert_eq!(key1, key2);
260 }
261
262 #[test]
263 fn thumbnail_key_different_urls_differ() {
264 let key1 = thumbnail_key("https://example.com/a.jpg");
265 let key2 = thumbnail_key("https://example.com/b.jpg");
266 assert_ne!(key1, key2);
267 }
268
269 #[test]
270 fn thumbnail_key_format() {
271 let key = thumbnail_key("https://example.com/photo.jpg");
272 assert!(key.starts_with("thumbnails/"));
273 assert!(
274 std::path::Path::new(&key)
275 .extension()
276 .is_some_and(|ext| ext.eq_ignore_ascii_case("webp"))
277 );
278 let hex_part = &key["thumbnails/".len()..key.len() - ".webp".len()];
279 assert_eq!(hex_part.len(), KEY_HEX_LEN);
280 assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
281 }
282
283 #[test]
284 fn resize_to_webp_produces_valid_output() {
285 let img = DynamicImage::new_rgb8(800, 600);
286 let mut png_bytes = Vec::new();
287 img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
288 .ok();
289
290 let webp = resize_to_webp(&png_bytes);
291 assert!(webp.is_ok());
292 let data = webp.ok();
293 assert!(data.is_some());
294 let data = data.unwrap_or_default();
295 assert!(!data.is_empty());
296 assert!(data.len() <= MAX_OUTPUT_BYTES);
297 }
298
299 #[test]
300 fn resize_to_webp_is_256x256() {
301 let img = DynamicImage::new_rgb8(1024, 768);
302 let mut png_bytes = Vec::new();
303 img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
304 .ok();
305
306 let webp = resize_to_webp(&png_bytes);
307 assert!(webp.is_ok());
308 let data = webp.unwrap_or_default();
309
310 let decoded = ImageReader::new(Cursor::new(&data))
311 .with_guessed_format()
312 .ok()
313 .and_then(|r| r.decode().ok());
314 assert!(decoded.is_some());
315 let decoded = decoded.unwrap_or_else(|| DynamicImage::new_rgb8(0, 0));
316 assert_eq!(decoded.width(), THUMB_WIDTH);
317 assert_eq!(decoded.height(), THUMB_HEIGHT);
318 }
319
320 #[test]
321 fn resize_to_webp_portrait_image() {
322 let img = DynamicImage::new_rgb8(400, 1200);
323 let mut png_bytes = Vec::new();
324 img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
325 .ok();
326
327 let webp = resize_to_webp(&png_bytes);
328 assert!(webp.is_ok());
329 }
330
331 #[test]
332 fn resize_to_webp_square_image() {
333 let img = DynamicImage::new_rgb8(500, 500);
334 let mut png_bytes = Vec::new();
335 img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
336 .ok();
337
338 let webp = resize_to_webp(&png_bytes);
339 assert!(webp.is_ok());
340 }
341
342 #[test]
343 fn resize_to_webp_tiny_image() {
344 let img = DynamicImage::new_rgb8(16, 16);
345 let mut png_bytes = Vec::new();
346 img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
347 .ok();
348
349 let webp = resize_to_webp(&png_bytes);
350 assert!(webp.is_ok());
351 }
352
353 #[test]
354 fn resize_to_webp_invalid_bytes_fails() {
355 let result = resize_to_webp(b"not an image");
356 assert!(result.is_err());
357 }
358
359 #[test]
360 fn download_invalid_url_fails() {
361 let result = download("not-a-url");
362 assert!(result.is_err());
363 }
364
365 #[test]
366 fn hex_encode_works() {
367 assert_eq!(hex_encode(&[0x00, 0xff, 0xab]), "00ffab");
368 }
369}