e7b85e56e2
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>
85 lines
4.5 KiB
Markdown
85 lines
4.5 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
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.
|