11use bytes:: Bytes ;
2- use image:: { ImageFormat , imageops:: FilterType } ;
2+ use image:: imageops:: FilterType ;
3+ use image:: codecs:: jpeg:: JpegEncoder ;
34/**
45 * Thumbnail Generation Service
56 *
@@ -8,7 +9,7 @@ use image::{ImageFormat, imageops::FilterType};
89 * Features:
910 * - Background thumbnail generation after upload
1011 * - Multiple sizes (icon 150x150, preview 800x600)
11- * - WebP output for smaller file sizes
12+ * - JPEG output (lossy q=80) for compact thumbnails
1213 * - Lock-free moka cache with weight-based eviction
1314 * - Lazy generation on first request if not pre-generated
1415 */
@@ -150,7 +151,7 @@ impl ThumbnailService {
150151 fn get_thumbnail_path ( & self , file_id : & str , size : ThumbnailSize ) -> PathBuf {
151152 self . thumbnails_root
152153 . join ( size. dir_name ( ) )
153- . join ( format ! ( "{}.webp " , file_id) )
154+ . join ( format ! ( "{}.jpg " , file_id) )
154155 }
155156
156157 /// Get a thumbnail, generating it if needed.
@@ -161,7 +162,7 @@ impl ThumbnailService {
161162 /// * `original_path` - Path to the original image file
162163 ///
163164 /// # Returns
164- /// Bytes of the thumbnail image (WebP format)
165+ /// Bytes of the thumbnail image (JPEG format)
165166 pub async fn get_thumbnail (
166167 & self ,
167168 file_id : & str ,
@@ -265,13 +266,13 @@ impl ThumbnailService {
265266
266267 /// Store an externally-generated thumbnail (e.g. client-side video frame).
267268 ///
268- /// **Fast path**: if the payload is already a valid WebP whose dimensions
269- /// fit within the target size, it is stored as-is — zero decode, zero
270- /// encode. The browser pre-scales the canvas to 400 px, so this fast
271- /// path is hit on every normal video-thumbnail upload.
269+ /// **Fast path**: if the payload is already a correctly-sized JPEG, it is
270+ /// stored as-is — zero decode, zero encode. The browser pre-scales the
271+ /// canvas to 400 px and sends JPEG , so this fast path is hit on every
272+ /// normal video-thumbnail upload.
272273 ///
273- /// **Slow path**: decode → optional resize → re-encode to WebP. Only
274- /// triggered when a client sends an oversized or non-WebP image.
274+ /// **Slow path**: decode → optional resize → re-encode to JPEG q=80.
275+ /// Only triggered when a client sends an oversized or non-JPEG image.
275276 pub async fn store_external_thumbnail (
276277 & self ,
277278 file_id : & str ,
@@ -281,24 +282,23 @@ impl ThumbnailService {
281282 let max_dim = size. max_dimension ( ) ;
282283
283284 // Validate + optionally re-encode in blocking thread
284- let webp_bytes = tokio:: task:: spawn_blocking ( move || -> Result < Vec < u8 > , ThumbnailError > {
285- // ── Fast path: already a correctly-sized WebP ─────────────
286- // WebP files start with RIFF....WEBP. Read dimensions from
287- // the header without a full decode (~0 CPU).
288- if data. len ( ) >= 12 && & data[ ..4 ] == b"RIFF" && & data[ 8 ..12 ] == b"WEBP" {
285+ let jpeg_bytes = tokio:: task:: spawn_blocking ( move || -> Result < Vec < u8 > , ThumbnailError > {
286+ // ── Fast path: already a correctly-sized JPEG ─────────────
287+ // JPEG files start with SOI marker 0xFF 0xD8.
288+ if data. len ( ) >= 2 && data[ 0 ] == 0xFF && data[ 1 ] == 0xD8 {
289289 if let Ok ( reader) = image:: ImageReader :: new ( std:: io:: Cursor :: new ( & data) )
290290 . with_guessed_format ( )
291291 {
292292 if let Ok ( ( w, h) ) = reader. into_dimensions ( ) {
293293 if w <= max_dim && h <= max_dim {
294- // Already WebP at correct size — zero-copy store
294+ // Already JPEG at correct size — zero-copy store
295295 return Ok ( data. to_vec ( ) ) ;
296296 }
297297 }
298298 }
299299 }
300300
301- // ── Slow path: decode, resize, re-encode ──────── ─────────
301+ // ── Slow path: decode, resize, re-encode to JPEG ─────────
302302 let img = image:: load_from_memory ( & data)
303303 . map_err ( |e| ThumbnailError :: ImageError ( format ! ( "Invalid image data: {e}" ) ) ) ?;
304304
@@ -316,15 +316,17 @@ impl ThumbnailService {
316316 img
317317 } ;
318318
319+ let rgb = img. to_rgb8 ( ) ;
319320 let mut buffer = Vec :: new ( ) ;
320- img. write_to ( & mut std:: io:: Cursor :: new ( & mut buffer) , ImageFormat :: WebP )
321+ let encoder = JpegEncoder :: new_with_quality ( & mut buffer, 80 ) ;
322+ rgb. write_with_encoder ( encoder)
321323 . map_err ( |e| ThumbnailError :: ImageError ( e. to_string ( ) ) ) ?;
322324 Ok ( buffer)
323325 } )
324326 . await
325327 . map_err ( |e| ThumbnailError :: TaskError ( e. to_string ( ) ) ) ??;
326328
327- let bytes = Bytes :: from ( webp_bytes ) ;
329+ let bytes = Bytes :: from ( jpeg_bytes ) ;
328330
329331 // Save to disk
330332 let thumb_path = self . get_thumbnail_path ( file_id, size) ;
@@ -419,10 +421,12 @@ impl ThumbnailService {
419421 } ;
420422 let thumbnail = img. resize ( new_width, new_height, filter) ;
421423
422- // Encode as WebP for smaller file size
424+ // Encode as JPEG (lossy q=80) — explicit quality control,
425+ // ~2× smaller than image-webp's Rust encoder at same visual quality
426+ let rgb = thumbnail. to_rgb8 ( ) ;
423427 let mut buffer = Vec :: new ( ) ;
424- thumbnail
425- . write_to ( & mut std :: io :: Cursor :: new ( & mut buffer ) , ImageFormat :: WebP )
428+ let encoder = JpegEncoder :: new_with_quality ( & mut buffer , 80 ) ;
429+ rgb . write_with_encoder ( encoder )
426430 . map_err ( |e| ThumbnailError :: ImageError ( e. to_string ( ) ) ) ?;
427431
428432 Ok ( buffer)
@@ -513,9 +517,10 @@ impl ThumbnailService {
513517 } ;
514518 let thumb = img. resize ( new_w, new_h, filter) ;
515519
520+ let rgb = thumb. to_rgb8 ( ) ;
516521 let mut buf = Vec :: new ( ) ;
517- thumb
518- . write_to ( & mut std :: io :: Cursor :: new ( & mut buf ) , ImageFormat :: WebP )
522+ let encoder = JpegEncoder :: new_with_quality ( & mut buf , 80 ) ;
523+ rgb . write_with_encoder ( encoder )
519524 . map_err ( |e| ThumbnailError :: ImageError ( e. to_string ( ) ) ) ?;
520525
521526 Ok ( ( size, Bytes :: from ( buf) ) )
0 commit comments