Skip to main content

weave_image/
lib.rs

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