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>
This commit is contained in:
DioCrafts
2026-06-21 23:22:39 +02:00
parent f6f4563f82
commit 5722481c4a
14 changed files with 871 additions and 168 deletions
-18
View File
@@ -114,21 +114,3 @@ export async function batchTrash(fileIds: string[]): Promise<Set<string>> {
}
return trashed;
}
/**
* Upload a generated thumbnail blob for a file at a given size. Used by the
* photos grid to persist client-generated video frames server-side.
*/
export async function uploadThumbnail(
fileId: string,
size: 'icon' | 'preview' | 'large',
blob: Blob,
contentType = 'image/jpeg'
): Promise<void> {
await apiFetch(`/api/files/${fileId}/thumbnail/${size}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...getCsrfHeaders(), 'Content-Type': contentType },
body: blob
});
}
+19 -109
View File
@@ -6,12 +6,7 @@
import { useSelection } from '$lib/composables/useSelection.svelte';
import { errorToast } from '$lib/utils/errors';
import { onMount } from 'svelte';
import {
batchTrash,
fetchPhotos,
uploadThumbnail,
type PhotoItem
} from '$lib/api/endpoints/photos';
import { batchTrash, fetchPhotos, type PhotoItem } from '$lib/api/endpoints/photos';
import { peopleEnabled } from '$lib/api/endpoints/people';
import { fileDownloadUrl, fileThumbnailUrl } from '$lib/api/endpoints/files';
import Icon from '$lib/icons/Icon.svelte';
@@ -55,9 +50,6 @@
else if (tab === 'people') void peopleView.load();
});
/** Client-generated video frame thumbnails (file id → data/URL). */
let videoThumbs = $state<Record<string, string>>({});
/** EXIF-aware timestamp (seconds → ms), matching the OLD grouping logic. */
function bucketKey(d: Date): string {
const y = d.getFullYear();
@@ -290,95 +282,17 @@
}
/**
* A tile thumbnail failed to load (no server thumbnail — e.g. SVGs, which the
* backend can't rasterise — or a transient error). Hide the broken <img> so
* the always-present placeholder shows through, and for videos kick off
* client-side frame extraction (which, on success, re-renders the tile from
* `videoThumbs`).
* A tile thumbnail failed to load — no server thumbnail yet (SVGs the backend
* can't rasterise; a video whose server-side frame extraction is still running
* or unavailable) or a transient error. Hide the broken <img> so the
* always-present placeholder (and, for videos, the play badge) shows through.
*
* Video thumbnails are now produced server-side (ffmpeg) on upload through the
* same WebP pipeline as photos — the browser no longer re-downloads the video
* to extract a frame.
*/
function onThumbError(e: Event, photo: PhotoItem) {
function onThumbError(e: Event) {
(e.currentTarget as HTMLImageElement).style.display = 'none';
if (isVideo(photo)) void generateVideoThumb(photo);
}
// ── Client-side video thumbnail generation ──────────────────────────────
// When the server has no thumbnail for a video tile the <img> errors; we
// then extract a frame with the browser's native decoder and upload it.
async function generateVideoThumb(file: PhotoItem) {
if (videoThumbs[file.id]) return;
try {
const bitmap = await frameFromVideo(`/api/files/${file.id}?inline=true`);
const SIZES: Array<['icon' | 'preview' | 'large', number, number]> = [
['icon', 150, 150],
['preview', 400, 400],
['large', 800, 800]
];
let previewData = '';
// Render the blobs and push all three sizes in parallel; `previewData`
// is captured before its upload so the local preview shows even if that
// upload fails (allSettled swallows per-size failures, as before).
await Promise.allSettled(
SIZES.map(async ([size, w, h]) => {
const blob = await bitmapToBlob(bitmap, w, h);
if (size === 'preview') previewData = await blobToDataUrl(blob);
await uploadThumbnail(file.id, size, blob);
})
);
if (previewData) videoThumbs = { ...videoThumbs, [file.id]: previewData };
} catch {
// Keep the generic play badge on failure.
}
}
function frameFromVideo(src: string): Promise<ImageBitmap> {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.src = src;
video.muted = true;
video.preload = 'metadata';
video.onloadedmetadata = () => {
video.currentTime = (video.duration || 3) / 3;
};
video.onseeked = async () => {
try {
const bitmap = await createImageBitmap(video);
video.removeAttribute('src');
video.load();
resolve(bitmap);
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
};
video.onerror = () => reject(new Error('video frame extraction failed'));
});
}
async function bitmapToBlob(bitmap: ImageBitmap, tw: number, th: number): Promise<Blob> {
const ratio = bitmap.width / bitmap.height;
const target = tw / th;
const w = ratio > target ? tw : Math.round(th * ratio);
const h = ratio > target ? Math.round(tw / ratio) : th;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, w, h);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('canvas toBlob failed'))),
'image/jpeg',
0.8
);
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('blob read failed'));
reader.readAsDataURL(blob);
});
}
onMount(() => {
@@ -540,19 +454,15 @@
it can't load (no server thumbnail, e.g. SVG), hides itself to reveal
this default rather than the browser's broken-image glyph. -->
<span class="photo-tile__placeholder" aria-hidden="true"><Icon name="file-image" /></span>
{#if videoThumbs[photo.id]}
<img src={videoThumbs[photo.id]} alt={photo.name} loading="lazy" decoding="async" />
{:else}
<img
src={fileThumbnailUrl(photo.id, 'preview')}
srcset={`${fileThumbnailUrl(photo.id, 'icon')} 150w, ${fileThumbnailUrl(photo.id, 'preview')} 400w, ${fileThumbnailUrl(photo.id, 'large')} 800w`}
sizes="(max-width: 768px) 33vw, 200px"
alt={photo.name}
loading="lazy"
decoding="async"
onerror={(e) => onThumbError(e, photo)}
/>
{/if}
<img
src={fileThumbnailUrl(photo.id, 'preview')}
srcset={`${fileThumbnailUrl(photo.id, 'icon')} 150w, ${fileThumbnailUrl(photo.id, 'preview')} 400w, ${fileThumbnailUrl(photo.id, 'large')} 800w`}
sizes="(max-width: 768px) 33vw, 200px"
alt={photo.name}
loading="lazy"
decoding="async"
onerror={onThumbError}
/>
{#if isVideo(photo)}
<span class="photo-tile__video-badge" aria-hidden="true"><Icon name="play" /></span>
{/if}