fix: instant video thumbnails on tab switch + throttle decodes

Backend:
- 204 response: add Cache-Control: no-store so browser never caches
  'no thumbnail yet' — next GET after PUT upload gets the WebP

Frontend:
- _videoThumbCache (Map): persists fileId → URL across re-renders,
  so switching tabs reuses cached URLs instantly (no re-decode)
- Render: videos with cached URL skip the 204/error/decode cycle
- After PUT succeeds: swap blob URL → server ?v=1 URL so blob is GC'd
- Concurrency throttle: max 3 simultaneous video decodes to avoid
  overwhelming network + CPU when gallery has many videos
- Decode queue: pending videos processed as slots free up
This commit is contained in:
Diocrafts
2026-03-07 19:06:37 +01:00
parent db93b48149
commit 2aeb97383c
2 changed files with 63 additions and 7 deletions
+6 -1
View File
@@ -349,7 +349,12 @@ impl FileHandler {
.unwrap() .unwrap()
.into_response(); .into_response();
} }
return StatusCode::NO_CONTENT.into_response(); return Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::CACHE_CONTROL, "no-store")
.body(Body::empty())
.unwrap()
.into_response();
} }
// Resolve the physical blob path (content-addressable storage) // Resolve the physical blob path (content-addressable storage)
+57 -6
View File
@@ -22,6 +22,14 @@ const photosView = {
_initialized: false, _initialized: false,
/** @type {'daily'|'monthly'|'yearly'} */ /** @type {'daily'|'monthly'|'yearly'} */
groupMode: 'monthly', groupMode: 'monthly',
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
_videoThumbCache: new Map(),
/** @type {number} Max concurrent video thumbnail extractions */
_maxConcurrentDecodes: 3,
/** @type {number} Currently running video decodes */
_activeDecodes: 0,
/** @type {Array} Pending video decode queue */
_decodeQueue: [],
PAGE_SIZE: 200, PAGE_SIZE: 200,
@@ -147,7 +155,12 @@ const photosView = {
for (const file of files) { for (const file of files) {
const isVideo = file.mime_type && file.mime_type.startsWith('video/'); const isVideo = file.mime_type && file.mime_type.startsWith('video/');
const selected = this.selected.has(file.id) ? ' selected' : ''; const selected = this.selected.has(file.id) ? ' selected' : '';
const thumbUrl = `/api/files/${file.id}/thumbnail/preview`; // For videos with a cached local thumb, use it directly;
// avoids the 204 → error → re-decode cycle on re-renders.
const cachedThumb = isVideo && this._videoThumbCache.has(file.id)
? this._videoThumbCache.get(file.id)
: null;
const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`;
html += `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}">`; html += `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}">`;
html += `<div class="photo-check"><i class="fas fa-check"></i></div>`; html += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
html += `<img src="${thumbUrl}" loading="lazy" alt="${this._escAttr(file.name)}">`; html += `<img src="${thumbUrl}" loading="lazy" alt="${this._escAttr(file.name)}">`;
@@ -194,16 +207,39 @@ const photosView = {
for (const tile of tiles) { for (const tile of tiles) {
const img = tile.querySelector('img'); const img = tile.querySelector('img');
if (!img) continue; if (!img) continue;
const fileId = tile.dataset.id;
// Skip if already cached locally (URL was set during render)
if (this._videoThumbCache.has(fileId)) continue;
// The server returns 204 for videos without a cached thumbnail, // The server returns 204 for videos without a cached thumbnail,
// which causes the <img> to fire 'error'. We could also detect // which causes the <img> to fire 'error'.
// a natural 0×0 image.
img.addEventListener('error', () => { img.addEventListener('error', () => {
this._generateVideoThumbnail(tile, img); this._enqueueVideoThumbnail(tile, img);
}, { once: true }); }, { once: true });
} }
}, },
/** Enqueue a video thumbnail decode, respecting concurrency limit. */
_enqueueVideoThumbnail(tile, img) {
if (this._activeDecodes < this._maxConcurrentDecodes) {
this._activeDecodes++;
this._generateVideoThumbnail(tile, img);
} else {
this._decodeQueue.push({ tile, img });
}
},
/** Process next item in the decode queue. */
_drainDecodeQueue() {
this._activeDecodes--;
if (this._decodeQueue.length > 0) {
const next = this._decodeQueue.shift();
this._activeDecodes++;
this._generateVideoThumbnail(next.tile, next.img);
}
},
/** Extract a single frame from a video and display it as the tile /** Extract a single frame from a video and display it as the tile
* thumbnail, then upload the WebP to the server for caching. */ * thumbnail, then upload the WebP to the server for caching. */
_generateVideoThumbnail(tile, img) { _generateVideoThumbnail(tile, img) {
@@ -232,13 +268,18 @@ const photosView = {
? 'image/webp' : 'image/jpeg'; ? 'image/webp' : 'image/jpeg';
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (!blob) return; if (!blob) {
this._drainDecodeQueue();
return;
}
// Show immediately in the tile // Show immediately in the tile
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
img.src = url; img.src = url;
// Cache locally so re-renders are instant
this._videoThumbCache.set(fileId, url);
// Upload to server for permanent caching (fire-and-forget) // Upload to server for permanent caching
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
|| sessionStorage.getItem('token'); || sessionStorage.getItem('token');
const headers = { 'Content-Type': blob.type }; const headers = { 'Content-Type': blob.type };
@@ -248,11 +289,20 @@ const photosView = {
method: 'PUT', method: 'PUT',
headers, headers,
body: blob, body: blob,
}).then((resp) => {
if (resp.ok) {
// Switch from blob URL to server URL so the blob
// can be garbage-collected and future loads use
// the permanently cached WebP from the server.
const serverUrl = `/api/files/${fileId}/thumbnail/preview?v=1`;
this._videoThumbCache.set(fileId, serverUrl);
}
}).catch(() => { /* best-effort */ }); }).catch(() => { /* best-effort */ });
// Release video resources // Release video resources
video.src = ''; video.src = '';
video.load(); video.load();
this._drainDecodeQueue();
}, mimeType, 0.80); }, mimeType, 0.80);
}, { once: true }); }, { once: true });
@@ -260,6 +310,7 @@ const photosView = {
video.addEventListener('error', () => { video.addEventListener('error', () => {
video.src = ''; video.src = '';
video.load(); video.load();
this._drainDecodeQueue();
}, { once: true }); }, { once: true });
}, },