Skip to main content

weave_image/
lib.rs

1use std::io::Cursor;
2use std::time::Duration;
3
4use image::imageops::FilterType;
5use image::{DynamicImage, ImageReader};
6use sha2::{Digest, Sha256};
7
8/// Maximum source image download size (5 MB).
9const MAX_DOWNLOAD_BYTES: u64 = 5 * 1024 * 1024;
10
11/// Download timeout.
12const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(15);
13
14/// Output thumbnail dimensions.
15const THUMB_WIDTH: u32 = 256;
16const THUMB_HEIGHT: u32 = 256;
17
18/// Maximum output thumbnail size (50 KB).
19const MAX_OUTPUT_BYTES: usize = 50 * 1024;
20
21/// SHA-256 hex prefix length for thumbnail keys.
22const KEY_HEX_LEN: usize = 32;
23
24/// Result of processing a thumbnail.
25#[derive(Debug, Clone)]
26pub struct ThumbnailResult {
27    /// Object key for storage: `thumbnails/{sha256_hex[0..32]}.webp`
28    pub key: String,
29    /// WebP image bytes.
30    pub data: Vec<u8>,
31}
32
33/// Errors that can occur during thumbnail processing.
34#[derive(Debug)]
35pub enum Error {
36    Download(String),
37    TooLarge(u64),
38    Decode(String),
39    Encode(String),
40}
41
42impl std::fmt::Display for Error {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Download(msg) => write!(f, "download failed: {msg}"),
46            Self::TooLarge(size) => write!(
47                f,
48                "source too large: {size} bytes (max {MAX_DOWNLOAD_BYTES})"
49            ),
50            Self::Decode(msg) => write!(f, "image decode failed: {msg}"),
51            Self::Encode(msg) => write!(f, "webp encode failed: {msg}"),
52        }
53    }
54}
55
56impl std::error::Error for Error {}
57
58/// Compute the thumbnail object key from a source URL.
59///
60/// Returns `thumbnails/{sha256_hex(url)[0..32]}.webp`.
61#[must_use]
62pub fn thumbnail_key(source_url: &str) -> String {
63    let mut hasher = Sha256::new();
64    hasher.update(source_url.as_bytes());
65    let hash = hasher.finalize();
66    let hex = hex_encode(&hash);
67    format!("thumbnails/{}.webp", &hex[..KEY_HEX_LEN])
68}
69
70/// Download image bytes from a URL.
71///
72/// Enforces a 5 MB size limit and 15s timeout.
73///
74/// # Errors
75///
76/// Returns `Error::Download` on network failure or non-success HTTP status,
77/// `Error::TooLarge` if the response exceeds 5 MB.
78pub fn download(url: &str) -> Result<Vec<u8>, Error> {
79    let rt = tokio::runtime::Builder::new_current_thread()
80        .enable_all()
81        .build()
82        .map_err(|e| Error::Download(e.to_string()))?;
83
84    rt.block_on(download_async(url))
85}
86
87/// Download image bytes from a URL asynchronously.
88///
89/// Enforces a 5 MB size limit and 15s timeout.
90///
91/// # Errors
92///
93/// Returns `Error::Download` on network failure or non-success HTTP status,
94/// `Error::TooLarge` if the response exceeds 5 MB.
95pub async fn download_async(url: &str) -> Result<Vec<u8>, Error> {
96    let client = reqwest::Client::builder()
97        .timeout(DOWNLOAD_TIMEOUT)
98        .build()
99        .map_err(|e| Error::Download(e.to_string()))?;
100
101    let response = client
102        .get(url)
103        .send()
104        .await
105        .map_err(|e| Error::Download(e.to_string()))?;
106
107    if !response.status().is_success() {
108        return Err(Error::Download(format!("HTTP {}", response.status())));
109    }
110
111    if let Some(len) = response.content_length()
112        && len > MAX_DOWNLOAD_BYTES
113    {
114        return Err(Error::TooLarge(len));
115    }
116
117    let bytes = response
118        .bytes()
119        .await
120        .map_err(|e| Error::Download(e.to_string()))?;
121
122    if bytes.len() as u64 > MAX_DOWNLOAD_BYTES {
123        return Err(Error::TooLarge(bytes.len() as u64));
124    }
125
126    Ok(bytes.to_vec())
127}
128
129/// Resize image bytes to a WebP thumbnail with cover crop from center.
130///
131/// # Errors
132///
133/// Returns `Error::Decode` if the image cannot be parsed,
134/// `Error::Encode` if WebP encoding fails or output exceeds 50 KB.
135pub fn resize_to_webp(bytes: &[u8]) -> Result<Vec<u8>, Error> {
136    let img = decode_image(bytes)?;
137    let thumb = cover_crop(&img, THUMB_WIDTH, THUMB_HEIGHT);
138    encode_webp(&thumb)
139}
140
141/// Download an image, resize to thumbnail, and compute the storage key.
142///
143/// # Errors
144///
145/// Returns errors from download or resize stages.
146pub fn process_thumbnail(url: &str) -> Result<ThumbnailResult, Error> {
147    let key = thumbnail_key(url);
148    let bytes = download(url)?;
149    let data = resize_to_webp(&bytes)?;
150    Ok(ThumbnailResult { key, data })
151}
152
153fn decode_image(bytes: &[u8]) -> Result<DynamicImage, Error> {
154    let cursor = Cursor::new(bytes);
155    let reader = ImageReader::new(cursor)
156        .with_guessed_format()
157        .map_err(|e| Error::Decode(e.to_string()))?;
158    reader.decode().map_err(|e| Error::Decode(e.to_string()))
159}
160
161/// Center-crop and resize to exactly `width x height`.
162fn cover_crop(img: &DynamicImage, width: u32, height: u32) -> DynamicImage {
163    let (iw, ih) = (img.width(), img.height());
164    let target_ratio = f64::from(width) / f64::from(height);
165    let source_ratio = f64::from(iw) / f64::from(ih);
166
167    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
168    let cropped = if source_ratio > target_ratio {
169        let new_w = (f64::from(ih) * target_ratio) as u32;
170        let x = (iw - new_w) / 2;
171        img.crop_imm(x, 0, new_w, ih)
172    } else {
173        let new_h = (f64::from(iw) / target_ratio) as u32;
174        let y = (ih - new_h) / 2;
175        img.crop_imm(0, y, iw, new_h)
176    };
177
178    cropped.resize_exact(width, height, FilterType::Lanczos3)
179}
180
181fn encode_webp(img: &DynamicImage) -> Result<Vec<u8>, Error> {
182    let mut buf = Cursor::new(Vec::new());
183    img.write_with_encoder(image::codecs::webp::WebPEncoder::new_lossless(&mut buf))
184        .map_err(|e| Error::Encode(e.to_string()))?;
185
186    let data = buf.into_inner();
187    if data.len() > MAX_OUTPUT_BYTES {
188        return Err(Error::Encode(format!(
189            "output too large: {} bytes (max {MAX_OUTPUT_BYTES})",
190            data.len()
191        )));
192    }
193
194    Ok(data)
195}
196
197fn hex_encode(bytes: &[u8]) -> String {
198    bytes
199        .iter()
200        .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
201            use std::fmt::Write;
202            let _ = write!(s, "{b:02x}");
203            s
204        })
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use image::ImageFormat;
211
212    #[test]
213    fn thumbnail_key_is_deterministic() {
214        let key1 = thumbnail_key("https://example.com/photo.jpg");
215        let key2 = thumbnail_key("https://example.com/photo.jpg");
216        assert_eq!(key1, key2);
217    }
218
219    #[test]
220    fn thumbnail_key_different_urls_differ() {
221        let key1 = thumbnail_key("https://example.com/a.jpg");
222        let key2 = thumbnail_key("https://example.com/b.jpg");
223        assert_ne!(key1, key2);
224    }
225
226    #[test]
227    fn thumbnail_key_format() {
228        let key = thumbnail_key("https://example.com/photo.jpg");
229        assert!(key.starts_with("thumbnails/"));
230        assert!(
231            std::path::Path::new(&key)
232                .extension()
233                .is_some_and(|ext| ext.eq_ignore_ascii_case("webp"))
234        );
235        let hex_part = &key["thumbnails/".len()..key.len() - ".webp".len()];
236        assert_eq!(hex_part.len(), KEY_HEX_LEN);
237        assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
238    }
239
240    #[test]
241    fn resize_to_webp_produces_valid_output() {
242        let img = DynamicImage::new_rgb8(800, 600);
243        let mut png_bytes = Vec::new();
244        img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
245            .ok();
246
247        let webp = resize_to_webp(&png_bytes);
248        assert!(webp.is_ok());
249        let data = webp.ok();
250        assert!(data.is_some());
251        let data = data.unwrap_or_default();
252        assert!(!data.is_empty());
253        assert!(data.len() <= MAX_OUTPUT_BYTES);
254    }
255
256    #[test]
257    fn resize_to_webp_is_256x256() {
258        let img = DynamicImage::new_rgb8(1024, 768);
259        let mut png_bytes = Vec::new();
260        img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
261            .ok();
262
263        let webp = resize_to_webp(&png_bytes);
264        assert!(webp.is_ok());
265        let data = webp.unwrap_or_default();
266
267        let decoded = ImageReader::new(Cursor::new(&data))
268            .with_guessed_format()
269            .ok()
270            .and_then(|r| r.decode().ok());
271        assert!(decoded.is_some());
272        let decoded = decoded.unwrap_or_else(|| DynamicImage::new_rgb8(0, 0));
273        assert_eq!(decoded.width(), THUMB_WIDTH);
274        assert_eq!(decoded.height(), THUMB_HEIGHT);
275    }
276
277    #[test]
278    fn resize_to_webp_portrait_image() {
279        let img = DynamicImage::new_rgb8(400, 1200);
280        let mut png_bytes = Vec::new();
281        img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
282            .ok();
283
284        let webp = resize_to_webp(&png_bytes);
285        assert!(webp.is_ok());
286    }
287
288    #[test]
289    fn resize_to_webp_square_image() {
290        let img = DynamicImage::new_rgb8(500, 500);
291        let mut png_bytes = Vec::new();
292        img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
293            .ok();
294
295        let webp = resize_to_webp(&png_bytes);
296        assert!(webp.is_ok());
297    }
298
299    #[test]
300    fn resize_to_webp_tiny_image() {
301        let img = DynamicImage::new_rgb8(16, 16);
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    }
309
310    #[test]
311    fn resize_to_webp_invalid_bytes_fails() {
312        let result = resize_to_webp(b"not an image");
313        assert!(result.is_err());
314    }
315
316    #[test]
317    fn download_invalid_url_fails() {
318        let result = download("not-a-url");
319        assert!(result.is_err());
320    }
321
322    #[test]
323    fn hex_encode_works() {
324        assert_eq!(hex_encode(&[0x00, 0xff, 0xab]), "00ffab");
325    }
326}