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
+18 -6
View File
@@ -339,7 +339,7 @@ impl FileHandler {
headers: HeaderMap,
Path((id, size)): Path<(String, String)>,
) -> impl IntoResponse {
use crate::application::ports::thumbnail_ports::ThumbnailSize;
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailSize};
// check first that user can access this resource
if let Err(err) = state
@@ -368,11 +368,18 @@ impl FileHandler {
}
};
// Content negotiation: WebP for clients that advertise it (~97%), JPEG
// otherwise. `Vary: Accept` keeps shared/browser caches from handing a
// WebP body to a JPEG-only client (or vice-versa).
let format =
ThumbnailFormat::from_accept(headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()));
// ── ETag short-circuit (Solution C) ──────────────────────────
// Thumbnails are immutable — the ETag never changes for a given
// (file_id, size) pair. If the browser already has it, return 304
// with zero I/O or DB work.
let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size);
// (file_id, size, format) triple. If the browser already has it, return
// 304 with zero I/O or DB work. Format is in the ETag so a client that
// switched codecs doesn't get a stale 304.
let etag = format!("\"thumb-{}-{:?}-{:?}\"", id, thumb_size, format);
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH)
&& let Ok(val) = if_none_match.to_str()
&& (val == etag || val == "*")
@@ -380,6 +387,7 @@ impl FileHandler {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, &etag)
.header(header::VARY, header::ACCEPT.as_str())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.body(Body::empty())
.unwrap()
@@ -390,7 +398,7 @@ impl FileHandler {
// Try moka (RAM) → disk before touching the database.
// If the thumbnail exists it was authorized at creation time.
if let Some(data) = thumbnail_service
.get_cached_thumbnail(&id, None, thumb_size.into())
.get_cached_thumbnail(&id, None, thumb_size.into(), format)
.await
{
return Response::builder()
@@ -402,6 +410,7 @@ impl FileHandler {
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, &etag)
.header(header::VARY, header::ACCEPT.as_str())
.body(Body::from(data))
.unwrap()
.into_response();
@@ -443,7 +452,7 @@ impl FileHandler {
}
};
if let Some(data) = thumbnail_service
.get_cached_thumbnail(&id, Some(&blob_hash), thumb_size.into())
.get_cached_thumbnail(&id, Some(&blob_hash), thumb_size.into(), format)
.await
{
return Response::builder()
@@ -455,6 +464,7 @@ impl FileHandler {
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, &etag)
.header(header::VARY, header::ACCEPT.as_str())
.body(Body::from(data))
.unwrap()
.into_response();
@@ -465,6 +475,7 @@ impl FileHandler {
&id,
&blob_hash,
thumb_size.into(),
format,
state.core.dedup_service.clone(),
)
.await
@@ -478,6 +489,7 @@ impl FileHandler {
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, &etag)
.header(header::VARY, header::ACCEPT.as_str())
.body(Body::from(data))
.unwrap()
.into_response(),