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:
@@ -24,6 +24,7 @@ use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use oxicloud::application::ports::thumbnail_ports::ThumbnailFormat;
|
||||
use oxicloud::bench_support::{self, CorpusCase};
|
||||
use oxicloud::infrastructure::services::thumbnail_service::{ThumbnailService, ThumbnailSize};
|
||||
|
||||
@@ -328,6 +329,160 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Table E1: WebP quality sweep vs JPEG q80 (find the equal-SSIM q) ---
|
||||
println!(
|
||||
"\n== E1. WebP quality sweep vs JPEG q80 — Preview/400px, SSIM vs uncompressed source =="
|
||||
);
|
||||
println!(
|
||||
" (the equal-quality bandwidth win = save% at the lowest WebP q whose ssim ≥ JPEG's)"
|
||||
);
|
||||
let sweep_q = [78.0_f32, 82.0, 86.0, 90.0];
|
||||
for case in corpus
|
||||
.iter()
|
||||
.filter(|c| matches!(c.name, "jpeg_12mp" | "jpeg_24mp" | "jpeg_48mp"))
|
||||
{
|
||||
let jpeg = ThumbnailService::bench_render_thumbnail_fmt(
|
||||
&case.bytes,
|
||||
ThumbnailSize::Preview,
|
||||
ThumbnailFormat::Jpeg,
|
||||
)
|
||||
.expect("jpeg");
|
||||
let (lj, wj, hj) = decode_to_luma(&jpeg);
|
||||
let refl = reference_luma_at(&case.bytes, wj, hj);
|
||||
let jpeg_ssim = block_ssim(&lj, &refl, wj, hj);
|
||||
println!(
|
||||
" {:<10} JPEG q80: {:>6} B ssim {:.4}",
|
||||
case.name,
|
||||
jpeg.len(),
|
||||
jpeg_ssim
|
||||
);
|
||||
for &q in &sweep_q {
|
||||
let webp =
|
||||
ThumbnailService::bench_render_webp_at(&case.bytes, ThumbnailSize::Preview, q)
|
||||
.expect("webp");
|
||||
let (lw, ww, hw) = decode_to_luma(&webp);
|
||||
let ssim_w = if (ww, hw) == (wj, hj) {
|
||||
block_ssim(&lw, &refl, wj, hj)
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
let save = 100.0 * (1.0 - webp.len() as f64 / jpeg.len() as f64);
|
||||
let flag = if ssim_w >= jpeg_ssim {
|
||||
" ← ≥ JPEG"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(
|
||||
" webp q{:>3.0}: {:>6} B ssim {:.4} {:>5.1}% smaller{}",
|
||||
q,
|
||||
webp.len(),
|
||||
ssim_w,
|
||||
save,
|
||||
flag
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Table E2: codec comparison at the production WEBP_QUALITY const ---
|
||||
println!(
|
||||
"\n== E2. Production codec (WEBP_QUALITY const): JPEG vs WebP bytes + SSIM vs source =="
|
||||
);
|
||||
println!(
|
||||
"| {:<13} | {:<8} | {:>8} | {:>8} | {:>6} | {:>9} | {:>9} |",
|
||||
"case", "size", "jpeg B", "webp B", "save%", "ssim jpg", "ssim webp"
|
||||
);
|
||||
println!(
|
||||
"|{:-<15}|{:-<10}|{:-<10}|{:-<10}|{:-<8}|{:-<11}|{:-<11}|",
|
||||
"", "", "", "", "", "", ""
|
||||
);
|
||||
let mut jpeg_total = 0u64;
|
||||
let mut webp_total = 0u64;
|
||||
for case in corpus
|
||||
.iter()
|
||||
.filter(|c| matches!(c.name, "jpeg_12mp" | "jpeg_24mp" | "jpeg_48mp"))
|
||||
{
|
||||
for &size in &[
|
||||
ThumbnailSize::Icon,
|
||||
ThumbnailSize::Preview,
|
||||
ThumbnailSize::Large,
|
||||
] {
|
||||
let jpeg = ThumbnailService::bench_render_thumbnail_fmt(
|
||||
&case.bytes,
|
||||
size,
|
||||
ThumbnailFormat::Jpeg,
|
||||
)
|
||||
.expect("jpeg encode");
|
||||
let webp = ThumbnailService::bench_render_thumbnail_fmt(
|
||||
&case.bytes,
|
||||
size,
|
||||
ThumbnailFormat::Webp,
|
||||
)
|
||||
.expect("webp encode");
|
||||
jpeg_total += jpeg.len() as u64;
|
||||
webp_total += webp.len() as u64;
|
||||
let save = 100.0 * (1.0 - webp.len() as f64 / jpeg.len() as f64);
|
||||
|
||||
// SSIM of each codec vs the uncompressed full-decode source at the
|
||||
// thumbnail's exact dims — proves WebP is equal/better quality.
|
||||
let (lj, wj, hj) = decode_to_luma(&jpeg);
|
||||
let (lw, ww, hw) = decode_to_luma(&webp);
|
||||
let (ssim_j, ssim_w) = if (wj, hj) == (ww, hw) {
|
||||
let refl = reference_luma_at(&case.bytes, wj, hj);
|
||||
(
|
||||
block_ssim(&lj, &refl, wj, hj),
|
||||
block_ssim(&lw, &refl, wj, hj),
|
||||
)
|
||||
} else {
|
||||
(f64::NAN, f64::NAN)
|
||||
};
|
||||
println!(
|
||||
"| {:<13} | {:<8} | {:>8} | {:>8} | {:>5.1}% | {:>9.4} | {:>9.4} |",
|
||||
case.name,
|
||||
format!("{size:?}"),
|
||||
jpeg.len(),
|
||||
webp.len(),
|
||||
save,
|
||||
ssim_j,
|
||||
ssim_w
|
||||
);
|
||||
}
|
||||
}
|
||||
let total_save = 100.0 * (1.0 - webp_total as f64 / jpeg_total as f64);
|
||||
println!(
|
||||
" ── all 3 sizes × 3 photos: JPEG {} B → WebP {} B = {:.1}% smaller ──",
|
||||
jpeg_total, webp_total, total_save
|
||||
);
|
||||
|
||||
// Encode time, full pipeline (decode+resize+encode), Preview/12MP, best of N.
|
||||
if let Some(c) = corpus.iter().find(|c| c.name == "jpeg_12mp") {
|
||||
let n = 50u32;
|
||||
let mut tj = f64::INFINITY;
|
||||
let mut tw = f64::INFINITY;
|
||||
for _ in 0..3 {
|
||||
let t = Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = ThumbnailService::bench_render_thumbnail_fmt(
|
||||
&c.bytes,
|
||||
ThumbnailSize::Preview,
|
||||
ThumbnailFormat::Jpeg,
|
||||
);
|
||||
}
|
||||
tj = tj.min(t.elapsed().as_secs_f64() * 1000.0 / n as f64);
|
||||
let t = Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = ThumbnailService::bench_render_thumbnail_fmt(
|
||||
&c.bytes,
|
||||
ThumbnailSize::Preview,
|
||||
ThumbnailFormat::Webp,
|
||||
);
|
||||
}
|
||||
tw = tw.min(t.elapsed().as_secs_f64() * 1000.0 / n as f64);
|
||||
}
|
||||
println!(
|
||||
" encode (Preview/12MP, full pipeline incl. shared decode): JPEG {tj:.2} ms vs WebP {tw:.2} ms"
|
||||
);
|
||||
}
|
||||
|
||||
write_json(threads, &peak_rows, &tp_rows);
|
||||
|
||||
println!(
|
||||
@@ -491,6 +646,17 @@ fn decode_to_luma(jpeg: &[u8]) -> (Vec<u8>, u32, u32) {
|
||||
(luma.into_raw(), w, h)
|
||||
}
|
||||
|
||||
/// Uncompressed ground truth: full-decode the source and resize to the exact
|
||||
/// thumbnail dims (CatmullRom), returning luma. Comparing each codec's decoded
|
||||
/// thumbnail against this isolates codec quality (no second lossy step).
|
||||
fn reference_luma_at(bytes: &[u8], w: u32, h: u32) -> Vec<u8> {
|
||||
image::load_from_memory(bytes)
|
||||
.expect("ref decode")
|
||||
.resize_exact(w, h, image::imageops::FilterType::CatmullRom)
|
||||
.to_luma8()
|
||||
.into_raw()
|
||||
}
|
||||
|
||||
/// Mean SSIM over non-overlapping 8×8 blocks (luma). 1.0 = identical.
|
||||
fn block_ssim(a: &[u8], b: &[u8], w: u32, h: u32) -> f64 {
|
||||
const C1: f64 = (0.01 * 255.0) * (0.01 * 255.0);
|
||||
|
||||
Reference in New Issue
Block a user