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 (100 KB).
19const MAX_OUTPUT_BYTES: usize = 100 * 1024;
20
21/// Default lossy WebP quality (0-100).
22const WEBP_QUALITY: f32 = 80.0;
23
24/// Reduced quality for retry when output exceeds limit.
25const WEBP_QUALITY_RETRY: f32 = 60.0;
26
27/// SHA-256 hex prefix length for thumbnail keys.
28const KEY_HEX_LEN: usize = 32;
29
30/// User-Agent for image downloads.
31const USER_AGENT: &str = "weave-image/0.2 (+https://github.com/redberrythread/weave)";
32
33/// Result of processing a thumbnail.
34#[derive(Debug, Clone)]
35pub struct ThumbnailResult {
36    /// Object key for storage: `thumbnails/{sha256_hex[0..32]}.webp`
37    pub key: String,
38    /// WebP image bytes.
39    pub data: Vec<u8>,
40}
41
42/// Errors that can occur during thumbnail processing.
43#[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/// Compute the thumbnail object key from a source URL.
68///
69/// Returns `thumbnails/{sha256_hex(url)[0..32]}.webp`.
70#[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
79/// Download image bytes from a URL.
80///
81/// Enforces a 5 MB size limit and 15s timeout.
82///
83/// # Errors
84///
85/// Returns `Error::Download` on network failure or non-success HTTP status,
86/// `Error::TooLarge` if the response exceeds 5 MB.
87pub 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
96/// Download image bytes from a URL asynchronously.
97///
98/// Enforces a 5 MB size limit and 15s timeout.
99///
100/// # Errors
101///
102/// Returns `Error::Download` on network failure or non-success HTTP status,
103/// `Error::TooLarge` if the response exceeds 5 MB.
104pub 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
139/// Resize image bytes to a WebP thumbnail with cover crop from center.
140///
141/// # Errors
142///
143/// Returns `Error::Decode` if the image cannot be parsed,
144/// `Error::Encode` if WebP encoding fails or output exceeds 50 KB.
145pub 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
151/// Download an image, resize to thumbnail, and compute the storage key.
152///
153/// # Errors
154///
155/// Returns errors from download or resize stages.
156pub 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
171/// Center-crop and resize to exactly `width x height`.
172fn 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}