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>
4.5 KiB
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}.jpgon first hit, then caches it like WebP. - Negotiation:
GET /api/files/{id}/thumbnail/{size}readsAccept, serves WebP or JPEG, setsVary: Accepton every response (incl. 304) and a format-keyed ETag so shared caches never hand the wrong codec to a client.Content-Typeis 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.