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:
@@ -26,4 +26,5 @@ pub mod thumbnail_ports;
|
||||
pub mod transcode_ports;
|
||||
pub mod trash_ports;
|
||||
pub mod user_lifecycle;
|
||||
pub mod video_frame_ports;
|
||||
pub mod zip_ports;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Video frame extraction port.
|
||||
//!
|
||||
//! Pulls a single representative still frame out of a video so the existing
|
||||
//! image thumbnail pipeline (shrink-on-load → SIMD resize → WebP → blob-hash
|
||||
//! storage → HTTP content negotiation) can treat videos exactly like photos —
|
||||
//! eagerly, server-side, on upload. Keeping this behind a port lets the
|
||||
//! composition root swap in a no-op when `ffmpeg` is absent or the feature is
|
||||
//! off, so video uploads degrade gracefully (no thumbnail) instead of erroring.
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use std::path::Path;
|
||||
|
||||
/// Extracts a representative still frame from a video file.
|
||||
///
|
||||
/// Implementations:
|
||||
/// - `FfmpegVideoFrameService` — shells out to the system `ffmpeg`, covering
|
||||
/// every container/codec (incl. HEVC/MOV, which a browser `<video>` cannot
|
||||
/// decode). Bounds its own process concurrency and per-call timeout.
|
||||
/// - `NoopVideoFrameService` — registered when `ffmpeg` is unavailable or the
|
||||
/// feature is disabled; `is_supported_video` returns false so the lifecycle
|
||||
/// hook never attempts video thumbnails.
|
||||
#[async_trait]
|
||||
pub trait VideoFramePort: Send + Sync + 'static {
|
||||
/// Whether `mime_type` is a video this extractor will attempt to thumbnail.
|
||||
/// The no-op implementation always returns false.
|
||||
fn is_supported_video(&self, mime_type: &str) -> bool;
|
||||
|
||||
/// Extract one representative frame from the video file at `path`, returning
|
||||
/// encoded **PNG** bytes ready to feed into the image thumbnail renderer.
|
||||
/// `path` must point at the decoded (decrypted, reassembled) video on disk.
|
||||
async fn extract_frame(&self, path: &Path) -> Result<Bytes, DomainError>;
|
||||
}
|
||||
Reference in New Issue
Block a user