From 1d2085457eefc4db52abe7b424bb7e29fade364e Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 4 May 2026 10:11:14 +0200 Subject: [PATCH] feath(ui): photo lib now using thumbnail library --- static/js/app/ui.js | 2 +- static/js/features/library/photos.js | 141 ++++----------------------- static/js/features/thumbnail.js | 72 +++++++++++--- 3 files changed, 78 insertions(+), 137 deletions(-) diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 71c86be7..f9c1b3b2 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -1339,7 +1339,7 @@ const ui = { thumb.addEventListener('error', () => { console.log(`thumbnail not found for "${file.name}", try to generate it...`); thumb.classList.add('hidden'); - thumbnail.generate(file, (dataUrl) => { + thumbnail.queueGenerate(file, (dataUrl) => { thumb.src = dataUrl; thumb.classList.remove('hidden'); }); diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index 9b405779..e59e0579 100644 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -5,6 +5,7 @@ import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; +import { thumbnail } from '../thumbnail.js'; import { photosLightbox } from './photosLightbox.js'; const photosView = { @@ -28,12 +29,6 @@ const photosView = { groupMode: 'monthly', /** @type {Map} 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: [], /** @type {number} Items already rendered in the DOM */ _renderedCount: 0, @@ -236,7 +231,7 @@ const photosView = { const selected = this.selected.has(file.id) ? ' selected' : ''; const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null; const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`; - let h = `
`; + let h = `
`; h += `
`; h += `${this._escAttr(file.name)}`; if (isVideo) h += `
`; @@ -286,131 +281,29 @@ const photosView = { img.addEventListener( 'error', () => { - this._enqueueVideoThumbnail(tile, img); + this._generateVideoThumbnail(tile, img); }, { 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 - * thumbnail, then upload the JPEG to the server for caching. */ - _generateVideoThumbnail(tile, img) { - // TODO: use thumbnail.js s common lib - + /** Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate(). */ + async _generateVideoThumbnail(tile, img) { const fileId = tile.dataset.id; - const video = document.createElement('video'); - video.crossOrigin = 'anonymous'; - video.preload = 'metadata'; - video.muted = true; - // Auth is handled via HttpOnly cookie — direct URL works - video.src = `/api/files/${fileId}`; + // TODO: remove this HACK, this is not evolutive... + const file = { id: fileId, icon_special_class: 'video-icon', name: tile.dataset.name, mime_type: tile.dataset.mime }; - video.addEventListener( - 'loadeddata', - () => { - // Seek to 25 % of duration, clamped between 0.5 s and 5 s - video.currentTime = Math.min(5, Math.max(0.5, video.duration * 0.25)); - }, - { once: true } - ); - - video.addEventListener( - 'seeked', - () => { - // Pre-scale to thumbnail size in the browser — saves ~22× RAM, - // ~15× bandwidth, and lets the server skip resize entirely. - const MAX_THUMB = 400; // must match ThumbnailSize::Preview - const scale = Math.min(MAX_THUMB / video.videoWidth, MAX_THUMB / video.videoHeight, 1); - const canvas = document.createElement('canvas'); - canvas.width = Math.round(video.videoWidth * scale); - canvas.height = Math.round(video.videoHeight * scale); - const ctx = canvas.getContext('2d'); - ctx?.drawImage(video, 0, 0, canvas.width, canvas.height); - - // JPEG: explicit quality control, universally supported, - // and server stores as-is when dimensions fit (zero re-encode). - const mimeType = 'image/jpeg'; - - canvas.toBlob( - (blob) => { - if (!blob) { - this._drainDecodeQueue(); - return; - } - - // Show immediately in the tile - const url = URL.createObjectURL(blob); - img.src = url; - // Cache locally so re-renders are instant - this._videoThumbCache.set(fileId, url); - - // Upload to server for permanent caching - const token = localStorage.getItem('token') || sessionStorage.getItem('token'); - const headers = /** @type {Record} */ ({ 'Content-Type': blob.type, ...getCsrfHeaders() }); - if (token) headers.Authorization = `Bearer ${token}`; - - fetch(`/api/files/${fileId}/thumbnail/preview`, { - method: 'PUT', - headers, - credentials: 'same-origin', - 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 JPEG from the server. - const serverUrl = `/api/files/${fileId}/thumbnail/preview?v=1`; - this._videoThumbCache.set(fileId, serverUrl); - } - }) - .catch(() => { - /* best-effort */ - }); - - // Release video resources - video.src = ''; - video.load(); - this._drainDecodeQueue(); - }, - mimeType, - 0.8 - ); - }, - { once: true } - ); - - // If the video can't be loaded at all, keep the generic play badge - video.addEventListener( - 'error', - () => { - video.src = ''; - video.load(); - this._drainDecodeQueue(); - }, - { once: true } - ); + try { + await thumbnail.queueGenerate(file, null, (previewDataUrl) => { + img.src = previewDataUrl; + this._videoThumbCache.set(fileId, previewDataUrl); + }); + // Switch to permanent server URL so the data URL can be GC'd + this._videoThumbCache.set(fileId, `/api/files/${fileId}/thumbnail/preview?v=1`); + } catch { + // Keep generic play badge on error + } }, /** Render the group mode toolbar */ diff --git a/static/js/features/thumbnail.js b/static/js/features/thumbnail.js index cf266d2c..6831167a 100644 --- a/static/js/features/thumbnail.js +++ b/static/js/features/thumbnail.js @@ -3,6 +3,8 @@ import { getCsrfHeaders } from '../core/csrf.js'; /** @type {typeof import('../vendors/pdf.min.d.ts') | null} */ let _pdfjsLib = null; +// TODO: do we need to add a max concurrncy ? + /** * Lazy-loads pdf.min.mjs on first use via dynamic import so it is never * bundled into the IIFE (it uses top-level await which breaks IIFE wrapping). @@ -52,8 +54,10 @@ export const thumbnail = { * @param {number} targetWidth * @param {number} targetHeight * @returns {Size} + * + * @private */ - computeSize(srcWidth, srcHeight, targetWidth, targetHeight) { + _computeSize(srcWidth, srcHeight, targetWidth, targetHeight) { const srcRatio = srcWidth / srcHeight; const targetRatio = targetWidth / targetHeight; if (srcRatio > targetRatio) { @@ -69,10 +73,12 @@ export const thumbnail = { * @param {number} targetWidth * @param {number} targetHeight * @param {ImageEncodeOptions} imageEncodeOptions - * @returns + * @returns {Promise} + * + * @private */ - bitmapToBlob(bitmap, targetWidth, targetHeight, imageEncodeOptions) { - const { width, height } = this.computeSize(bitmap.width, bitmap.height, targetWidth, targetHeight); + _bitmapToBlob(bitmap, targetWidth, targetHeight, imageEncodeOptions) { + const { width, height } = this._computeSize(bitmap.width, bitmap.height, targetWidth, targetHeight); const canvas = new OffscreenCanvas(width, height); canvas.getContext('2d')?.drawImage(bitmap, 0, 0, width, height); return canvas.convertToBlob(imageEncodeOptions); @@ -81,9 +87,11 @@ export const thumbnail = { /** * * @param {Blob} blob - * @returns + * @returns {Promise} + * + * @private */ - blobToDataUrl(blob) { + _blobToDataUrl(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); @@ -96,9 +104,12 @@ export const thumbnail = { * * @param {Object} file * @param {string} source - * @returns + * @returns {Promise} + * + * @private */ - async sourceToBitmap(file, source) { + async _sourceToBitmap(file, source) { + // FIXME: more efficient to use mimetype switch (file.icon_special_class) { case 'image-icon': { const response = await fetch(source); @@ -150,18 +161,25 @@ export const thumbnail = { * * @param {Object} file the source of the image * @param {(dataURL: string) => void} [onIconGenerated] the callback once thumbnail is generated + * @param {(dataURL: string) => void} [onPreviewGenerated] the callback once thumbnail is generated + * + * @private */ - async generate(file, onIconGenerated) { + async _generate(file, onIconGenerated, onPreviewGenerated) { const source = `${window.location.origin}/api/files/${file.id}`; - const bitmap = await this.sourceToBitmap(file, source); + const bitmap = await this._sourceToBitmap(file, source); const [iconBlob, previewBlob, largeBlob] = await Promise.all( - Object.values(this.SIZES).map(({ width, height }) => this.bitmapToBlob(bitmap, width, height, { type: this.FORMAT, quality: this.QUALITY })) + Object.values(this.SIZES).map(({ width, height }) => this._bitmapToBlob(bitmap, width, height, { type: this.FORMAT, quality: this.QUALITY })) ); if (onIconGenerated) { - onIconGenerated(await this.blobToDataUrl(iconBlob)); + onIconGenerated(await this._blobToDataUrl(iconBlob)); + } + + if (onPreviewGenerated) { + onPreviewGenerated(await this._blobToDataUrl(previewBlob)); } await Promise.all( @@ -177,5 +195,35 @@ export const thumbnail = { }).then((r) => console.log(`uploaded ${size} thumbnail of ${file.name}: ${r.status}`)) ) ); + }, + + MAX_CONCURRENT: 3, + _activeGenerates: 0, + /** @type {Array<() => void>} */ + _generateQueue: [], + + /** + * Concurrency-limited wrapper around generate(). + * At most MAX_CONCURRENT generations run simultaneously; excess calls are + * queued and resume automatically as slots free up. + * + * @param {Object} file + * @param {((dataURL: string) => void) | null} [onIconGenerated] + * @param {((dataURL: string) => void) | null} [onPreviewGenerated] + * @returns {Promise} + */ + async queueGenerate(file, onIconGenerated, onPreviewGenerated) { + if (this._activeGenerates >= this.MAX_CONCURRENT) { + await new Promise((resolve) => this._generateQueue.push(resolve)); + } + this._activeGenerates++; + try { + await this._generate(file, onIconGenerated, onPreviewGenerated); + } finally { + this._activeGenerates--; + if (this._generateQueue.length > 0) { + this._generateQueue.shift()(); + } + } } };