Files
Oxicloud/benches/WEBP.md
T
DioCrafts e7b85e56e2 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>
2026-06-21 21:16:08 +02:00

4.5 KiB
Raw Blame History

WebP thumbnails — codec comparison (bandwidth vs quality)

WebP (lossy) is the primary thumbnail codec: generated eagerly on upload and served to the ~97% of clients that advertise Accept: image/webp. JPEG is the lazy fallback for older clients and NextCloud. This doc records the before/after that justified the change and the WEBP_QUALITY choice.

What it buys

For "hundreds of photos", thumbnail bytes on the wire are the dominant cost. WebP at q82 ships visually-equivalent thumbnails (SSIM within ~0.005 of JPEG q80, imperceptible at thumbnail scale) for ~65% fewer bytes on the bench corpus.

⚠️ Honest caveat — the corpus is smooth. The synthetic corpus is now photo-realistic (summed low-frequency sinusoids = smooth color fields + mild grain), which compresses far more like a real photo than the old white-noise corpus did. But it has no hard edges / text / foliage — exactly the high-frequency content where both codecs grow and the WebP-vs-JPEG ratio narrows toward the classic ~25–40%. So treat ~65% as an upper bound and ~25–40% as the realistic real-photo expectation. Drop real photos into benches/corpus/ (same filenames) to measure on real data.

Reproduce

cargo run --release --features bench --example bench_thumbnails_mem
# Tables E1 (WebP quality sweep) and E2 (production codec) at the bottom.

SSIM is mean over non-overlapping 8×8 luma blocks vs the uncompressed full-decode source resized to the thumbnail's exact dims (reference_luma_at), so it isolates codec fidelity (no second lossy step). Note this 8×8 metric slightly favours JPEG's 8×8 DCT blocks, so WebP's SSIM reads a hair low.

E2 — production codec at WEBP_QUALITY = 82 (14 cores)

case size jpeg B webp B save% ssim jpeg ssim webp
jpeg_12mp Icon 4608 1966 57.3% 0.9960 0.9916
jpeg_12mp Preview 17564 6614 62.3% 0.9931 0.9871
jpeg_12mp Large 47254 15580 67.0% 0.9865 0.9808
jpeg_24mp Icon 5518 2588 53.1% 0.9970 0.9935
jpeg_24mp Preview 19294 7556 60.8% 0.9949 0.9904
jpeg_24mp Large 51705 18904 63.4% 0.9918 0.9863
jpeg_48mp Icon 4554 1870 58.9% 0.9957 0.9895
jpeg_48mp Preview 16567 5704 65.6% 0.9930 0.9866
jpeg_48mp Large 45938 12540 72.7% 0.9910 0.9866

Total: JPEG 213.0 KB → WebP 73.3 KB = 65.6% smaller. WebP SSIM trails JPEG by ≤0.006 everywhere — imperceptible at thumbnail scale.

Encode (Preview/12 MP, full pipeline incl. the shared decode): JPEG 34.4 ms vs WebP 39.5 ms (+5 ms, +15%). The WebP encoder is marginally slower but the cost is paid once, eagerly, in the background generator — it never sits in the request path (served thumbnails are cache hits).

E1 — why q82 (quality sweep, Preview/400px)

Even at q90 WebP's 8×8-block SSIM stays a touch under JPEG q80 (the metric favours JPEG's DCT grid), but the gap is ≤0.008 at the 0.99 level while the byte savings are 50–68%. q82 is the chosen balance: SSIM 0.987–0.990 (within ~0.005 of JPEG, imperceptible) at ~60–66% fewer bytes.

source JPEG q80 (B / ssim) webp q78 webp q82 webp q86 webp q90
jpeg_12mp 17564 / 0.9931 −65% 0.9858 −62% 0.9871 −58% 0.9900 −51% 0.9912
jpeg_24mp 19294 / 0.9949 −64% 0.9891 −61% 0.9904 −57% 0.9921 −50% 0.9930
jpeg_48mp 16567 / 0.9930 −68% 0.9855 −66% 0.9866 −61% 0.9888 −55% 0.9911

Tune WEBP_QUALITY (in thumbnail_service.rs) up for more fidelity, down for more bandwidth savings.

How it's served (Strategy B)

  • Eager: on upload the background generator renders all 3 sizes as WebP ({blob_hash}.webp).
  • Lazy fallback: a request without Accept: image/webp (or NextCloud, which pins JPEG) generates {blob_hash}.jpg on first hit, then caches it like WebP.
  • Negotiation: GET /api/files/{id}/thumbnail/{size} reads Accept, serves WebP or JPEG, sets Vary: Accept on every response (incl. 304) and a format-keyed ETag so shared caches never hand the wrong codec to a client. Content-Type is byte-sniffed (infer), so it always matches the bytes.
  • Dedup, the moka cache (keyed by (file_id, size, format)), and cleanup all carry both formats.