221c1f31b0
Backend (each change benchmark-gated with BEFORE replicas + equivalence gates; see examples/bench_round11_micro.rs, bench_round11_queries.rs, bench_log_writer.rs and benches/ROUND11.md — final numbers land in the follow-up doc commit): - StoragePath re-representation: single canonical joined String, segments derived on demand; File/Folder drop the duplicated path_string field (4000→1000 allocs per 500-row listing page) - Display classifier fusion: classify_display shares one stack-lowered extension across the three decision trees; call sites in FileDto, folder/favorites/recent handlers, trash, path-resolver (+ interning where Arc::from was still used) - /status.php and /openapi.json memoized into OnceLock<Bytes> (openapi rebuilt a 171 KiB spec per request: 2.8 ms → 18 ns) - NC upload-session PROPFIND: write! + pre-sized body + stack RFC2822 dates (2.3-2.6x, 2582→772 allocs at 256 chunks) - REST download: dead FileDto clone removed (capture mime/size + move) - CalendarEventDto/TrashedItem into_parts moves (11 KiB ical_data memcpy gone per CalDAV row); CardDAV getlastmodified stack render - 4xx path: borrowed ErrorResponse serialize, ErrorKind::as_str, not_found/already_exists clone kill - vCard emit via write!; search page moved out with into_iter skip/take; content-hit UUIDs parsed once; group last-user check via HashSet - RateLimiter: lock-free get + insert (and_upsert_with variant REJECTED by benchmark); CSRF token borrow-compare + borrowed cookie extraction - Thumbnail/preview ETags built from as_str (Debug-identical bytes) - Encrypted backend: encrypt_in_place_detached single-buffer write path, chunk-sized reserve in collect_stream; retry labels made lazy - PG: deferred upload registration 3→1 round-trips (persist_file CTE template); direct_grant_cache for Calendar/AddressBook/Playlist authz (single-flight + set_role/clear_role invalidation); expand_user tokio::join!; geo clusters min(uuid)::text; recluster face assignment batched into one UNNEST update - People recluster cosine: norms precomputed once (bit-identical gate) - NC capabilities poll logs demoted to debug; tracing-appender dep added for the log-writer benchmark Frontend: - ResourceList.selectedEntries O(N)-per-toggle → id-index projection O(k log k); favorites/recent consume the batchToolbar snippet param and drop their duplicate filter + dead selectedIds mirror - Recent: star state via new favoriteIds prop — a star click no longer rebuilds all N entries - admin timeAgo >30d fallback uses the cached Intl.DateTimeFormat - vitest gates in src/lib/components/round11.bench.test.ts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ABhTEHuGujvwoodh67Kga7
179 lines
5.9 KiB
Rust
179 lines
5.9 KiB
Rust
//! 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 {
|
||
/// Stable name, byte-identical to the derived `Debug` output. Used by
|
||
/// the thumbnail/preview ETags on the hottest revalidation path — a
|
||
/// `&'static str` push beats routing through the `Debug` machinery
|
||
/// (benches/ROUND11.md §7) while keeping every already-cached client
|
||
/// ETag valid.
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
ThumbnailSize::Icon => "Icon",
|
||
ThumbnailSize::Preview => "Preview",
|
||
ThumbnailSize::Large => "Large",
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
/// Stable name, byte-identical to the derived `Debug` output (see
|
||
/// [`ThumbnailSize::as_str`] — same ETag-stability contract).
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
ThumbnailFormat::Webp => "Webp",
|
||
ThumbnailFormat::Jpeg => "Jpeg",
|
||
}
|
||
}
|
||
|
||
/// 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;
|
||
}
|