feat: client-side video thumbnail generation (no ffmpeg)

Backend:
- get_thumbnail: for non-images, serve cached thumb or 204 (not 400)
- store_external_thumbnail: validate + re-encode to WebP + persist
- get_cached_thumbnail: memory → disk lookup without generation
- PUT /api/files/{id}/thumbnail/{size} endpoint for client uploads
- ThumbnailPort trait: add get_cached_thumbnail + store_external_thumbnail

Frontend (photos.js):
- On <img> error for video tiles, use <video> + <canvas> to extract
  a frame at 25% duration using browser's native codec
- Show frame immediately via blob URL
- Fire-and-forget PUT to server for permanent caching
- Subsequent visits serve cached WebP instantly (no re-extraction)

Zero server-side dependencies — CPU distributed across clients.
This commit is contained in:
Diocrafts
2026-03-07 18:55:44 +01:00
parent 54ce2d085a
commit db93b48149
5 changed files with 306 additions and 8 deletions
+21
View File
@@ -84,6 +84,27 @@ pub trait ThumbnailPort: Send + Sync + 'static {
/// Delete all thumbnails for a file.
async fn delete_thumbnails(&self, file_id: &str) -> Result<(), DomainError>;
/// Try to get a cached thumbnail without generating one.
///
/// Returns `None` if no cached thumbnail exists on disk or in memory.
/// Used for non-image file types (videos) where thumbnails are
/// generated client-side and uploaded.
async fn get_cached_thumbnail(
&self,
file_id: &str,
size: ThumbnailSize,
) -> Option<Bytes>;
/// Store an externally-generated thumbnail (e.g. client-side video frame).
///
/// Validates the image, re-encodes to WebP, and persists to cache.
async fn store_external_thumbnail(
&self,
file_id: &str,
size: ThumbnailSize,
data: Bytes,
) -> Result<Bytes, DomainError>;
/// Get cache statistics.
async fn get_stats(&self) -> ThumbnailStatsDto;
}