Merge pull request #338 from EdouardVanbelle/feat/thumbnail-generation-from-client
This commit is contained in:
@@ -128,15 +128,11 @@ fn process_release(manifest_dir: &Path, static_dir: &Path, out_dir: &Path) {
|
|||||||
// ── 6. Minify ALL individual JS files in static-dist/ ────────────────────
|
// ── 6. Minify ALL individual JS files in static-dist/ ────────────────────
|
||||||
minify_tree_js(&dist_dir.join("js"));
|
minify_tree_js(&dist_dir.join("js"));
|
||||||
|
|
||||||
// ── 7. Inline theme-init.js & rewrite index.html ──────────────────────
|
// ── 7. Rewrite index.html ────────────────────────────────────────────────
|
||||||
let theme_init =
|
|
||||||
fs::read_to_string(static_dir.join("js/core/theme-init.js")).unwrap_or_default();
|
|
||||||
let theme_init_min = js_minify_safe(&theme_init);
|
|
||||||
let rewritten_index = rewrite_index_html(
|
let rewritten_index = rewrite_index_html(
|
||||||
&index_html,
|
&index_html,
|
||||||
&format!("/css/{css_name}"),
|
&format!("/css/{css_name}"),
|
||||||
&format!("/js/{js_name}"),
|
&format!("/js/{js_name}"),
|
||||||
&theme_init_min,
|
|
||||||
);
|
);
|
||||||
fs::write(dist_dir.join("index.html"), &rewritten_index).expect("write dist index.html");
|
fs::write(dist_dir.join("index.html"), &rewritten_index).expect("write dist index.html");
|
||||||
|
|
||||||
@@ -326,7 +322,13 @@ fn collect_module_deps(
|
|||||||
|
|
||||||
for rel in extract_esm_import_paths(&src) {
|
for rel in extract_esm_import_paths(&src) {
|
||||||
if rel.starts_with('.') {
|
if rel.starts_with('.') {
|
||||||
collect_module_deps(&base.join(&rel), order, seen);
|
let target = base.join(&rel);
|
||||||
|
// Skip vendor bundles: they may use top-level await or other ESM
|
||||||
|
// patterns that are incompatible with IIFE wrapping. They must be
|
||||||
|
// loaded via dynamic import() at runtime instead.
|
||||||
|
if !target.components().any(|c| c.as_os_str() == "vendors") {
|
||||||
|
collect_module_deps(&target, order, seen);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Non-relative (bare specifiers like 'react') are ignored — not used here.
|
// Non-relative (bare specifiers like 'react') are ignored — not used here.
|
||||||
}
|
}
|
||||||
@@ -660,10 +662,9 @@ fn json_minify(source: &str) -> String {
|
|||||||
|
|
||||||
/// Rewrite index.html for release:
|
/// Rewrite index.html for release:
|
||||||
/// - Collapse all `<link stylesheet href="/css/…">` into the single CSS bundle.
|
/// - Collapse all `<link stylesheet href="/css/…">` into the single CSS bundle.
|
||||||
/// - Inline `theme-init.js` as a `<script>` block.
|
|
||||||
/// - Replace all `<script type="module" src="…">` with the single JS bundle.
|
/// - Replace all `<script type="module" src="…">` with the single JS bundle.
|
||||||
/// - Leave the non-module `sw-register.js` script untouched.
|
/// - Leave `theme-init.js` and `sw-register.js` as external src references.
|
||||||
fn rewrite_index_html(html: &str, css_path: &str, js_path: &str, inline_theme_js: &str) -> String {
|
fn rewrite_index_html(html: &str, css_path: &str, js_path: &str) -> String {
|
||||||
let mut out: Vec<String> = Vec::with_capacity(html.lines().count());
|
let mut out: Vec<String> = Vec::with_capacity(html.lines().count());
|
||||||
let mut css_done = false;
|
let mut css_done = false;
|
||||||
let mut js_done = false;
|
let mut js_done = false;
|
||||||
@@ -680,12 +681,6 @@ fn rewrite_index_html(html: &str, css_path: &str, js_path: &str, inline_theme_js
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Replace sync theme-init.js with inline <script> ─────────────────
|
|
||||||
if t.starts_with("<script") && !t.contains("defer") && t.contains("theme-init") {
|
|
||||||
out.push(format!(" <script>{inline_theme_js}</script>"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Replace all type="module" scripts with single bundle ─────────────
|
// ── Replace all type="module" scripts with single bundle ─────────────
|
||||||
if t.starts_with("<script") && t.contains("type=\"module\"") && t.contains("src=\"") {
|
if t.starts_with("<script") && t.contains("type=\"module\"") && t.contains("src=\"") {
|
||||||
if !js_done {
|
if !js_done {
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
HeaderValue::from_static(
|
HeaderValue::from_static(
|
||||||
"default-src 'self'; \
|
"default-src 'self'; \
|
||||||
script-src 'self'; \
|
script-src 'self'; \
|
||||||
|
worker-src 'self'; \
|
||||||
style-src 'self' 'unsafe-inline'; \
|
style-src 'self' 'unsafe-inline'; \
|
||||||
img-src 'self' data: blob:; \
|
img-src 'self' data: blob:; \
|
||||||
media-src 'self' blob:; \
|
media-src 'self' blob:; \
|
||||||
|
|||||||
@@ -425,4 +425,7 @@
|
|||||||
--color-music-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
--color-music-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
--color-music-background: var(--color-bg-surface);
|
--color-music-background: var(--color-bg-surface);
|
||||||
--color-music-public-bg: rgba(74, 144, 217, 0.12);
|
--color-music-public-bg: rgba(74, 144, 217, 0.12);
|
||||||
|
|
||||||
|
--color-video-play: white;
|
||||||
|
--color-video-play-shadow: black;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,15 @@
|
|||||||
color: var(--color-ft-video-text);
|
color: var(--color-ft-video-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* special case for vidao, add ▶ over the thumbnail (only if not hidden = thumb loaded) */
|
||||||
|
.video-icon:has(img:not(.hidden))::before {
|
||||||
|
content: "▶";
|
||||||
|
color: var(--color-video-play);
|
||||||
|
text-shadow: 0 0 3px var(--color-video-play-shadow);
|
||||||
|
z-index: 2; /* above the img */
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.code-icon {
|
.code-icon {
|
||||||
background-color: var(--color-border);
|
background-color: var(--color-border);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,14 @@
|
|||||||
color: var(--color-badge-blue-text);
|
color: var(--color-badge-blue-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-item .file-icon > i,
|
||||||
|
.file-item .file-icon > svg {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------File list View --------------------- */
|
/* ------------------File list View --------------------- */
|
||||||
|
|
||||||
.list-header {
|
.list-header {
|
||||||
@@ -145,7 +153,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 20px;
|
font-size: 16px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -422,17 +430,14 @@
|
|||||||
.files-grid-view .file-item .file-icon {
|
.files-grid-view .file-item .file-icon {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
font-size: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.files-grid-view .file-item .file-icon > i,
|
.files-grid-view .file-item .file-icon > i,
|
||||||
.files-grid-view .file-item .file-icon > svg {
|
.files-grid-view .file-item .file-icon > svg {
|
||||||
position: absolute;
|
|
||||||
top: 5px;
|
top: 5px;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------- dragged items -----------*/
|
/* ----------------------- dragged items -----------*/
|
||||||
|
|||||||
+1
-1
@@ -243,7 +243,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input Modal (for New Folder, Rename, etc.) -->
|
<!-- Input Modal (for New Folder, Rename, etc.) -->
|
||||||
<div id="input-modal" class="modal-overlay">
|
<div id="input-modal" class="modal-overlay hidden">
|
||||||
<div class="modal-container">
|
<div class="modal-container">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-icon">
|
<div class="modal-icon">
|
||||||
|
|||||||
+13
-5
@@ -16,6 +16,7 @@ import { wopiEditor } from '../features/files/wopiEditor.js';
|
|||||||
import { favorites } from '../features/library/favorites.js';
|
import { favorites } from '../features/library/favorites.js';
|
||||||
import { recent } from '../features/library/recent.js';
|
import { recent } from '../features/library/recent.js';
|
||||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||||
|
import { thumbnail } from '../features/thumbnail.js';
|
||||||
import { sharedView } from '../views/shared/sharedView.js';
|
import { sharedView } from '../views/shared/sharedView.js';
|
||||||
import { loadFiles } from './filesView.js';
|
import { loadFiles } from './filesView.js';
|
||||||
import { updateHistory } from './main.js';
|
import { updateHistory } from './main.js';
|
||||||
@@ -1302,6 +1303,7 @@ const ui = {
|
|||||||
const formattedDate = formatDateTime(file.modified_at);
|
const formattedDate = formatDateTime(file.modified_at);
|
||||||
const isFav = favorites?.isFavorite(file.id, 'file');
|
const isFav = favorites?.isFavorite(file.id, 'file');
|
||||||
const isShared = sharedView.isShared(file.id, 'file');
|
const isShared = sharedView.isShared(file.id, 'file');
|
||||||
|
const canThumbnail = thumbnail.canHandle(file);
|
||||||
|
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'file-item';
|
el.className = 'file-item';
|
||||||
@@ -1315,7 +1317,7 @@ const ui = {
|
|||||||
<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>
|
<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<div class="file-icon ${iconSpecialClass}">
|
<div class="file-icon ${iconSpecialClass}">
|
||||||
${iconSpecialClass === 'image-icon' ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
|
${canThumbnail ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
|
||||||
<i class="${iconClass}"></i>
|
<i class="${iconClass}"></i>
|
||||||
</div>
|
</div>
|
||||||
<span>${escapeHtml(file.name)}</span>
|
<span>${escapeHtml(file.name)}</span>
|
||||||
@@ -1332,11 +1334,17 @@ const ui = {
|
|||||||
<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>
|
<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
var thumb = el.querySelector('.file-thumb');
|
var thumb = /** @type {HTMLImageElement} */ (el.querySelector('.file-thumb'));
|
||||||
if (thumb)
|
if (thumb) {
|
||||||
thumb.addEventListener('error', function () {
|
thumb.addEventListener('error', () => {
|
||||||
this.style.display = 'none';
|
console.log(`thumbnail not found for "${file.name}", try to generate it...`);
|
||||||
|
thumb.classList.add('hidden');
|
||||||
|
thumbnail.queueGenerate(file, (dataUrl) => {
|
||||||
|
thumb.src = dataUrl;
|
||||||
|
thumb.classList.remove('hidden');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
this._bindStarClick(el);
|
this._bindStarClick(el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
|
|
||||||
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';
|
||||||
|
|
||||||
|
/** @import {FileInfo} from '../../core/types.js' */
|
||||||
|
|
||||||
const photosView = {
|
const photosView = {
|
||||||
/** @type {Array} All loaded photo items */
|
/** @type {Array} All loaded photo items */
|
||||||
items: [],
|
items: [],
|
||||||
@@ -28,12 +31,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,
|
||||||
|
|
||||||
@@ -58,7 +55,7 @@ const photosView = {
|
|||||||
this._container = el;
|
this._container = el;
|
||||||
}
|
}
|
||||||
if (!this._initialized) {
|
if (!this._initialized) {
|
||||||
this.groupMode = localStorage.getItem('oxicloud-photos-group') || 'monthly';
|
this.groupMode = /** @type {'daily'|'monthly'|'yearly'} */ (localStorage.getItem('oxicloud-photos-group')) || 'monthly';
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -236,7 +233,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 +283,29 @@ const photosView = {
|
|||||||
img.addEventListener(
|
img.addEventListener(
|
||||||
'error',
|
'error',
|
||||||
() => {
|
() => {
|
||||||
this._enqueueVideoThumbnail(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);
|
this._generateVideoThumbnail(tile, img);
|
||||||
} else {
|
},
|
||||||
this._decodeQueue.push({ tile, img });
|
{ once: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Process next item in the decode queue. */
|
/** Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate(). */
|
||||||
_drainDecodeQueue() {
|
async _generateVideoThumbnail(tile, img) {
|
||||||
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 = /** @type {FileInfo} */ ({ 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));
|
|
||||||
},
|
|
||||||
{ 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<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 */
|
|
||||||
});
|
});
|
||||||
|
// Switch to permanent server URL so the data URL can be GC'd
|
||||||
// Release video resources
|
this._videoThumbCache.set(fileId, `/api/files/${fileId}/thumbnail/preview?v=1`);
|
||||||
video.src = '';
|
} catch {
|
||||||
video.load();
|
// Keep generic play badge on error
|
||||||
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 */
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { getCsrfHeaders } from '../core/csrf.js';
|
||||||
|
|
||||||
|
/** @import {FileInfo} from '../core/types.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).
|
||||||
|
* @returns {Promise<typeof import('../vendors/pdf.min.d.ts')>}
|
||||||
|
*/
|
||||||
|
async function getPdfjsLib() {
|
||||||
|
if (_pdfjsLib) return _pdfjsLib;
|
||||||
|
_pdfjsLib = await import('/js/vendors/pdf.min.mjs');
|
||||||
|
_pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs';
|
||||||
|
return _pdfjsLib;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const thumbnail = {
|
||||||
|
SUPPORTED_MIME_TYPE: [/^image\//, /^application\/pdf$/, /^video\//],
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Object} file
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
canHandle(file) {
|
||||||
|
for (const re of this.SUPPORTED_MIME_TYPE) {
|
||||||
|
if (file.mime_type.match(re)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// TODO: use these informations from server ?
|
||||||
|
SIZES: {
|
||||||
|
icon: { width: 150, height: 150 },
|
||||||
|
preview: { width: 300, height: 300 },
|
||||||
|
large: { width: 900, height: 800 }
|
||||||
|
},
|
||||||
|
|
||||||
|
// note: server moved to jpeg q=80 for images
|
||||||
|
// FORMAT: 'image/webp',
|
||||||
|
// QUALITY: 0.85,
|
||||||
|
FORMAT: 'image/jpeg',
|
||||||
|
QUALITY: 0.8,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Size
|
||||||
|
* @property {number} width
|
||||||
|
* @property {number} height
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} srcWidth
|
||||||
|
* @param {number} srcHeight
|
||||||
|
* @param {number} targetWidth
|
||||||
|
* @param {number} targetHeight
|
||||||
|
* @returns {Size}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_computeSize(srcWidth, srcHeight, targetWidth, targetHeight) {
|
||||||
|
const srcRatio = srcWidth / srcHeight;
|
||||||
|
const targetRatio = targetWidth / targetHeight;
|
||||||
|
if (srcRatio > targetRatio) {
|
||||||
|
return { width: targetWidth, height: Math.round(targetWidth / srcRatio) };
|
||||||
|
} else {
|
||||||
|
return { width: Math.round(targetHeight * srcRatio), height: targetHeight };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {ImageBitmap} bitmap
|
||||||
|
* @param {number} targetWidth
|
||||||
|
* @param {number} targetHeight
|
||||||
|
* @param {ImageEncodeOptions} imageEncodeOptions
|
||||||
|
* @returns {Promise<Blob>}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_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);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Blob} blob
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_blobToDataUrl(blob) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {FileInfo} file
|
||||||
|
* @param {string} source
|
||||||
|
* @returns {Promise<ImageBitmap>}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _sourceToBitmap(file, source) {
|
||||||
|
// FIXME: more efficient to use mimetype
|
||||||
|
if (file.mime_type.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 (file.mime_type === '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 (file.mime_type.startsWith('video/')) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.src = source;
|
||||||
|
video.muted = true;
|
||||||
|
video.preload = 'metadata';
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
// seek to 1/3 of video to take snapshot
|
||||||
|
video.currentTime = video.duration / 3;
|
||||||
|
};
|
||||||
|
video.onseeked = async () => {
|
||||||
|
const bitmap = await createImageBitmap(video);
|
||||||
|
video.pause();
|
||||||
|
video.removeAttribute('src'); // hack to close network connection
|
||||||
|
video.load();
|
||||||
|
resolve(bitmap);
|
||||||
|
};
|
||||||
|
video.onerror = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`unsupported mime type: ${file.mime_type} for file ${file.name}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generateThumbnail and update image
|
||||||
|
*
|
||||||
|
* @param {Object} file the source of the image
|
||||||
|
* @param {((dataURL: string) => void) | null} [onIconGenerated] the callback once thumbnail is generated
|
||||||
|
* @param {((dataURL: string) => void) | null} [onPreviewGenerated] the callback once thumbnail is generated
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _generate(file, onIconGenerated, onPreviewGenerated) {
|
||||||
|
const source = `${window.location.origin}/api/files/${file.id}`;
|
||||||
|
|
||||||
|
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 }))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onIconGenerated) {
|
||||||
|
onIconGenerated(await this._blobToDataUrl(iconBlob));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onPreviewGenerated) {
|
||||||
|
onPreviewGenerated(await this._blobToDataUrl(previewBlob));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
[
|
||||||
|
['icon', iconBlob],
|
||||||
|
['preview', previewBlob],
|
||||||
|
['large', largeBlob]
|
||||||
|
].map(([size, blob]) =>
|
||||||
|
fetch(`${window.location.origin}/api/files/${file.id}/thumbnail/${size}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...getCsrfHeaders(), 'Content-Type': this.FORMAT },
|
||||||
|
body: blob
|
||||||
|
}).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 {FileInfo} 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);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Event) {
|
||||||
|
console.warn(`generation of thumbnail for ${file.name} failed: `, err.target.error);
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
console.warn(`generation of thumbnail for ${file.name} failed: `, err.message);
|
||||||
|
} else {
|
||||||
|
console.warn(`generation of thumbnail for ${file.name} failed: `, err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._activeGenerates--;
|
||||||
|
if (this._generateQueue.length > 0) {
|
||||||
|
this._generateQueue.shift()();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Vendored
+21
File diff suppressed because one or more lines are too long
Vendored
+21
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user