Files
Oxicloud/src/application/ports/thumbnail_ports.rs
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

157 lines
5.1 KiB
Rust
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.
//! Thumbnail Port - Application layer abstraction for thumbnail generation.
//!
//! This module defines the port (trait) for thumbnail operations,
//! keeping the application and interface layers independent of specific
//! image processing implementations.
use crate::common::errors::DomainError;
use bytes::Bytes;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Thumbnail sizes supported by the system.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ThumbnailSize {
/// Small icon for file listings (150×150)
Icon,
/// Medium preview for gallery view (400×400)
Preview,
/// Large preview for detail view (800×800)
Large,
}
impl ThumbnailSize {
/// Get the maximum dimension for this size.
pub fn max_dimension(&self) -> u32 {
match self {
ThumbnailSize::Icon => 150,
ThumbnailSize::Preview => 400,
ThumbnailSize::Large => 800,
}
}
/// Get the directory name for this size.
pub fn dir_name(&self) -> &'static str {
match self {
ThumbnailSize::Icon => "icon",
ThumbnailSize::Preview => "preview",
ThumbnailSize::Large => "large",
}
}
/// Get all thumbnail sizes.
pub fn all() -> &'static [ThumbnailSize] {
&[
ThumbnailSize::Icon,
ThumbnailSize::Preview,
ThumbnailSize::Large,
]
}
}
/// 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 {
pub cached_thumbnails: usize,
pub cache_size_bytes: usize,
pub max_cache_bytes: usize,
}
/// Port for thumbnail generation and retrieval.
///
/// Implementations handle the actual image processing, caching,
/// and storage of thumbnails, while the application layer only
/// interacts through this abstraction.
pub trait ThumbnailPort: Send + Sync + 'static {
/// Check if a file is an image that can have thumbnails.
fn is_supported_image(&self, mime_type: &str) -> bool;
/// Get a thumbnail, generating it on-demand if needed.
///
/// `blob_hash` is the content hash used as the disk storage key
/// (dedup: identical blobs share one set of thumbnails).
async fn get_thumbnail(
&self,
file_id: &str,
blob_hash: &str,
size: ThumbnailSize,
original_path: &Path,
) -> Result<Bytes, DomainError>;
/// Generate all thumbnail sizes for a file in the background.
///
/// `blob_hash` is the content hash used as the disk storage key.
/// If thumbnails already exist for this hash, only the moka cache
/// is populated (zero CPU for image processing).
fn generate_all_sizes_background(
self: Arc<Self>,
file_id: String,
blob_hash: String,
original_path: PathBuf,
);
/// Delete all thumbnails for a file.
async fn delete_thumbnails(&self, file_id: &str) -> Result<(), DomainError>;
/// Try to get a cached thumbnail without generating one.
///
/// Returns `None` if no cached thumbnail exists on disk or in memory.
/// `blob_hash` is used to locate the file on disk. If `None`, only
/// the in-memory moka cache is checked.
async fn get_cached_thumbnail(
&self,
file_id: &str,
blob_hash: Option<&str>,
size: ThumbnailSize,
) -> Option<Bytes>;
/// Store an externally-generated thumbnail (e.g. client-side video frame).
///
/// 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,
size: ThumbnailSize,
data: Bytes,
) -> Result<Bytes, DomainError>;
/// Get cache statistics.
async fn get_stats(&self) -> ThumbnailStatsDto;
}