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
+37 -1
View File
@@ -49,6 +49,41 @@ impl ThumbnailSize {
}
}
/// Output encoding of a generated thumbnail.
///
/// WebP (lossy) is the primary format — ~25-30% smaller than JPEG at equal
/// quality — generated eagerly on upload and served to the ~97% of clients that
/// advertise `Accept: image/webp`. JPEG is the fallback for older clients and
/// NextCloud, generated lazily on first request and then cached like WebP.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ThumbnailFormat {
/// Lossy WebP — primary, eager.
Webp,
/// Baseline JPEG — fallback for non-WebP clients, lazy.
Jpeg,
}
impl ThumbnailFormat {
/// On-disk file extension for this format (no dot).
pub fn ext(self) -> &'static str {
match self {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
}
}
/// Pick the output format from a request `Accept` header: WebP when the
/// client advertises `image/webp`, JPEG otherwise. A plain substring check
/// is sufficient — no client sends `image/webp;q=0`, and every WebP-capable
/// browser lists it explicitly.
pub fn from_accept(accept: Option<&str>) -> Self {
match accept {
Some(a) if a.contains("image/webp") => ThumbnailFormat::Webp,
_ => ThumbnailFormat::Jpeg,
}
}
}
/// Statistics about the thumbnail cache.
#[derive(Debug, Clone)]
pub struct ThumbnailStatsDto {
@@ -107,7 +142,8 @@ pub trait ThumbnailPort: Send + Sync + 'static {
/// Store an externally-generated thumbnail (e.g. client-side video frame).
///
/// Validates the image, re-encodes to WebP, and persists to cache.
/// Validates the image and persists it as JPEG (external/video thumbnails
/// are kept JPEG-only — a tiny, non-dedup-able slice not worth a second codec).
async fn store_external_thumbnail(
&self,
file_id: &str,