From b8638f5131c90cc5d38827e094cd1c37c7256f92 Mon Sep 17 00:00:00 2001 From: Diocrafts Date: Sat, 7 Mar 2026 20:37:19 +0100 Subject: [PATCH] perf(thumbs): switch thumbnail encoding from WebP to JPEG q=80 Replace all 3 ImageFormat::WebP encode sites with JpegEncoder q=80. Update fast-path to detect JPEG SOI instead of RIFF/WEBP magic. Change file extension .webp -> .jpg, Content-Type headers, and browser toBlob. Remove unused ImageFormat import and stale comments. The webp feature stays for DECODING uploaded WebP images. --- .../services/thumbnail_service.rs | 53 ++++++++++--------- .../services/thumbnail_service_test.rs | 6 +-- src/interfaces/api/handlers/file_handler.rs | 4 +- static/js/features/library/photos.js | 6 +-- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index d15e7edb..8de8610b 100755 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -1,5 +1,6 @@ use bytes::Bytes; -use image::{ImageFormat, imageops::FilterType}; +use image::imageops::FilterType; +use image::codecs::jpeg::JpegEncoder; /** * Thumbnail Generation Service * @@ -8,7 +9,7 @@ use image::{ImageFormat, imageops::FilterType}; * Features: * - Background thumbnail generation after upload * - Multiple sizes (icon 150x150, preview 800x600) - * - WebP output for smaller file sizes + * - JPEG output (lossy q=80) for compact thumbnails * - Lock-free moka cache with weight-based eviction * - Lazy generation on first request if not pre-generated */ @@ -150,7 +151,7 @@ impl ThumbnailService { fn get_thumbnail_path(&self, file_id: &str, size: ThumbnailSize) -> PathBuf { self.thumbnails_root .join(size.dir_name()) - .join(format!("{}.webp", file_id)) + .join(format!("{}.jpg", file_id)) } /// Get a thumbnail, generating it if needed. @@ -161,7 +162,7 @@ impl ThumbnailService { /// * `original_path` - Path to the original image file /// /// # Returns - /// Bytes of the thumbnail image (WebP format) + /// Bytes of the thumbnail image (JPEG format) pub async fn get_thumbnail( &self, file_id: &str, @@ -265,13 +266,13 @@ impl ThumbnailService { /// Store an externally-generated thumbnail (e.g. client-side video frame). /// - /// **Fast path**: if the payload is already a valid WebP whose dimensions - /// fit within the target size, it is stored as-is — zero decode, zero - /// encode. The browser pre-scales the canvas to 400 px, so this fast - /// path is hit on every normal video-thumbnail upload. + /// **Fast path**: if the payload is already a correctly-sized JPEG, it is + /// stored as-is — zero decode, zero encode. The browser pre-scales the + /// canvas to 400 px and sends JPEG, so this fast path is hit on every + /// normal video-thumbnail upload. /// - /// **Slow path**: decode → optional resize → re-encode to WebP. Only - /// triggered when a client sends an oversized or non-WebP image. + /// **Slow path**: decode → optional resize → re-encode to JPEG q=80. + /// Only triggered when a client sends an oversized or non-JPEG image. pub async fn store_external_thumbnail( &self, file_id: &str, @@ -281,24 +282,23 @@ impl ThumbnailService { let max_dim = size.max_dimension(); // Validate + optionally re-encode in blocking thread - let webp_bytes = tokio::task::spawn_blocking(move || -> Result, ThumbnailError> { - // ── Fast path: already a correctly-sized WebP ───────────── - // WebP files start with RIFF....WEBP. Read dimensions from - // the header without a full decode (~0 CPU). - if data.len() >= 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" { + let jpeg_bytes = tokio::task::spawn_blocking(move || -> Result, ThumbnailError> { + // ── Fast path: already a correctly-sized JPEG ───────────── + // JPEG files start with SOI marker 0xFF 0xD8. + if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 { if let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(&data)) .with_guessed_format() { if let Ok((w, h)) = reader.into_dimensions() { if w <= max_dim && h <= max_dim { - // Already WebP at correct size — zero-copy store + // Already JPEG at correct size — zero-copy store return Ok(data.to_vec()); } } } } - // ── Slow path: decode, resize, re-encode ───────────────── + // ── Slow path: decode, resize, re-encode to JPEG ───────── let img = image::load_from_memory(&data) .map_err(|e| ThumbnailError::ImageError(format!("Invalid image data: {e}")))?; @@ -316,15 +316,17 @@ impl ThumbnailService { img }; + let rgb = img.to_rgb8(); let mut buffer = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut buffer), ImageFormat::WebP) + let encoder = JpegEncoder::new_with_quality(&mut buffer, 80); + rgb.write_with_encoder(encoder) .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; Ok(buffer) }) .await .map_err(|e| ThumbnailError::TaskError(e.to_string()))??; - let bytes = Bytes::from(webp_bytes); + let bytes = Bytes::from(jpeg_bytes); // Save to disk let thumb_path = self.get_thumbnail_path(file_id, size); @@ -419,10 +421,12 @@ impl ThumbnailService { }; let thumbnail = img.resize(new_width, new_height, filter); - // Encode as WebP for smaller file size + // Encode as JPEG (lossy q=80) — explicit quality control, + // ~2× smaller than image-webp's Rust encoder at same visual quality + let rgb = thumbnail.to_rgb8(); let mut buffer = Vec::new(); - thumbnail - .write_to(&mut std::io::Cursor::new(&mut buffer), ImageFormat::WebP) + let encoder = JpegEncoder::new_with_quality(&mut buffer, 80); + rgb.write_with_encoder(encoder) .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; Ok(buffer) @@ -513,9 +517,10 @@ impl ThumbnailService { }; let thumb = img.resize(new_w, new_h, filter); + let rgb = thumb.to_rgb8(); let mut buf = Vec::new(); - thumb - .write_to(&mut std::io::Cursor::new(&mut buf), ImageFormat::WebP) + let encoder = JpegEncoder::new_with_quality(&mut buf, 80); + rgb.write_with_encoder(encoder) .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; Ok((size, Bytes::from(buf))) diff --git a/src/infrastructure/services/thumbnail_service_test.rs b/src/infrastructure/services/thumbnail_service_test.rs index 003e8154..dbbc1c68 100755 --- a/src/infrastructure/services/thumbnail_service_test.rs +++ b/src/infrastructure/services/thumbnail_service_test.rs @@ -40,10 +40,10 @@ async fn generate_thumbnail_from_blob_path() { let thumb_bytes = result.expect("thumbnail generation should succeed from blob path"); assert!(!thumb_bytes.is_empty(), "thumbnail bytes must not be empty"); - // Verify it's valid WebP (starts with "RIFF" magic) + // Verify it's valid JPEG (starts with SOI marker 0xFF 0xD8) assert!( - thumb_bytes.len() > 12 && &thumb_bytes[0..4] == b"RIFF", - "output should be WebP format" + thumb_bytes.len() > 2 && thumb_bytes[0] == 0xFF && thumb_bytes[1] == 0xD8, + "output should be JPEG format" ); } diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 7b15f7f3..c5c3ecea 100755 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -359,7 +359,7 @@ impl FileHandler { { return Response::builder() .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "image/webp") + .header(header::CONTENT_TYPE, "image/jpeg") .header(header::CONTENT_LENGTH, data.len()) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .header(header::ETAG, &etag) @@ -412,7 +412,7 @@ impl FileHandler { Ok(data) => { Response::builder() .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "image/webp") + .header(header::CONTENT_TYPE, "image/jpeg") .header(header::CONTENT_LENGTH, data.len()) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .header(header::ETAG, &etag) diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index 7f9c3f71..7935557e 100755 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -268,9 +268,9 @@ const photosView = { const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - // Try WebP first, fall back to JPEG - const mimeType = typeof canvas.toBlob === 'function' - ? 'image/webp' : 'image/jpeg'; + // JPEG: explicit quality control, universally supported, + // and server stores as-is when dimensions fit (zero re-encode). + const mimeType = 'image/jpeg'; canvas.toBlob((blob) => { if (!blob) {