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:
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user