diff --git a/db/schema.sql b/db/schema.sql index 3c462d8c..067d4b79 100755 --- a/db/schema.sql +++ b/db/schema.sql @@ -553,7 +553,11 @@ CREATE TABLE IF NOT EXISTS storage.files ( trashed_at TIMESTAMP WITH TIME ZONE, original_folder_id UUID, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Denormalised sort key for the Photos timeline. + -- Equals COALESCE(file_metadata.captured_at, created_at). + -- Kept in sync by trg_sync_media_sort_date on file_metadata. + media_sort_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- A user cannot have two non-trashed files with the same name in the same folder @@ -567,6 +571,13 @@ CREATE INDEX IF NOT EXISTS idx_files_folder_id ON storage.files(folder_id); CREATE INDEX IF NOT EXISTS idx_files_blob_hash ON storage.files(blob_hash); CREATE INDEX IF NOT EXISTS idx_files_trashed ON storage.files(user_id, is_trashed); CREATE INDEX IF NOT EXISTS idx_files_name_search ON storage.files(user_id, name text_pattern_ops); +-- Partial covering index for the Photos timeline query. +-- Satisfies filter (user + not-trashed + media mime) AND ORDER BY media_sort_date DESC +-- in a single Index Scan — no heap filter, no Sort node, O(LIMIT) not O(N). +CREATE INDEX IF NOT EXISTS idx_files_media_timeline + ON storage.files(user_id, media_sort_date DESC) + WHERE NOT is_trashed + AND (mime_type LIKE 'image/%' OR mime_type LIKE 'video/%'); -- GIN trigram index for ILIKE substring search (search_files, suggest_files_by_name) CREATE INDEX IF NOT EXISTS idx_files_name_trgm ON storage.files USING gin (name gin_trgm_ops); @@ -662,12 +673,25 @@ CREATE TABLE IF NOT EXISTS storage.file_metadata ( created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); --- For the Photos timeline: ORDER BY captured_at DESC with cursor pagination -CREATE INDEX IF NOT EXISTS idx_file_metadata_captured - ON storage.file_metadata(captured_at DESC) WHERE captured_at IS NOT NULL; - COMMENT ON TABLE storage.file_metadata IS 'EXIF and media metadata extracted at upload time'; +-- ── Trigger: sync files.media_sort_date when EXIF captured_at is set ───── +-- Keeps the denormalised sort key in storage.files up to date so the +-- Photos timeline query never needs to JOIN file_metadata. +CREATE OR REPLACE FUNCTION storage.sync_media_sort_date() +RETURNS trigger AS $$ +BEGIN + UPDATE storage.files + SET media_sort_date = COALESCE(NEW.captured_at, created_at) + WHERE id = NEW.file_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_sync_media_sort_date + AFTER INSERT OR UPDATE OF captured_at ON storage.file_metadata + FOR EACH ROW EXECUTE FUNCTION storage.sync_media_sort_date(); + -- ── Atomic recursive folder copy (WebDAV COPY Depth: infinity) ────────── -- -- Copies the entire subtree rooted at `p_source_id` under `p_target_parent_id`. @@ -753,8 +777,8 @@ BEGIN END LOOP; -- ── Batch copy all files (zero-copy: same blob_hash) ── - INSERT INTO storage.files(name, folder_id, user_id, blob_hash, size, mime_type) - SELECT f.name, cm.new_id, f.user_id, f.blob_hash, f.size, f.mime_type + INSERT INTO storage.files(name, folder_id, user_id, blob_hash, size, mime_type, media_sort_date) + SELECT f.name, cm.new_id, f.user_id, f.blob_hash, f.size, f.mime_type, f.media_sort_date FROM storage.files f JOIN _copy_map cm ON f.folder_id = cm.old_id WHERE NOT f.is_trashed; diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 013ca21e..307056d8 100755 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -165,6 +165,12 @@ impl FileBlobReadRepository { /// /// Returns `(Vec, Vec)` where the second vec contains the /// `sort_date` epoch for each file (used as pagination cursor). + /// + /// Uses the denormalised `media_sort_date` column (synced from + /// `file_metadata.captured_at` by trigger) so no JOIN with + /// `file_metadata` is needed. The partial index + /// `idx_files_media_timeline` covers the full query: filter + ORDER BY + /// in a single Index Scan — O(LIMIT) not O(N). pub async fn list_media_files( &self, owner_id: Uuid, @@ -178,16 +184,15 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.user_id, - EXTRACT(EPOCH FROM COALESCE(fm.captured_at, fi.created_at))::bigint AS sort_date + EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id - LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id WHERE fi.user_id = $1 AND NOT fi.is_trashed AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') AND ($2::bigint IS NULL - OR EXTRACT(EPOCH FROM COALESCE(fm.captured_at, fi.created_at))::bigint < $2::bigint) - ORDER BY COALESCE(fm.captured_at, fi.created_at) DESC + OR EXTRACT(EPOCH FROM fi.media_sort_date)::bigint < $2::bigint) + ORDER BY fi.media_sort_date DESC LIMIT $3 "#, ) diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index dd8ce292..d15e7edb 100755 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -265,8 +265,13 @@ impl ThumbnailService { /// Store an externally-generated thumbnail (e.g. client-side video frame). /// - /// Validates the image data, re-encodes to WebP for cache consistency, - /// and persists to both disk and in-memory cache. + /// **Fast path**: if the payload is already a valid WebP whose dimensions + /// fit within the target size, it is stored as-is — zero decode, zero + /// encode. The browser pre-scales the canvas to 400 px, so this fast + /// path is hit on every normal video-thumbnail upload. + /// + /// **Slow path**: decode → optional resize → re-encode to WebP. Only + /// triggered when a client sends an oversized or non-WebP image. pub async fn store_external_thumbnail( &self, file_id: &str, @@ -275,12 +280,28 @@ impl ThumbnailService { ) -> Result { let max_dim = size.max_dimension(); - // Validate + re-encode in blocking thread (tiny image, ~1 ms) + // Validate + optionally re-encode in blocking thread let webp_bytes = tokio::task::spawn_blocking(move || -> Result, ThumbnailError> { + // ── Fast path: already a correctly-sized WebP ───────────── + // WebP files start with RIFF....WEBP. Read dimensions from + // the header without a full decode (~0 CPU). + if data.len() >= 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" { + if let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(&data)) + .with_guessed_format() + { + if let Ok((w, h)) = reader.into_dimensions() { + if w <= max_dim && h <= max_dim { + // Already WebP at correct size — zero-copy store + return Ok(data.to_vec()); + } + } + } + } + + // ── Slow path: decode, resize, re-encode ───────────────── let img = image::load_from_memory(&data) .map_err(|e| ThumbnailError::ImageError(format!("Invalid image data: {e}")))?; - // Resize if larger than target size let (w, h) = (img.width(), img.height()); let img = if w > max_dim || h > max_dim { let filter = FilterType::CatmullRom; diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index f8e4fa03..7f9c3f71 100755 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -257,11 +257,16 @@ const photosView = { }, { once: true }); video.addEventListener('seeked', () => { + // Pre-scale to thumbnail size in the browser — saves ~22× RAM, + // ~15× bandwidth, and lets the server skip resize entirely. + const MAX_THUMB = 400; // must match ThumbnailSize::Preview + const scale = Math.min(MAX_THUMB / video.videoWidth, + MAX_THUMB / video.videoHeight, 1); const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; + canvas.width = Math.round(video.videoWidth * scale); + canvas.height = Math.round(video.videoHeight * scale); const ctx = canvas.getContext('2d'); - ctx.drawImage(video, 0, 0); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); // Try WebP first, fall back to JPEG const mimeType = typeof canvas.toBlob === 'function'