5722481c4a
Videos now get a thumbnail generated eagerly server-side on upload, through the same WebP/blob-hash pipeline as photos — instead of the old browser path that only ran when the Photos grid first rendered a video tile, re-downloaded the whole video to seek a frame, and PUT 3 JPEGs back (and produced nothing at all for HEVC/.mov, which a browser <video> cannot decode). - New VideoFramePort (application) + FfmpegVideoFrameService / NoopVideoFrameService (infrastructure): shell out to the system ffmpeg (no compile-time libav dep), extract one representative frame as PNG, bounded by its own semaphore + a per-process timeout + kill_on_drop. Noop when ffmpeg is absent/disabled, so videos degrade gracefully to no thumbnail. - ThumbnailRefreshHook.on_file_created routes video/* to generate_video_thumbnails_background: stream the (decrypted, reassembled) blob to a size- and time-bounded temp file on the data volume, extract a frame, and reuse the shared render_and_persist_all_webp helper — so video thumbnails are WebP, blob-hash keyed (dedup'd) and content-negotiated, exactly like photos. - GET thumbnail serves the video's WebP to every client (byte-sniffed Content-Type); a genuine miss returns 204. - Config: OXICLOUD_ENABLE_VIDEO_THUMBNAILS (default true, needs ffmpeg detected at startup) + OXICLOUD_FFMPEG_PATH / _CONCURRENCY / _TIMEOUT_SECS / _MAX_MB. - Dockerfile installs ffmpeg in the runtime image. - Frontend: drop the client-side generateVideoThumb/frameFromVideo re-download path; the server is now the source of truth. Benchmark (examples/bench_video_thumbnails.rs, needs ffmpeg): 4/4 codecs incl. HEVC/.mov produce a thumbnail server-side (was 0% for HEVC); ~50-70 ms/frame in the background; ~3.9 KB preview WebP; up to ~23x less per-first-view transfer on the test corpus (far more on real multi-MB clips). Methodology in benches/VIDEO-THUMB.md. Hardening from an adversarial review: video render holds the decode_semaphore like the image path; the ffmpeg scale filter bounds both dimensions; the blob stream has a timeout; the temp file lives on the data volume; the size cap uses saturating_mul. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
4.1 KiB
TypeScript
117 lines
4.1 KiB
TypeScript
/** Photos timeline endpoint — ported from features/library/photos.js. */
|
|
import { apiFetch } from '$lib/api/client';
|
|
import { getCsrfHeaders } from '$lib/api/csrf';
|
|
import type { FileItem } from '$lib/api/types';
|
|
|
|
/**
|
|
* A timeline photo/video. Extends {@link FileItem} with the pixel dimensions the
|
|
* list endpoint returns, used by the justified (aspect-preserving) grid layout.
|
|
*/
|
|
export interface PhotoItem extends FileItem {
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
export interface PhotoPage {
|
|
items: PhotoItem[];
|
|
nextCursor: string | null;
|
|
}
|
|
|
|
/** EXIF metadata returned by `/api/files/{id}/metadata` (subset used by the lightbox). */
|
|
export interface FileMetadata {
|
|
file_id: string;
|
|
captured_at?: number;
|
|
latitude?: number | null;
|
|
longitude?: number | null;
|
|
camera_make?: string | null;
|
|
camera_model?: string | null;
|
|
orientation?: number | null;
|
|
width?: number | null;
|
|
height?: number | null;
|
|
}
|
|
|
|
/** Result of a batch trash request (200 = all, 206 = partial success). */
|
|
export interface BatchTrashResult {
|
|
successful: string[];
|
|
failed: string[];
|
|
}
|
|
|
|
/** One server-side photo cluster for the Places map (`GET /api/photos/geo`). */
|
|
export interface GeoCluster {
|
|
lng: number;
|
|
lat: number;
|
|
count: number;
|
|
sample_file_id: string;
|
|
}
|
|
|
|
/**
|
|
* Fetch geotagged-photo clusters for a viewport. The backend aggregates
|
|
* server-side on a grid keyed by zoom, so the client draws one lightweight
|
|
* marker per cluster — no client-side clustering needed. `bbox` is
|
|
* `"west,south,east,north"` in decimal degrees. Available only when the
|
|
* Places feature is enabled (otherwise the route 404s).
|
|
*/
|
|
export async function fetchPhotosGeo(bbox: string, zoom: number): Promise<GeoCluster[]> {
|
|
const res = await apiFetch(`/api/photos/geo?bbox=${encodeURIComponent(bbox)}&zoom=${zoom}`, {
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!res.ok) throw new Error(`photos geo failed: ${res.status}`);
|
|
return (await res.json()) as GeoCluster[];
|
|
}
|
|
|
|
/** Backend `MAX_BATCH_SIZE` — chunk larger selections into separate requests. */
|
|
const BATCH_CHUNK_SIZE = 1000;
|
|
|
|
/**
|
|
* Fetch one page of the photo timeline. The next-page cursor is returned in the
|
|
* `X-Next-Cursor` response header; the page is the last one when fewer than
|
|
* `limit` items come back.
|
|
*/
|
|
export async function fetchPhotos(limit = 60, before?: string | null): Promise<PhotoPage> {
|
|
let url = `/api/photos?limit=${limit}`;
|
|
if (before) url += `&before=${encodeURIComponent(before)}`;
|
|
const res = await apiFetch(url, { credentials: 'same-origin' });
|
|
if (!res.ok) throw new Error(`photos failed: ${res.status}`);
|
|
const items = (await res.json()) as PhotoItem[];
|
|
const cursor = res.headers.get('X-Next-Cursor');
|
|
return {
|
|
items: items ?? [],
|
|
nextCursor: cursor && items && items.length >= limit ? cursor : null
|
|
};
|
|
}
|
|
|
|
/** Fetch EXIF metadata for a file. Returns `null` on any error (non-critical). */
|
|
export async function fetchFileMetadata(fileId: string): Promise<FileMetadata | null> {
|
|
try {
|
|
const res = await apiFetch(`/api/files/${fileId}/metadata`, { credentials: 'same-origin' });
|
|
if (!res.ok) return null;
|
|
return (await res.json()) as FileMetadata;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move files to trash in batches via `POST /api/batch/trash`. One request per
|
|
* chunk (up to {@link BATCH_CHUNK_SIZE} ids); 200 = all trashed, 206 = partial.
|
|
* Returns the set of ids that were actually trashed across all chunks.
|
|
*/
|
|
export async function batchTrash(fileIds: string[]): Promise<Set<string>> {
|
|
const trashed = new Set<string>();
|
|
for (let i = 0; i < fileIds.length; i += BATCH_CHUNK_SIZE) {
|
|
const chunk = fileIds.slice(i, i + BATCH_CHUNK_SIZE);
|
|
const res = await apiFetch('/api/batch/trash', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
|
body: JSON.stringify({ file_ids: chunk, folder_ids: [] })
|
|
});
|
|
// 200 = all trashed, 206 = partial; both carry `successful`.
|
|
if (!res.ok && res.status !== 206) continue;
|
|
const data = (await res.json().catch(() => ({}))) as Partial<BatchTrashResult>;
|
|
const ok = Array.isArray(data?.successful) ? data.successful : chunk;
|
|
for (const id of ok) trashed.add(id);
|
|
}
|
|
return trashed;
|
|
}
|