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
+54 -16
View File
@@ -5,11 +5,13 @@
//! throughput). Gated behind the `bench` feature so it never touches normal
//! builds.
//!
//! The corpus is **generated deterministically** (a low-frequency gradient plus
//! seeded high-frequency xorshift noise) so it is reproducible, license-free and
//! gives the decoder/resizer realistic work without committing large binaries to
//! git. Files are written to `benches/corpus/` (git-ignored) on first run and
//! reused afterwards.
//! The corpus is **generated deterministically** as photo-realistic images
//! (per-channel sums of low-frequency 2D sinusoids — smooth, gradually-varying
//! color fields like an in-focus scene — plus mild grain), so it is
//! reproducible, license-free, and compresses the way real photos do. This
//! matters for the codec comparison: pure white noise is a high-frequency
//! pathology that wildly distorts JPEG-vs-WebP byte ratios. Files are written to
//! `benches/corpus/` (git-ignored) on first run and reused afterwards.
//!
//! Files already present on disk are **always preferred** over generation — so
//! you can drop your own real photos into `benches/corpus/` using the documented
@@ -240,22 +242,58 @@ fn generate(spec: &Spec) -> Result<Vec<u8>, String> {
}
}
/// Build a photo-like RGB image: a smooth diagonal gradient (low frequency)
/// plus seeded ±32 white noise (high frequency). Deterministic for a given
/// seed, so corpus bytes are byte-stable across runs and machines.
/// One smooth low-frequency 2D sinusoid component (a "color field").
struct Wave {
fx: f32,
fy: f32,
phase: f32,
amp: f32,
}
/// Build a **photo-realistic** RGB image: per channel, a sum of low-frequency
/// 2D sinusoids (smooth, gradually-varying color fields, like an in-focus scene)
/// plus mild ±6 grain. Unlike pure white noise, this compresses the way real
/// photos do (smooth regions JPEG/WebP handle efficiently), so the codec
/// comparison is representative rather than a high-frequency pathology.
/// Deterministic for a given seed (byte-stable corpus). Drop real photos into
/// `benches/corpus/` with the documented filenames to benchmark on real data.
fn synthesize(width: u32, height: u32, seed: u64) -> RgbImage {
let mut img = RgbImage::new(width, height);
let mut state = seed | 1; // xorshift requires a non-zero state
let (w, h) = (width.max(1), height.max(1));
let mk_waves = |state: &mut u64| -> [Wave; 4] {
std::array::from_fn(|_| Wave {
fx: 0.5 + (xorshift(state) % 7) as f32 * 0.5, // 0.5..3.5 cycles across the image
fy: 0.5 + (xorshift(state) % 7) as f32 * 0.5,
phase: (xorshift(state) % 628) as f32 / 100.0, // 0..2π
amp: 18.0 + (xorshift(state) % 42) as f32, // 18..60
})
};
let channels = [
mk_waves(&mut state),
mk_waves(&mut state),
mk_waves(&mut state),
];
let bases = [112.0f32, 124.0, 136.0]; // mid-tone per channel
let (w, h) = (width.max(1) as f32, height.max(1) as f32);
let eval = |waves: &[Wave; 4], base: f32, u: f32, v: f32| -> f32 {
let mut acc = base;
for wv in waves {
acc += wv.amp * (std::f32::consts::TAU * (wv.fx * u + wv.fy * v) + wv.phase).sin();
}
acc
};
for y in 0..height {
let gy = (y as i32 * 255 / h as i32).clamp(0, 255);
let v = y as f32 / h;
for x in 0..width {
let gx = (x as i32 * 255 / w as i32).clamp(0, 255);
let noise = (xorshift(&mut state) & 0x3F) as i32 - 32; // -32..=31
let r = (gx + noise).clamp(0, 255) as u8;
let g = (gy + noise).clamp(0, 255) as u8;
let b = (((gx + gy) / 2) + noise).clamp(0, 255) as u8;
img.put_pixel(x, y, Rgb([r, g, b]));
let u = x as f32 / w;
let grain = (xorshift(&mut state) & 0x0F) as f32 - 8.0; // ±8 fine texture
let px = std::array::from_fn(|c| {
(eval(&channels[c], bases[c], u, v) + grain).clamp(0.0, 255.0) as u8
});
img.put_pixel(x, y, Rgb(px));
}
}
img