Files
Oxicloud/frontend/src/lib/api/endpoints/photos.ts
T
DioCrafts 5722481c4a feat(thumbnails): server-side video thumbnails via ffmpeg
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>
2026-06-21 23:23:04 +02:00

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;
}