fix(thumbnail): restore thumb gen PDD|img|video
client can generate thumbnail for PDF, image, video if not found
2 cases:
- offload CPU to client side
- permit E2EE (encryption)
This commit is contained in:
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Client-side thumbnail generation + upload.
|
||||||
|
*
|
||||||
|
* Fallback path: when the server returns 404 for a file's thumbnail
|
||||||
|
* (the mime type isn't supported server-side, e.g. PDF, or the async
|
||||||
|
* server-side generator hasn't caught up yet), the client can generate
|
||||||
|
* the three canonical sizes from the file itself and PUT them back so
|
||||||
|
* subsequent viewers hit the server thumbnail.
|
||||||
|
*
|
||||||
|
* Ported from the legacy vanilla-JS `static/js/features/thumbnail.js`
|
||||||
|
* (retired in commit 54639d46). Same shape, same behaviour:
|
||||||
|
*
|
||||||
|
* * SUPPORTED_MIME_TYPE — image/*, application/pdf, video/*.
|
||||||
|
* * SIZES — icon 150×150, preview 300×300, large 900×800.
|
||||||
|
* * FORMAT / QUALITY — JPEG q=0.8 (matches the server's own encoder).
|
||||||
|
* * MAX_CONCURRENT — 3 parallel generations, excess queued.
|
||||||
|
*
|
||||||
|
* pdf.js lives at `/vendors/pdf.min.mjs` (+ worker) — dynamically
|
||||||
|
* imported on first PDF encounter so image/video-only sessions never
|
||||||
|
* pay the ~1 MB pdf.js download.
|
||||||
|
*/
|
||||||
|
import type { FileItem } from '$lib/api/types';
|
||||||
|
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||||
|
|
||||||
|
const PDFJS_LIB_URL = '/vendors/pdf.min.mjs';
|
||||||
|
const PDFJS_WORKER_URL = '/vendors/pdf.worker.min.mjs';
|
||||||
|
|
||||||
|
// Anything that ships a runtime API surface too broad to type here without
|
||||||
|
// vendoring `@types/pdfjs-dist`; the two methods we call (`getDocument`,
|
||||||
|
// worker options) are stable across pdf.js 4.x.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let pdfjsLibPromise: Promise<any> | null = null;
|
||||||
|
let pdfWorkerWarmed = false;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function getPdfjsLib(): Promise<any> {
|
||||||
|
if (!pdfjsLibPromise) {
|
||||||
|
pdfjsLibPromise = import(/* @vite-ignore */ PDFJS_LIB_URL)
|
||||||
|
.then((lib) => {
|
||||||
|
lib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
|
||||||
|
return lib;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
pdfjsLibPromise = null; // allow retry on a later sighting
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pdfjsLibPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_MIME_TYPE = [/^image\//, /^application\/pdf$/, /^video\//];
|
||||||
|
|
||||||
|
const SIZES = {
|
||||||
|
icon: { width: 150, height: 150 },
|
||||||
|
preview: { width: 300, height: 300 },
|
||||||
|
large: { width: 900, height: 800 }
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const FORMAT = 'image/jpeg';
|
||||||
|
const QUALITY = 0.8;
|
||||||
|
|
||||||
|
const MAX_CONCURRENT = 3;
|
||||||
|
let activeGenerates = 0;
|
||||||
|
const generateQueue: Array<(release: void) => void> = [];
|
||||||
|
|
||||||
|
interface Size {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSize(
|
||||||
|
srcWidth: number,
|
||||||
|
srcHeight: number,
|
||||||
|
targetWidth: number,
|
||||||
|
targetHeight: number
|
||||||
|
): Size {
|
||||||
|
const srcRatio = srcWidth / srcHeight;
|
||||||
|
const targetRatio = targetWidth / targetHeight;
|
||||||
|
if (srcRatio > targetRatio) {
|
||||||
|
return { width: targetWidth, height: Math.round(targetWidth / srcRatio) };
|
||||||
|
}
|
||||||
|
return { width: Math.round(targetHeight * srcRatio), height: targetHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bitmapToBlob(
|
||||||
|
bitmap: ImageBitmap,
|
||||||
|
targetWidth: number,
|
||||||
|
targetHeight: number,
|
||||||
|
options: ImageEncodeOptions
|
||||||
|
): Promise<Blob> {
|
||||||
|
const { width, height } = 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(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sourceToBitmap(file: FileItem, source: string): Promise<ImageBitmap> {
|
||||||
|
const mime = file.mime_type ?? '';
|
||||||
|
if (mime.startsWith('image/')) {
|
||||||
|
const response = await fetch(source);
|
||||||
|
if (!response.ok) throw new Error(`failed to fetch: ${response.status}`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}
|
||||||
|
if (mime === 'application/pdf') {
|
||||||
|
const pdfjsLib = await getPdfjsLib();
|
||||||
|
const pdf = await pdfjsLib.getDocument(source).promise;
|
||||||
|
const page = await pdf.getPage(1);
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
|
||||||
|
return createImageBitmap(canvas);
|
||||||
|
}
|
||||||
|
if (mime.startsWith('video/')) {
|
||||||
|
return new Promise<ImageBitmap>((resolve, reject) => {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.src = source;
|
||||||
|
video.muted = true;
|
||||||
|
video.preload = 'metadata';
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
// Snapshot at 1/3 duration — skips titles/logos, still in the
|
||||||
|
// meat of the content for most videos.
|
||||||
|
video.currentTime = video.duration / 3;
|
||||||
|
};
|
||||||
|
video.onseeked = async () => {
|
||||||
|
const bitmap = await createImageBitmap(video);
|
||||||
|
video.pause();
|
||||||
|
video.removeAttribute('src'); // close the pending HTTP body
|
||||||
|
video.load();
|
||||||
|
resolve(bitmap);
|
||||||
|
};
|
||||||
|
video.onerror = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unsupported mime type: ${mime} for file ${file.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate(
|
||||||
|
file: FileItem,
|
||||||
|
onIconGenerated?: (dataUrl: string) => void,
|
||||||
|
onPreviewGenerated?: (dataUrl: string) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const source = `${window.location.origin}/api/files/${file.id}`;
|
||||||
|
const bitmap = await sourceToBitmap(file, source);
|
||||||
|
|
||||||
|
const [iconBlob, previewBlob, largeBlob] = await Promise.all(
|
||||||
|
Object.values(SIZES).map(({ width, height }) =>
|
||||||
|
bitmapToBlob(bitmap, width, height, { type: FORMAT, quality: QUALITY })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onIconGenerated) onIconGenerated(await blobToDataUrl(iconBlob));
|
||||||
|
if (onPreviewGenerated) onPreviewGenerated(await blobToDataUrl(previewBlob));
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
(
|
||||||
|
[
|
||||||
|
['icon', iconBlob],
|
||||||
|
['preview', previewBlob],
|
||||||
|
['large', largeBlob]
|
||||||
|
] as const
|
||||||
|
).map(([size, blob]) =>
|
||||||
|
fetch(`${window.location.origin}/api/files/${file.id}/thumbnail/${size}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...getCsrfHeaders(), 'Content-Type': FORMAT },
|
||||||
|
body: blob,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this file's mime type is one the client-side generator can
|
||||||
|
* handle. Callers use this to decide whether to install the fallback
|
||||||
|
* `onerror` handler on the `<img>` in the first place.
|
||||||
|
*/
|
||||||
|
export function canThumbnailClientSide(file: FileItem): boolean {
|
||||||
|
const mime = file.mime_type ?? '';
|
||||||
|
return SUPPORTED_MIME_TYPE.some((re) => re.test(mime));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget warm-up of the pdf.js stack (module + worker script).
|
||||||
|
*
|
||||||
|
* Call the moment a PDF file appears in a listing so the ~1.3 MB library
|
||||||
|
* downloads in the background while the user is still looking at the
|
||||||
|
* list — instead of stalling the first thumbnail render on it.
|
||||||
|
* Idempotent; only folders that actually contain PDFs pay the download.
|
||||||
|
*/
|
||||||
|
export function preloadPdf(): void {
|
||||||
|
getPdfjsLib().catch(() => {
|
||||||
|
/* transient failure — first real use retries */
|
||||||
|
});
|
||||||
|
if (pdfWorkerWarmed) return;
|
||||||
|
pdfWorkerWarmed = true;
|
||||||
|
fetch(PDFJS_WORKER_URL)
|
||||||
|
.then((r) => (r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||||
|
.catch(() => {
|
||||||
|
pdfWorkerWarmed = false; // allow retry on a later sighting
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concurrency-limited wrapper around `generate`. At most `MAX_CONCURRENT`
|
||||||
|
* generations run simultaneously; excess calls await a released slot.
|
||||||
|
*
|
||||||
|
* The optional callbacks receive the icon / preview data URLs the moment
|
||||||
|
* they encode locally — callers use them to paint the fallback
|
||||||
|
* immediately, before the server round-trip completes.
|
||||||
|
*/
|
||||||
|
export async function queueGenerate(
|
||||||
|
file: FileItem,
|
||||||
|
onIconGenerated?: (dataUrl: string) => void,
|
||||||
|
onPreviewGenerated?: (dataUrl: string) => void
|
||||||
|
): Promise<void> {
|
||||||
|
if (activeGenerates >= MAX_CONCURRENT) {
|
||||||
|
await new Promise<void>((resolve) => generateQueue.push(resolve));
|
||||||
|
}
|
||||||
|
activeGenerates++;
|
||||||
|
try {
|
||||||
|
await generate(file, onIconGenerated, onPreviewGenerated);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
console.warn(`thumbnail generation failed for ${file.name}:`, err.message);
|
||||||
|
} else {
|
||||||
|
console.warn(`thumbnail generation failed for ${file.name}:`, err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeGenerates--;
|
||||||
|
const next = generateQueue.shift();
|
||||||
|
if (next) next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,11 @@
|
|||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
||||||
import { gridColumns } from '$lib/utils/grid';
|
import { gridColumns } from '$lib/utils/grid';
|
||||||
|
import {
|
||||||
|
canThumbnailClientSide,
|
||||||
|
preloadPdf,
|
||||||
|
queueGenerate as queueThumbnailGenerate
|
||||||
|
} from '$lib/utils/thumbnail';
|
||||||
|
|
||||||
// File preview and the WOPI editor are heavy and only appear on demand, so
|
// File preview and the WOPI editor are heavy and only appear on demand, so
|
||||||
// their modules load the first time the user opens one (see the effects that
|
// their modules load the first time the user opens one (see the effects that
|
||||||
@@ -731,7 +736,11 @@
|
|||||||
*/
|
*/
|
||||||
function canThumbnail(file: FileItem): boolean {
|
function canThumbnail(file: FileItem): boolean {
|
||||||
const m = file.mime_type ?? '';
|
const m = file.mime_type ?? '';
|
||||||
return m.startsWith('image/') || m.startsWith('video/');
|
// PDF joins image/video: the client-side generator ported from
|
||||||
|
// the legacy vanilla frontend renders PDFs via pdf.js on the
|
||||||
|
// `<img onerror>` fallback path. Without this the img never
|
||||||
|
// mounts for PDFs and the fallback never fires.
|
||||||
|
return m.startsWith('image/') || m.startsWith('video/') || m === 'application/pdf';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Multi-select + batch ────────────────────────────────────────────────
|
// ── Multi-select + batch ────────────────────────────────────────────────
|
||||||
@@ -1918,7 +1927,20 @@
|
|||||||
src={fileThumbnailUrl(file.id)}
|
src={fileThumbnailUrl(file.id)}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = 'none')}
|
onerror={(e) => {
|
||||||
|
// Server-side thumbnail is missing (404) — try client-side
|
||||||
|
// generation for image / PDF / video and PUT the result
|
||||||
|
// back so the next viewer gets the server thumbnail.
|
||||||
|
// Ported from the legacy static/js/features/thumbnail.js.
|
||||||
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
|
img.style.display = 'none';
|
||||||
|
if (!canThumbnailClientSide(file)) return;
|
||||||
|
if (file.mime_type === 'application/pdf') preloadPdf();
|
||||||
|
void queueThumbnailGenerate(file, (dataUrl) => {
|
||||||
|
img.src = dataUrl;
|
||||||
|
img.style.display = '';
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+21
File diff suppressed because one or more lines are too long
+21
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user