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

85 lines
4.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.