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:
@@ -3,6 +3,8 @@ use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::application::ports::thumbnail_ports::ThumbnailFormat;
|
||||
|
||||
use super::thumbnail_service::{ThumbnailService, ThumbnailSize};
|
||||
|
||||
/// Minimal valid 1x1 red PNG (68 bytes).
|
||||
@@ -46,6 +48,7 @@ async fn generate_thumbnail_from_blob_path() {
|
||||
"test-file-id",
|
||||
"ab1234567890",
|
||||
ThumbnailSize::Icon,
|
||||
ThumbnailFormat::Jpeg,
|
||||
&blob_path,
|
||||
)
|
||||
.await;
|
||||
@@ -78,6 +81,7 @@ async fn generate_thumbnail_nonexistent_path_returns_error() {
|
||||
"missing-id",
|
||||
"nonexistent-hash",
|
||||
ThumbnailSize::Icon,
|
||||
ThumbnailFormat::Jpeg,
|
||||
&bad_path,
|
||||
)
|
||||
.await;
|
||||
@@ -105,6 +109,7 @@ async fn generate_thumbnail_from_blob_bytes() {
|
||||
"bytes-file-id",
|
||||
"bytes-hash-123",
|
||||
ThumbnailSize::Preview,
|
||||
ThumbnailFormat::Jpeg,
|
||||
Bytes::from(tiny_png()),
|
||||
)
|
||||
.await;
|
||||
|
||||
Reference in New Issue
Block a user