Skip to content

Commit b8638f5

Browse files
author
Diocrafts
committed
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.
1 parent 05108d3 commit b8638f5

File tree

4 files changed

+37
-32
lines changed

4 files changed

+37
-32
lines changed

src/infrastructure/services/thumbnail_service.rs

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use 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)))

src/infrastructure/services/thumbnail_service_test.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ async fn generate_thumbnail_from_blob_path() {
4040
let thumb_bytes = result.expect("thumbnail generation should succeed from blob path");
4141
assert!(!thumb_bytes.is_empty(), "thumbnail bytes must not be empty");
4242

43-
// Verify it's valid WebP (starts with "RIFF" magic)
43+
// Verify it's valid JPEG (starts with SOI marker 0xFF 0xD8)
4444
assert!(
45-
thumb_bytes.len() > 12 && &thumb_bytes[0..4] == b"RIFF",
46-
"output should be WebP format"
45+
thumb_bytes.len() > 2 && thumb_bytes[0] == 0xFF && thumb_bytes[1] == 0xD8,
46+
"output should be JPEG format"
4747
);
4848
}
4949

src/interfaces/api/handlers/file_handler.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ impl FileHandler {
359359
{
360360
return Response::builder()
361361
.status(StatusCode::OK)
362-
.header(header::CONTENT_TYPE, "image/webp")
362+
.header(header::CONTENT_TYPE, "image/jpeg")
363363
.header(header::CONTENT_LENGTH, data.len())
364364
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
365365
.header(header::ETAG, &etag)
@@ -412,7 +412,7 @@ impl FileHandler {
412412
Ok(data) => {
413413
Response::builder()
414414
.status(StatusCode::OK)
415-
.header(header::CONTENT_TYPE, "image/webp")
415+
.header(header::CONTENT_TYPE, "image/jpeg")
416416
.header(header::CONTENT_LENGTH, data.len())
417417
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
418418
.header(header::ETAG, &etag)

static/js/features/library/photos.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,9 @@ const photosView = {
268268
const ctx = canvas.getContext('2d');
269269
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
270270

271-
// Try WebP first, fall back to JPEG
272-
const mimeType = typeof canvas.toBlob === 'function'
273-
? 'image/webp' : 'image/jpeg';
271+
// JPEG: explicit quality control, universally supported,
272+
// and server stores as-is when dimensions fit (zero re-encode).
273+
const mimeType = 'image/jpeg';
274274

275275
canvas.toBlob((blob) => {
276276
if (!blob) {

0 commit comments

Comments
 (0)