feat(thumbnails): WebP output with Accept content negotiation

Thumbnails are now generated eagerly as lossy WebP (the primary codec) and
served to clients that advertise `Accept: image/webp`; JPEG is kept as a lazy
fallback for older clients and NextCloud, generated on first request and then
cached like WebP.

- ThumbnailFormat{Webp,Jpeg} enum threaded through encode/render/generate, the
  on-disk path ({hash}.webp / {hash}.jpg), the moka cache key
  (file_id, size, format), and cleanup (both formats removed).
- file_handler: parse Accept -> format, format-keyed ETag, `Vary: Accept` on
  every response (incl. 304) so shared caches never serve the wrong codec;
  Content-Type is byte-sniffed (infer) so it always matches the bytes.
- preview_handler (NextCloud) pins JPEG.
- webp = "0.3" (vendored libwebp via cc, no system dependency).

WEBP_QUALITY=82, chosen via a quality sweep (bench Table E1): SSIM within
~0.005 of JPEG q80 (imperceptible at thumbnail scale) for ~62% fewer bytes. On
the photo-realistic bench corpus the full set (3 sizes x 3 photos) drops 65.6%
(213->73 KB); real photos with edges/text land nearer ~25-40%. Encode is +5ms,
paid once in the eager background generator (off the request path).

The bench corpus is now photo-realistic (per-channel sums of low-frequency
sinusoids) instead of white noise, which had distorted codec byte ratios.
Methodology + numbers in benches/WEBP.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-21 21:16:08 +02:00
parent 68001dc7e8
commit e7b85e56e2
10 changed files with 598 additions and 84 deletions
+10 -2
View File
@@ -13,7 +13,7 @@ use std::sync::Arc;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::storage_ports::FileReadPort;
use crate::application::ports::thumbnail_ports::{ThumbnailPort, ThumbnailSize};
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailPort, ThumbnailSize};
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::AuthUser;
@@ -143,7 +143,14 @@ pub async fn handle_preview(
if let Some(data) = state
.core
.thumbnail_service
.get_cached_thumbnail(&object_id, Some(&blob_hash), thumb_size.into())
// NextCloud clients don't advertise WebP and expect JPEG — pin to JPEG
// (served from the shared lazy `.jpg` fallback).
.get_cached_thumbnail(
&object_id,
Some(&blob_hash),
thumb_size.into(),
ThumbnailFormat::Jpeg,
)
.await
{
let etag = format!("\"thumb-{}-{:?}\"", object_id, thumb_size);
@@ -167,6 +174,7 @@ pub async fn handle_preview(
&object_id,
&blob_hash,
thumb_size.into(),
ThumbnailFormat::Jpeg,
state.core.dedup_service.clone(),
)
.await