feath(ui): photo lib now using thumbnail library
This commit is contained in:
+1
-1
@@ -1339,7 +1339,7 @@ const ui = {
|
|||||||
thumb.addEventListener('error', () => {
|
thumb.addEventListener('error', () => {
|
||||||
console.log(`thumbnail not found for "${file.name}", try to generate it...`);
|
console.log(`thumbnail not found for "${file.name}", try to generate it...`);
|
||||||
thumb.classList.add('hidden');
|
thumb.classList.add('hidden');
|
||||||
thumbnail.generate(file, (dataUrl) => {
|
thumbnail.queueGenerate(file, (dataUrl) => {
|
||||||
thumb.src = dataUrl;
|
thumb.src = dataUrl;
|
||||||
thumb.classList.remove('hidden');
|
thumb.classList.remove('hidden');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
|
import { thumbnail } from '../thumbnail.js';
|
||||||
import { photosLightbox } from './photosLightbox.js';
|
import { photosLightbox } from './photosLightbox.js';
|
||||||
|
|
||||||
const photosView = {
|
const photosView = {
|
||||||
@@ -28,12 +29,6 @@ const photosView = {
|
|||||||
groupMode: 'monthly',
|
groupMode: 'monthly',
|
||||||
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
|
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
|
||||||
_videoThumbCache: new Map(),
|
_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 */
|
/** @type {number} Items already rendered in the DOM */
|
||||||
_renderedCount: 0,
|
_renderedCount: 0,
|
||||||
|
|
||||||
@@ -236,7 +231,7 @@ const photosView = {
|
|||||||
const selected = this.selected.has(file.id) ? ' selected' : '';
|
const selected = this.selected.has(file.id) ? ' selected' : '';
|
||||||
const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null;
|
const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null;
|
||||||
const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`;
|
const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`;
|
||||||
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}">`;
|
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}">`;
|
||||||
h += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
|
h += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
|
||||||
h += `<img src="${thumbUrl}" loading="lazy" alt="${this._escAttr(file.name)}">`;
|
h += `<img src="${thumbUrl}" loading="lazy" alt="${this._escAttr(file.name)}">`;
|
||||||
if (isVideo) h += `<div class="video-badge"><i class="fas fa-play"></i></div>`;
|
if (isVideo) h += `<div class="video-badge"><i class="fas fa-play"></i></div>`;
|
||||||
@@ -286,131 +281,29 @@ const photosView = {
|
|||||||
img.addEventListener(
|
img.addEventListener(
|
||||||
'error',
|
'error',
|
||||||
() => {
|
() => {
|
||||||
this._enqueueVideoThumbnail(tile, img);
|
this._generateVideoThumbnail(tile, img);
|
||||||
},
|
},
|
||||||
{ once: true }
|
{ once: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Enqueue a video thumbnail decode, respecting concurrency limit. */
|
/** Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate(). */
|
||||||
_enqueueVideoThumbnail(tile, img) {
|
async _generateVideoThumbnail(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
|
|
||||||
|
|
||||||
const fileId = tile.dataset.id;
|
const fileId = tile.dataset.id;
|
||||||
const video = document.createElement('video');
|
// TODO: remove this HACK, this is not evolutive...
|
||||||
video.crossOrigin = 'anonymous';
|
const file = { id: fileId, icon_special_class: 'video-icon', name: tile.dataset.name, mime_type: tile.dataset.mime };
|
||||||
video.preload = 'metadata';
|
|
||||||
video.muted = true;
|
|
||||||
// Auth is handled via HttpOnly cookie — direct URL works
|
|
||||||
video.src = `/api/files/${fileId}`;
|
|
||||||
|
|
||||||
video.addEventListener(
|
try {
|
||||||
'loadeddata',
|
await thumbnail.queueGenerate(file, null, (previewDataUrl) => {
|
||||||
() => {
|
img.src = previewDataUrl;
|
||||||
// Seek to 25 % of duration, clamped between 0.5 s and 5 s
|
this._videoThumbCache.set(fileId, previewDataUrl);
|
||||||
video.currentTime = Math.min(5, Math.max(0.5, video.duration * 0.25));
|
});
|
||||||
},
|
// Switch to permanent server URL so the data URL can be GC'd
|
||||||
{ once: true }
|
this._videoThumbCache.set(fileId, `/api/files/${fileId}/thumbnail/preview?v=1`);
|
||||||
);
|
} catch {
|
||||||
|
// Keep generic play badge on error
|
||||||
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<String, String>} */ ({ '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 }
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Render the group mode toolbar */
|
/** Render the group mode toolbar */
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { getCsrfHeaders } from '../core/csrf.js';
|
|||||||
/** @type {typeof import('../vendors/pdf.min.d.ts') | null} */
|
/** @type {typeof import('../vendors/pdf.min.d.ts') | null} */
|
||||||
let _pdfjsLib = 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
|
* 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).
|
* 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} targetWidth
|
||||||
* @param {number} targetHeight
|
* @param {number} targetHeight
|
||||||
* @returns {Size}
|
* @returns {Size}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
computeSize(srcWidth, srcHeight, targetWidth, targetHeight) {
|
_computeSize(srcWidth, srcHeight, targetWidth, targetHeight) {
|
||||||
const srcRatio = srcWidth / srcHeight;
|
const srcRatio = srcWidth / srcHeight;
|
||||||
const targetRatio = targetWidth / targetHeight;
|
const targetRatio = targetWidth / targetHeight;
|
||||||
if (srcRatio > targetRatio) {
|
if (srcRatio > targetRatio) {
|
||||||
@@ -69,10 +73,12 @@ export const thumbnail = {
|
|||||||
* @param {number} targetWidth
|
* @param {number} targetWidth
|
||||||
* @param {number} targetHeight
|
* @param {number} targetHeight
|
||||||
* @param {ImageEncodeOptions} imageEncodeOptions
|
* @param {ImageEncodeOptions} imageEncodeOptions
|
||||||
* @returns
|
* @returns {Promise<Blob>}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
bitmapToBlob(bitmap, targetWidth, targetHeight, imageEncodeOptions) {
|
_bitmapToBlob(bitmap, targetWidth, targetHeight, imageEncodeOptions) {
|
||||||
const { width, height } = this.computeSize(bitmap.width, bitmap.height, targetWidth, targetHeight);
|
const { width, height } = this._computeSize(bitmap.width, bitmap.height, targetWidth, targetHeight);
|
||||||
const canvas = new OffscreenCanvas(width, height);
|
const canvas = new OffscreenCanvas(width, height);
|
||||||
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, width, height);
|
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, width, height);
|
||||||
return canvas.convertToBlob(imageEncodeOptions);
|
return canvas.convertToBlob(imageEncodeOptions);
|
||||||
@@ -81,9 +87,11 @@ export const thumbnail = {
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {Blob} blob
|
* @param {Blob} blob
|
||||||
* @returns
|
* @returns {Promise<any>}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
blobToDataUrl(blob) {
|
_blobToDataUrl(blob) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => resolve(reader.result);
|
reader.onload = () => resolve(reader.result);
|
||||||
@@ -96,9 +104,12 @@ export const thumbnail = {
|
|||||||
*
|
*
|
||||||
* @param {Object} file
|
* @param {Object} file
|
||||||
* @param {string} source
|
* @param {string} source
|
||||||
* @returns
|
* @returns {Promise<ImageBitmap>}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
async sourceToBitmap(file, source) {
|
async _sourceToBitmap(file, source) {
|
||||||
|
// FIXME: more efficient to use mimetype
|
||||||
switch (file.icon_special_class) {
|
switch (file.icon_special_class) {
|
||||||
case 'image-icon': {
|
case 'image-icon': {
|
||||||
const response = await fetch(source);
|
const response = await fetch(source);
|
||||||
@@ -150,18 +161,25 @@ export const thumbnail = {
|
|||||||
*
|
*
|
||||||
* @param {Object} file the source of the image
|
* @param {Object} file the source of the image
|
||||||
* @param {(dataURL: string) => void} [onIconGenerated] the callback once thumbnail is generated
|
* @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 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(
|
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) {
|
if (onIconGenerated) {
|
||||||
onIconGenerated(await this.blobToDataUrl(iconBlob));
|
onIconGenerated(await this._blobToDataUrl(iconBlob));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onPreviewGenerated) {
|
||||||
|
onPreviewGenerated(await this._blobToDataUrl(previewBlob));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -177,5 +195,35 @@ export const thumbnail = {
|
|||||||
}).then((r) => console.log(`uploaded ${size} thumbnail of ${file.name}: ${r.status}`))
|
}).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<void>}
|
||||||
|
*/
|
||||||
|
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()();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user