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.
This commit is contained in:
Diocrafts
2026-03-07 20:37:19 +01:00
parent 05108d3e12
commit b8638f5131
4 changed files with 37 additions and 32 deletions
@@ -1,5 +1,6 @@
use bytes::Bytes; use bytes::Bytes;
use image::{ImageFormat, imageops::FilterType}; use image::imageops::FilterType;
use image::codecs::jpeg::JpegEncoder;
/** /**
* Thumbnail Generation Service * Thumbnail Generation Service
* *
@@ -8,7 +9,7 @@ use image::{ImageFormat, imageops::FilterType};
* Features: * Features:
* - Background thumbnail generation after upload * - Background thumbnail generation after upload
* - Multiple sizes (icon 150x150, preview 800x600) * - 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 * - Lock-free moka cache with weight-based eviction
* - Lazy generation on first request if not pre-generated * - 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 { fn get_thumbnail_path(&self, file_id: &str, size: ThumbnailSize) -> PathBuf {
self.thumbnails_root self.thumbnails_root
.join(size.dir_name()) .join(size.dir_name())
.join(format!("{}.webp", file_id)) .join(format!("{}.jpg", file_id))
} }
/// Get a thumbnail, generating it if needed. /// Get a thumbnail, generating it if needed.
@@ -161,7 +162,7 @@ impl ThumbnailService {
/// * `original_path` - Path to the original image file /// * `original_path` - Path to the original image file
/// ///
/// # Returns /// # Returns
/// Bytes of the thumbnail image (WebP format) /// Bytes of the thumbnail image (JPEG format)
pub async fn get_thumbnail( pub async fn get_thumbnail(
&self, &self,
file_id: &str, file_id: &str,
@@ -265,13 +266,13 @@ impl ThumbnailService {
/// Store an externally-generated thumbnail (e.g. client-side video frame). /// Store an externally-generated thumbnail (e.g. client-side video frame).
/// ///
/// **Fast path**: if the payload is already a valid WebP whose dimensions /// **Fast path**: if the payload is already a correctly-sized JPEG, it is
/// fit within the target size, it is stored as-is — zero decode, zero /// stored as-is — zero decode, zero encode. The browser pre-scales the
/// encode. The browser pre-scales the canvas to 400 px, so this fast /// canvas to 400 px and sends JPEG, so this fast path is hit on every
/// path is hit on every normal video-thumbnail upload. /// normal video-thumbnail upload.
/// ///
/// **Slow path**: decode → optional resize → re-encode to WebP. Only /// **Slow path**: decode → optional resize → re-encode to JPEG q=80.
/// triggered when a client sends an oversized or non-WebP image. /// Only triggered when a client sends an oversized or non-JPEG image.
pub async fn store_external_thumbnail( pub async fn store_external_thumbnail(
&self, &self,
file_id: &str, file_id: &str,
@@ -281,24 +282,23 @@ impl ThumbnailService {
let max_dim = size.max_dimension(); let max_dim = size.max_dimension();
// Validate + optionally re-encode in blocking thread // Validate + optionally re-encode in blocking thread
let webp_bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, ThumbnailError> { let jpeg_bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, ThumbnailError> {
// ── Fast path: already a correctly-sized WebP ───────────── // ── Fast path: already a correctly-sized JPEG ─────────────
// WebP files start with RIFF....WEBP. Read dimensions from // JPEG files start with SOI marker 0xFF 0xD8.
// the header without a full decode (~0 CPU). if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
if data.len() >= 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
if let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(&data)) if let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(&data))
.with_guessed_format() .with_guessed_format()
{ {
if let Ok((w, h)) = reader.into_dimensions() { if let Ok((w, h)) = reader.into_dimensions() {
if w <= max_dim && h <= max_dim { 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()); 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) let img = image::load_from_memory(&data)
.map_err(|e| ThumbnailError::ImageError(format!("Invalid image data: {e}")))?; .map_err(|e| ThumbnailError::ImageError(format!("Invalid image data: {e}")))?;
@@ -316,15 +316,17 @@ impl ThumbnailService {
img img
}; };
let rgb = img.to_rgb8();
let mut buffer = Vec::new(); 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()))?; .map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
Ok(buffer) Ok(buffer)
}) })
.await .await
.map_err(|e| ThumbnailError::TaskError(e.to_string()))??; .map_err(|e| ThumbnailError::TaskError(e.to_string()))??;
let bytes = Bytes::from(webp_bytes); let bytes = Bytes::from(jpeg_bytes);
// Save to disk // Save to disk
let thumb_path = self.get_thumbnail_path(file_id, size); 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); 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(); let mut buffer = Vec::new();
thumbnail let encoder = JpegEncoder::new_with_quality(&mut buffer, 80);
.write_to(&mut std::io::Cursor::new(&mut buffer), ImageFormat::WebP) rgb.write_with_encoder(encoder)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?; .map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
Ok(buffer) Ok(buffer)
@@ -513,9 +517,10 @@ impl ThumbnailService {
}; };
let thumb = img.resize(new_w, new_h, filter); let thumb = img.resize(new_w, new_h, filter);
let rgb = thumb.to_rgb8();
let mut buf = Vec::new(); let mut buf = Vec::new();
thumb let encoder = JpegEncoder::new_with_quality(&mut buf, 80);
.write_to(&mut std::io::Cursor::new(&mut buf), ImageFormat::WebP) rgb.write_with_encoder(encoder)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?; .map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
Ok((size, Bytes::from(buf))) Ok((size, Bytes::from(buf)))
@@ -40,10 +40,10 @@ async fn generate_thumbnail_from_blob_path() {
let thumb_bytes = result.expect("thumbnail generation should succeed 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"); 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!( assert!(
thumb_bytes.len() > 12 && &thumb_bytes[0..4] == b"RIFF", thumb_bytes.len() > 2 && thumb_bytes[0] == 0xFF && thumb_bytes[1] == 0xD8,
"output should be WebP format" "output should be JPEG format"
); );
} }
+2 -2
View File
@@ -359,7 +359,7 @@ impl FileHandler {
{ {
return Response::builder() return Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/webp") .header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CONTENT_LENGTH, data.len()) .header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, &etag) .header(header::ETAG, &etag)
@@ -412,7 +412,7 @@ impl FileHandler {
Ok(data) => { Ok(data) => {
Response::builder() Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/webp") .header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CONTENT_LENGTH, data.len()) .header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, &etag) .header(header::ETAG, &etag)
+3 -3
View File
@@ -268,9 +268,9 @@ const photosView = {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Try WebP first, fall back to JPEG // JPEG: explicit quality control, universally supported,
const mimeType = typeof canvas.toBlob === 'function' // and server stores as-is when dimensions fit (zero re-encode).
? 'image/webp' : 'image/jpeg'; const mimeType = 'image/jpeg';
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (!blob) { if (!blob) {