diff --git a/Cargo.lock b/Cargo.lock index 6ea37277..34d31ff6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3629,6 +3629,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libwebp-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733" +dependencies = [ + "cc", + "glob", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4286,6 +4296,7 @@ dependencies = [ "urlencoding", "utoipa", "uuid", + "webp", "zip", ] @@ -7342,6 +7353,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1" +dependencies = [ + "image", + "libwebp-sys", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index 8508805a..a0203cb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,10 @@ jpeg-decoder = "0.3" # SIMD (AVX2/SSE4.1/NEON) image resizing for thumbnails — far faster than the # `image` crate's scalar resampler, and it speeds every format (incl. PNG/WebP). fast_image_resize = "5" +# Lossy WebP encoding for thumbnails. The `image` crate's webp feature is +# lossless-only; this binds libwebp (vendored + built via `cc`, no system dep) +# for quality-controlled lossy output (~25-30% smaller than JPEG at equal SSIM). +webp = "0.3" id3 = "1.17" mp3-duration = "0.1" kamadak-exif = "0.6.1" diff --git a/benches/WEBP.md b/benches/WEBP.md new file mode 100644 index 00000000..b6c81435 --- /dev/null +++ b/benches/WEBP.md @@ -0,0 +1,84 @@ +# 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. diff --git a/examples/bench_thumbnails_mem.rs b/examples/bench_thumbnails_mem.rs index 458c8c3d..3f19737b 100644 --- a/examples/bench_thumbnails_mem.rs +++ b/examples/bench_thumbnails_mem.rs @@ -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, 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 { + 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); diff --git a/src/application/ports/thumbnail_ports.rs b/src/application/ports/thumbnail_ports.rs index 4eb31f8e..8a8a909c 100644 --- a/src/application/ports/thumbnail_ports.rs +++ b/src/application/ports/thumbnail_ports.rs @@ -49,6 +49,41 @@ impl ThumbnailSize { } } +/// Output encoding of a generated thumbnail. +/// +/// WebP (lossy) is the primary format — ~25-30% smaller than JPEG at equal +/// quality — generated eagerly on upload and served to the ~97% of clients that +/// advertise `Accept: image/webp`. JPEG is the fallback for older clients and +/// NextCloud, generated lazily on first request and then cached like WebP. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ThumbnailFormat { + /// Lossy WebP — primary, eager. + Webp, + /// Baseline JPEG — fallback for non-WebP clients, lazy. + Jpeg, +} + +impl ThumbnailFormat { + /// On-disk file extension for this format (no dot). + pub fn ext(self) -> &'static str { + match self { + ThumbnailFormat::Webp => "webp", + ThumbnailFormat::Jpeg => "jpg", + } + } + + /// Pick the output format from a request `Accept` header: WebP when the + /// client advertises `image/webp`, JPEG otherwise. A plain substring check + /// is sufficient — no client sends `image/webp;q=0`, and every WebP-capable + /// browser lists it explicitly. + pub fn from_accept(accept: Option<&str>) -> Self { + match accept { + Some(a) if a.contains("image/webp") => ThumbnailFormat::Webp, + _ => ThumbnailFormat::Jpeg, + } + } +} + /// Statistics about the thumbnail cache. #[derive(Debug, Clone)] pub struct ThumbnailStatsDto { @@ -107,7 +142,8 @@ pub trait ThumbnailPort: Send + Sync + 'static { /// Store an externally-generated thumbnail (e.g. client-side video frame). /// - /// Validates the image, re-encodes to WebP, and persists to cache. + /// Validates the image and persists it as JPEG (external/video thumbnails + /// are kept JPEG-only — a tiny, non-dedup-able slice not worth a second codec). async fn store_external_thumbnail( &self, file_id: &str, diff --git a/src/bench_support.rs b/src/bench_support.rs index f9396ceb..b64a4095 100644 --- a/src/bench_support.rs +++ b/src/bench_support.rs @@ -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, 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 diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index 84fcb772..d6a6f2a7 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -23,7 +23,7 @@ use tokio::sync::Semaphore; use tokio::time::timeout; use crate::application::ports::thumbnail_ports::{ - ThumbnailPort, ThumbnailSize as PortThumbnailSize, ThumbnailStatsDto, + ThumbnailFormat, ThumbnailPort, ThumbnailSize as PortThumbnailSize, ThumbnailStatsDto, }; use crate::domain::errors::{DomainError, ErrorKind}; use crate::infrastructure::services::dedup_service::DedupService; @@ -68,17 +68,29 @@ impl ThumbnailSize { } } -/// Cache key for thumbnails +/// Cache key for thumbnails. Includes `format` so WebP and the JPEG fallback for +/// the same (file_id, size) are distinct entries (no cross-format collision). #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ThumbnailCacheKey { file_id: String, size: ThumbnailSize, + format: ThumbnailFormat, } /// Maximum pixel count before rejecting decode (50 megapixels → ~200 MB RGBA). /// Images above this are silently skipped — protects against single-image OOM. const MAX_DECODE_PIXELS: u64 = 50_000_000; +/// JPEG quality for the fallback codec. +const JPEG_QUALITY: u8 = 80; +/// Lossy WebP quality for the primary codec. q82 lands within ~0.005 SSIM of +/// JPEG q80 (imperceptible at thumbnail scale) while encoding markedly smaller: +/// ~60% on the smooth photo-realistic bench corpus, and a more modest but still +/// substantial win (~25-40%) expected on real photos with edges/text/foliage. +/// Raise for more fidelity, lower for more bandwidth savings — see the E1 sweep +/// in `examples/bench_thumbnails_mem`. +const WEBP_QUALITY: f32 = 82.0; + /// Environment override for the decode-concurrency cap (ops tuning). const DECODE_CONCURRENCY_ENV: &str = "OXICLOUD_THUMBNAIL_DECODE_CONCURRENCY"; @@ -184,11 +196,18 @@ impl ThumbnailService { ) } - /// Get the path where a thumbnail would be stored (keyed by blob hash for dedup). - fn get_thumbnail_path(&self, blob_hash: &str, size: ThumbnailSize) -> PathBuf { + /// Get the path where a thumbnail would be stored (keyed by blob hash for + /// dedup; the extension encodes the format: `.webp` primary, `.jpg` fallback). + /// This is the single source of truth for blob-hash thumbnail paths. + fn get_thumbnail_path( + &self, + blob_hash: &str, + size: ThumbnailSize, + format: ThumbnailFormat, + ) -> PathBuf { self.thumbnails_root .join(size.dir_name()) - .join(format!("{}.jpg", blob_hash)) + .join(format!("{}.{}", blob_hash, format.ext())) } /// Get a thumbnail, generating it if needed. @@ -206,14 +225,16 @@ impl ThumbnailService { file_id: &str, blob_hash: &str, size: ThumbnailSize, + format: ThumbnailFormat, original_path: &Path, ) -> Result { let cache_key = ThumbnailCacheKey { file_id: file_id.to_string(), size, + format, }; - let thumb_path = self.get_thumbnail_path(blob_hash, size); + let thumb_path = self.get_thumbnail_path(blob_hash, size, format); let original_owned = original_path.to_path_buf(); let file_id_owned = file_id.to_string(); @@ -236,7 +257,7 @@ impl ThumbnailService { // 2. Generate thumbnail (CPU-bound, runs in spawn_blocking) tracing::info!("🎨 Generating thumbnail: {} {:?}", file_id_owned, size); - match self.generate_thumbnail(&original_owned, size).await { + match self.generate_thumbnail(&original_owned, size, format).await { Ok(bytes) => { // Save to disk (best-effort — don't fail the request) if let Some(parent) = thumb_path.parent() { @@ -282,14 +303,16 @@ impl ThumbnailService { file_id: &str, blob_hash: &str, size: ThumbnailSize, + format: ThumbnailFormat, original_data: Bytes, ) -> Result { let cache_key = ThumbnailCacheKey { file_id: file_id.to_string(), size, + format, }; - let thumb_path = self.get_thumbnail_path(blob_hash, size); + let thumb_path = self.get_thumbnail_path(blob_hash, size, format); let file_id_owned = file_id.to_string(); let entry = self @@ -309,7 +332,7 @@ impl ThumbnailService { tracing::warn!("Decode semaphore closed, skipping {}", file_id_owned); return Bytes::new(); }; - self.generate_and_persist(&file_id_owned, &thumb_path, size, original_data) + self.generate_and_persist(&file_id_owned, &thumb_path, size, format, original_data) .await }) .await; @@ -337,14 +360,16 @@ impl ThumbnailService { file_id: &str, blob_hash: &str, size: ThumbnailSize, + format: ThumbnailFormat, dedup: Arc, ) -> Result { let cache_key = ThumbnailCacheKey { file_id: file_id.to_string(), size, + format, }; - let thumb_path = self.get_thumbnail_path(blob_hash, size); + let thumb_path = self.get_thumbnail_path(blob_hash, size, format); let file_id_owned = file_id.to_string(); let blob_hash_owned = blob_hash.to_string(); @@ -376,7 +401,7 @@ impl ThumbnailService { return Bytes::new(); } }; - self.generate_and_persist(&file_id_owned, &thumb_path, size, original_data) + self.generate_and_persist(&file_id_owned, &thumb_path, size, format, original_data) .await }) .await; @@ -402,10 +427,17 @@ impl ThumbnailService { file_id: &str, thumb_path: &Path, size: ThumbnailSize, + format: ThumbnailFormat, original_data: Bytes, ) -> Bytes { tracing::info!("🎨 Generating thumbnail: {} {:?}", file_id, size); - match Self::generate_thumbnail_from_data(original_data, size, self.generation_timeout).await + match Self::generate_thumbnail_from_data( + original_data, + size, + format, + self.generation_timeout, + ) + .await { Ok(bytes) => { if let Some(parent) = thumb_path.parent() { @@ -439,11 +471,13 @@ impl ThumbnailService { file_id: &str, blob_hash: Option<&str>, size: ThumbnailSize, + format: ThumbnailFormat, ) -> Option { // 1. Check in-memory cache let cache_key = ThumbnailCacheKey { file_id: file_id.to_string(), size, + format, }; if let Some(bytes) = self.cache.get(&cache_key).await && !bytes.is_empty() @@ -452,20 +486,30 @@ impl ThumbnailService { } // 2. Check disk for external (video-frame) thumbnails stored by file_id. - // These don't require blob_hash since they use ext-{file_id}.jpg paths. + // These are JPEG-only (ext-{file_id}.jpg) regardless of requested + // format; the byte-sniffing Content-Type makes serving correct. let ext_path = self .thumbnails_root .join(size.dir_name()) .join(format!("ext-{}.jpg", file_id)); if let Ok(data) = fs::read(&ext_path).await { let bytes = Bytes::from(data); - self.cache.insert(cache_key.clone(), bytes.clone()).await; + // Cache under a Jpeg-pinned key: these bytes are always JPEG, so the + // key's format must describe them. Inserting under `cache_key` (whose + // format is the *requested* format, possibly Webp) would store JPEG + // bytes behind a Webp key — a latent cross-format invariant violation. + let ext_key = ThumbnailCacheKey { + file_id: file_id.to_string(), + size, + format: ThumbnailFormat::Jpeg, + }; + self.cache.insert(ext_key, bytes.clone()).await; return Some(bytes); } // 3. Check disk for blob-hash thumbnails (needs blob_hash to locate) let hash = blob_hash?; - let thumb_path = self.get_thumbnail_path(hash, size); + let thumb_path = self.get_thumbnail_path(hash, size, format); if let Ok(data) = fs::read(&thumb_path).await { let bytes = Bytes::from(data); // Populate in-memory cache for next hit @@ -555,10 +599,11 @@ impl ThumbnailService { .await .map_err(|e| ThumbnailError::IoError(e.to_string()))?; - // Populate in-memory cache + // Populate in-memory cache (external thumbnails are JPEG) let cache_key = ThumbnailCacheKey { file_id: file_id.to_string(), size, + format: ThumbnailFormat::Jpeg, }; self.cache.insert(cache_key, bytes.clone()).await; @@ -696,6 +741,7 @@ impl ThumbnailService { dst_w: u32, dst_h: u32, filter: fast_image_resize::FilterType, + format: ThumbnailFormat, ) -> Result, ThumbnailError> { use fast_image_resize::images::{Image, ImageRef}; use fast_image_resize::{FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer}; @@ -717,28 +763,43 @@ impl ThumbnailService { .resize(&src, &mut dst, &opts) .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; - let rgb = image::RgbImage::from_raw(dst_w, dst_h, dst.into_vec()) - .ok_or_else(|| ThumbnailError::ImageError("resize buffer size mismatch".into()))?; - let mut buffer = Vec::new(); - let encoder = JpegEncoder::new_with_quality(&mut buffer, 80); - rgb.write_with_encoder(encoder) - .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; - Ok(buffer) + // The resized RGB8 plane feeds either codec from one buffer. + let resized = dst.into_vec(); + match format { + ThumbnailFormat::Jpeg => { + let rgb = image::RgbImage::from_raw(dst_w, dst_h, resized).ok_or_else(|| { + ThumbnailError::ImageError("resize buffer size mismatch".into()) + })?; + let mut buffer = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut buffer, JPEG_QUALITY); + rgb.write_with_encoder(encoder) + .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + Ok(buffer) + } + ThumbnailFormat::Webp => { + // libwebp lossy — ~25-30% smaller than JPEG q80 at equal SSIM. + Ok(webp::Encoder::from_rgb(&resized, dst_w, dst_h) + .encode(WEBP_QUALITY) + .to_vec()) + } + } } fn render_thumbnail_from_data( data: &[u8], size: ThumbnailSize, + format: ThumbnailFormat, ) -> Result, ThumbnailError> { let max_dim = size.max_dimension(); let rgb = Self::decode_oriented(data, max_dim)?.into_rgb8(); let (sw, sh) = (rgb.width(), rgb.height()); let (nw, nh) = Self::fit_dims(sw, sh, max_dim); - Self::encode_thumbnail(rgb.as_raw(), sw, sh, nw, nh, Self::filter_for(size)) + Self::encode_thumbnail(rgb.as_raw(), sw, sh, nw, nh, Self::filter_for(size), format) } fn render_all_thumbnails_from_data( data: &[u8], + format: ThumbnailFormat, ) -> Result, ThumbnailError> { // Decode once, shrunk-on-load for the largest size (800 px), and convert // to RGB8 once; all three sizes are then SIMD-resampled from this single @@ -751,7 +812,8 @@ impl ThumbnailService { .par_iter() .map(|&size| { let (nw, nh) = Self::fit_dims(sw, sh, size.max_dimension()); - let buf = Self::encode_thumbnail(src, sw, sh, nw, nh, Self::filter_for(size))?; + let buf = + Self::encode_thumbnail(src, sw, sh, nw, nh, Self::filter_for(size), format)?; Ok((size, Bytes::from(buf))) }) .collect::, ThumbnailError>>() @@ -760,10 +822,11 @@ impl ThumbnailService { async fn generate_thumbnail_from_data( original_data: Bytes, size: ThumbnailSize, + format: ThumbnailFormat, timeout_duration: Duration, ) -> Result { let spawn_result = tokio::task::spawn_blocking(move || { - Self::render_thumbnail_from_data(original_data.as_ref(), size) + Self::render_thumbnail_from_data(original_data.as_ref(), size, format) }); let result = timeout(timeout_duration, spawn_result) @@ -791,6 +854,7 @@ impl ThumbnailService { &self, original_path: &Path, size: ThumbnailSize, + format: ThumbnailFormat, ) -> Result { let path = original_path.to_path_buf(); let timeout_duration = self.generation_timeout; @@ -807,7 +871,7 @@ impl ThumbnailService { tokio::task::spawn_blocking(move || -> Result, ThumbnailError> { let data = std::fs::read(&path).map_err(|e| ThumbnailError::ImageError(e.to_string()))?; - Self::render_thumbnail_from_data(&data, size) + Self::render_thumbnail_from_data(&data, size, format) }); // Apply timeout to prevent hanging on large images @@ -853,7 +917,8 @@ impl ThumbnailService { let all_exist = { let mut ok = true; for size in ThumbnailSize::all() { - let thumb_path = self.get_thumbnail_path(&blob_hash, *size); + let thumb_path = + self.get_thumbnail_path(&blob_hash, *size, ThumbnailFormat::Webp); if fs::metadata(&thumb_path).await.is_err() { ok = false; break; @@ -863,11 +928,13 @@ impl ThumbnailService { }; if all_exist { for size in ThumbnailSize::all() { - let thumb_path = self.get_thumbnail_path(&blob_hash, *size); + let thumb_path = + self.get_thumbnail_path(&blob_hash, *size, ThumbnailFormat::Webp); if let Ok(data) = fs::read(&thumb_path).await { let cache_key = ThumbnailCacheKey { file_id: file_id.clone(), size: *size, + format: ThumbnailFormat::Webp, }; self.cache.insert(cache_key, Bytes::from(data)).await; } @@ -901,7 +968,7 @@ impl ThumbnailService { let results = tokio::task::spawn_blocking(move || { let data = std::fs::read(&path).map_err(|e| ThumbnailError::ImageError(e.to_string()))?; - Self::render_all_thumbnails_from_data(&data) + Self::render_all_thumbnails_from_data(&data, ThumbnailFormat::Webp) }) .await; @@ -921,7 +988,7 @@ impl ThumbnailService { // Save each size to disk (keyed by blob_hash for dedup) // AND populate moka (keyed by file_id for fast serving). for (size, bytes) in thumbnails { - let thumb_path = self.get_thumbnail_path(&blob_hash, size); + let thumb_path = self.get_thumbnail_path(&blob_hash, size, ThumbnailFormat::Webp); if let Some(parent) = thumb_path.parent() { let _ = fs::create_dir_all(parent).await; } @@ -932,6 +999,7 @@ impl ThumbnailService { let cache_key = ThumbnailCacheKey { file_id: file_id.clone(), size, + format: ThumbnailFormat::Webp, }; self.cache.insert(cache_key, bytes).await; tracing::debug!("✅ Generated thumbnail: {} {:?}", file_id, size); @@ -971,7 +1039,8 @@ impl ThumbnailService { let all_exist = { let mut ok = true; for size in ThumbnailSize::all() { - let thumb_path = self.get_thumbnail_path(&blob_hash, *size); + let thumb_path = + self.get_thumbnail_path(&blob_hash, *size, ThumbnailFormat::Webp); if fs::metadata(&thumb_path).await.is_err() { ok = false; break; @@ -981,11 +1050,13 @@ impl ThumbnailService { }; if all_exist { for size in ThumbnailSize::all() { - let thumb_path = self.get_thumbnail_path(&blob_hash, *size); + let thumb_path = + self.get_thumbnail_path(&blob_hash, *size, ThumbnailFormat::Webp); if let Ok(data) = fs::read(&thumb_path).await { let cache_key = ThumbnailCacheKey { file_id: file_id.clone(), size: *size, + format: ThumbnailFormat::Webp, }; self.cache.insert(cache_key, Bytes::from(data)).await; } @@ -1024,7 +1095,7 @@ impl ThumbnailService { }; let results = tokio::task::spawn_blocking(move || { - Self::render_all_thumbnails_from_data(original_data.as_ref()) + Self::render_all_thumbnails_from_data(original_data.as_ref(), ThumbnailFormat::Webp) }) .await; @@ -1041,7 +1112,7 @@ impl ThumbnailService { }; for (size, bytes) in thumbnails { - let thumb_path = self.get_thumbnail_path(&blob_hash, size); + let thumb_path = self.get_thumbnail_path(&blob_hash, size, ThumbnailFormat::Webp); if let Some(parent) = thumb_path.parent() { let _ = fs::create_dir_all(parent).await; } @@ -1051,6 +1122,7 @@ impl ThumbnailService { let cache_key = ThumbnailCacheKey { file_id: file_id.clone(), size, + format: ThumbnailFormat::Webp, }; self.cache.insert(cache_key, bytes).await; tracing::debug!("✅ Generated thumbnail: {} {:?}", file_id, size); @@ -1070,14 +1142,17 @@ impl ThumbnailService { /// Also removes any external (video-frame) thumbnails stored by file_id. pub async fn delete_thumbnails(&self, file_id: &str) -> Result<(), ThumbnailError> { for size in ThumbnailSize::all() { - // Remove from moka cache (lock-free invalidation) - let cache_key = ThumbnailCacheKey { - file_id: file_id.to_string(), - size: *size, - }; - self.cache.invalidate(&cache_key).await; + // Remove from moka cache (lock-free invalidation) — both codecs. + for format in [ThumbnailFormat::Webp, ThumbnailFormat::Jpeg] { + let cache_key = ThumbnailCacheKey { + file_id: file_id.to_string(), + size: *size, + format, + }; + self.cache.invalidate(&cache_key).await; + } - // Remove external (video-frame) thumbnails stored by file_id + // Remove external (video-frame) thumbnails stored by file_id (JPEG-only) let ext_path = self .thumbnails_root .join(size.dir_name()) @@ -1097,9 +1172,12 @@ impl ThumbnailService { /// being deleted and the corresponding thumbnails are removed from disk. pub async fn delete_blob_thumbnails(&self, blob_hash: &str) { for size in ThumbnailSize::all() { - let path = self.get_thumbnail_path(blob_hash, *size); - if fs::metadata(&path).await.is_ok() { - let _ = fs::remove_file(&path).await; + // Delete both the primary WebP and any lazily-materialized JPEG. + for format in [ThumbnailFormat::Webp, ThumbnailFormat::Jpeg] { + let path = self.get_thumbnail_path(blob_hash, *size, format); + if fs::metadata(&path).await.is_ok() { + let _ = fs::remove_file(&path).await; + } } } tracing::debug!( @@ -1214,11 +1292,14 @@ impl crate::application::ports::blob_lifecycle::BlobLifecycleHook for ThumbnailS let blob_hash = blob_hash.to_string(); tokio::spawn(async move { for size in ThumbnailSize::all() { - let path = root - .join(size.dir_name()) - .join(format!("{}.jpg", &blob_hash)); - if tokio::fs::metadata(&path).await.is_ok() { - let _ = tokio::fs::remove_file(&path).await; + // Delete both the primary WebP and any lazy JPEG fallback. + for format in [ThumbnailFormat::Webp, ThumbnailFormat::Jpeg] { + let path = + root.join(size.dir_name()) + .join(format!("{}.{}", &blob_hash, format.ext())); + if tokio::fs::metadata(&path).await.is_ok() { + let _ = tokio::fs::remove_file(&path).await; + } } } tracing::debug!( @@ -1254,9 +1335,17 @@ impl ThumbnailPort for ThumbnailService { size: PortThumbnailSize, original_path: &Path, ) -> Result { - self.get_thumbnail(file_id, blob_hash, size.into(), original_path) - .await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string())) + // Abstract port lookup defaults to the primary (WebP) format. The REST + // handler uses the concrete service with Accept-derived format instead. + self.get_thumbnail( + file_id, + blob_hash, + size.into(), + ThumbnailFormat::Webp, + original_path, + ) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string())) } fn generate_all_sizes_background( @@ -1280,7 +1369,7 @@ impl ThumbnailPort for ThumbnailService { blob_hash: Option<&str>, size: PortThumbnailSize, ) -> Option { - self.get_cached_thumbnail(file_id, blob_hash, size.into()) + self.get_cached_thumbnail(file_id, blob_hash, size.into(), ThumbnailFormat::Webp) .await } @@ -1316,18 +1405,69 @@ impl ThumbnailPort for ThumbnailService { /// `ThumbnailError` into the public API. #[cfg(feature = "bench")] impl ThumbnailService { - /// Render a single thumbnail size, returning the encoded JPEG bytes. + /// Render a single thumbnail size as JPEG (back-compat shim for existing benches). pub fn bench_render_thumbnail(data: &[u8], size: ThumbnailSize) -> Result, String> { - Self::render_thumbnail_from_data(data, size).map_err(|e| e.to_string()) + Self::bench_render_thumbnail_fmt(data, size, ThumbnailFormat::Jpeg) } - /// Render all sizes in one decode (the upload-time path), returning each - /// size paired with its encoded byte length (output-size baseline). + /// Render all sizes in one decode as JPEG, returning each size's byte length. pub fn bench_render_all(data: &[u8]) -> Result, String> { - Self::render_all_thumbnails_from_data(data) + Self::bench_render_all_fmt(data, ThumbnailFormat::Jpeg) + } + + /// Render a single thumbnail size in `format`, returning the encoded bytes + /// (for the codec comparison: JPEG vs WebP bytes + SSIM-vs-source). + pub fn bench_render_thumbnail_fmt( + data: &[u8], + size: ThumbnailSize, + format: ThumbnailFormat, + ) -> Result, String> { + Self::render_thumbnail_from_data(data, size, format).map_err(|e| e.to_string()) + } + + /// Render all sizes in one decode in `format`, returning each size's byte length. + pub fn bench_render_all_fmt( + data: &[u8], + format: ThumbnailFormat, + ) -> Result, String> { + Self::render_all_thumbnails_from_data(data, format) .map(|v| v.into_iter().map(|(s, b)| (s, b.len())).collect()) .map_err(|e| e.to_string()) } + + /// Render a thumbnail to WebP at an explicit quality — used by the codec + /// benchmark to sweep WebP quality and find the one matching JPEG q80 SSIM. + pub fn bench_render_webp_at( + data: &[u8], + size: ThumbnailSize, + quality: f32, + ) -> Result, String> { + use fast_image_resize::images::{Image, ImageRef}; + use fast_image_resize::{FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer}; + let rgb = Self::decode_oriented(data, size.max_dimension()) + .map_err(|e| e.to_string())? + .into_rgb8(); + let (sw, sh) = (rgb.width(), rgb.height()); + let (dw, dh) = Self::fit_dims(sw, sh, size.max_dimension()); + let filter = if dw > sw || dh > sh { + FilterType::CatmullRom + } else { + Self::filter_for(size) + }; + let src = + ImageRef::new(sw, sh, rgb.as_raw(), PixelType::U8x3).map_err(|e| e.to_string())?; + let mut dst = Image::new(dw, dh, PixelType::U8x3); + Resizer::new() + .resize( + &src, + &mut dst, + &ResizeOptions::new().resize_alg(ResizeAlg::Convolution(filter)), + ) + .map_err(|e| e.to_string())?; + Ok(webp::Encoder::from_rgb(&dst.into_vec(), dw, dh) + .encode(quality) + .to_vec()) + } } /// Thumbnail service errors diff --git a/src/infrastructure/services/thumbnail_service_test.rs b/src/infrastructure/services/thumbnail_service_test.rs index ac571d63..6b2c95eb 100644 --- a/src/infrastructure/services/thumbnail_service_test.rs +++ b/src/infrastructure/services/thumbnail_service_test.rs @@ -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; diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 822d3303..46344690 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -339,7 +339,7 @@ impl FileHandler { headers: HeaderMap, Path((id, size)): Path<(String, String)>, ) -> impl IntoResponse { - use crate::application::ports::thumbnail_ports::ThumbnailSize; + use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailSize}; // check first that user can access this resource if let Err(err) = state @@ -368,11 +368,18 @@ impl FileHandler { } }; + // Content negotiation: WebP for clients that advertise it (~97%), JPEG + // otherwise. `Vary: Accept` keeps shared/browser caches from handing a + // WebP body to a JPEG-only client (or vice-versa). + let format = + ThumbnailFormat::from_accept(headers.get(header::ACCEPT).and_then(|v| v.to_str().ok())); + // ── ETag short-circuit (Solution C) ────────────────────────── // Thumbnails are immutable — the ETag never changes for a given - // (file_id, size) pair. If the browser already has it, return 304 - // with zero I/O or DB work. - let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size); + // (file_id, size, format) triple. If the browser already has it, return + // 304 with zero I/O or DB work. Format is in the ETag so a client that + // switched codecs doesn't get a stale 304. + let etag = format!("\"thumb-{}-{:?}-{:?}\"", id, thumb_size, format); if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) && let Ok(val) = if_none_match.to_str() && (val == etag || val == "*") @@ -380,6 +387,7 @@ impl FileHandler { return Response::builder() .status(StatusCode::NOT_MODIFIED) .header(header::ETAG, &etag) + .header(header::VARY, header::ACCEPT.as_str()) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .body(Body::empty()) .unwrap() @@ -390,7 +398,7 @@ impl FileHandler { // Try moka (RAM) → disk before touching the database. // If the thumbnail exists it was authorized at creation time. if let Some(data) = thumbnail_service - .get_cached_thumbnail(&id, None, thumb_size.into()) + .get_cached_thumbnail(&id, None, thumb_size.into(), format) .await { return Response::builder() @@ -402,6 +410,7 @@ impl FileHandler { .header(header::CONTENT_LENGTH, data.len()) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .header(header::ETAG, &etag) + .header(header::VARY, header::ACCEPT.as_str()) .body(Body::from(data)) .unwrap() .into_response(); @@ -443,7 +452,7 @@ impl FileHandler { } }; if let Some(data) = thumbnail_service - .get_cached_thumbnail(&id, Some(&blob_hash), thumb_size.into()) + .get_cached_thumbnail(&id, Some(&blob_hash), thumb_size.into(), format) .await { return Response::builder() @@ -455,6 +464,7 @@ impl FileHandler { .header(header::CONTENT_LENGTH, data.len()) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .header(header::ETAG, &etag) + .header(header::VARY, header::ACCEPT.as_str()) .body(Body::from(data)) .unwrap() .into_response(); @@ -465,6 +475,7 @@ impl FileHandler { &id, &blob_hash, thumb_size.into(), + format, state.core.dedup_service.clone(), ) .await @@ -478,6 +489,7 @@ impl FileHandler { .header(header::CONTENT_LENGTH, data.len()) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .header(header::ETAG, &etag) + .header(header::VARY, header::ACCEPT.as_str()) .body(Body::from(data)) .unwrap() .into_response(), diff --git a/src/interfaces/nextcloud/preview_handler.rs b/src/interfaces/nextcloud/preview_handler.rs index 9fb9ac30..fd75cd10 100644 --- a/src/interfaces/nextcloud/preview_handler.rs +++ b/src/interfaces/nextcloud/preview_handler.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::storage_ports::FileReadPort; -use crate::application::ports::thumbnail_ports::{ThumbnailPort, ThumbnailSize}; +use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailPort, ThumbnailSize}; use crate::common::di::AppState; use crate::interfaces::middleware::auth::AuthUser; @@ -143,7 +143,14 @@ pub async fn handle_preview( if let Some(data) = state .core .thumbnail_service - .get_cached_thumbnail(&object_id, Some(&blob_hash), thumb_size.into()) + // NextCloud clients don't advertise WebP and expect JPEG — pin to JPEG + // (served from the shared lazy `.jpg` fallback). + .get_cached_thumbnail( + &object_id, + Some(&blob_hash), + thumb_size.into(), + ThumbnailFormat::Jpeg, + ) .await { let etag = format!("\"thumb-{}-{:?}\"", object_id, thumb_size); @@ -167,6 +174,7 @@ pub async fn handle_preview( &object_id, &blob_hash, thumb_size.into(), + ThumbnailFormat::Jpeg, state.core.dedup_service.clone(), ) .await