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 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<Vec<u8>, 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<Vec<u8>, 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)))
@@ -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"
);
}
+2 -2
View File
@@ -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)
+3 -3
View File
@@ -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) {