Release 2.21.4: media library thumbnails and scrolling
This commit is contained in:
1 parent
1b5a32cb05
commit
b80d4aa9c7
11 files changed
+354
-42
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { countContentReferenceOwners } = require('#src/data/content-references');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
@@ -22,6 +23,8 @@ const MEDIA_MIME_TYPES = {
|
||||
|
||||
const OPTIMIZABLE_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
||||
const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384];
|
||||
const IMAGE_THUMBNAIL_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg']);
|
||||
const IMAGE_THUMBNAIL_MAX_DIMENSION = 480;
|
||||
|
||||
function getImageVariantPath(filePath, dimension) {
|
||||
const extension = path.extname(filePath);
|
||||
@@ -29,14 +32,167 @@ function getImageVariantPath(filePath, dimension) {
|
||||
return path.join(path.dirname(filePath), baseName + '.variant-' + dimension + extension);
|
||||
}
|
||||
|
||||
function getImageThumbnailPath(filePath) {
|
||||
const extension = path.extname(filePath);
|
||||
return path.join(path.dirname(filePath), path.basename(filePath, extension) + '.thumb.webp');
|
||||
}
|
||||
|
||||
function getVideoPosterPath(filePath) {
|
||||
const extension = path.extname(filePath);
|
||||
return path.join(path.dirname(filePath), path.basename(filePath, extension) + '.poster.webp');
|
||||
}
|
||||
|
||||
function runFfmpeg(args) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
execFile('ffmpeg', args, { timeout: 12000, maxBuffer: 1024 * 1024, windowsHide: true }, function (error) {
|
||||
if (error) return reject(error);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createVideoThumbnail(file) {
|
||||
const filePath = String(file && file.path || '').trim();
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
if (!filePath || !MEDIA_MIME_TYPES[extension] || MEDIA_MIME_TYPES[extension].indexOf('video/') !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const posterPath = getVideoPosterPath(filePath);
|
||||
try {
|
||||
await fs.promises.access(posterPath);
|
||||
return '/media/uploads/' + path.basename(posterPath);
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const originalStats = await fs.promises.stat(filePath);
|
||||
const temporaryPath = posterPath + '.partial-' + crypto.randomUUID();
|
||||
try {
|
||||
for (const seekSeconds of ['1', '0']) {
|
||||
await fs.promises.unlink(temporaryPath).catch(function () {});
|
||||
try {
|
||||
await runFfmpeg([
|
||||
'-hide_banner', '-loglevel', 'error', '-y', '-ss', seekSeconds, '-i', filePath,
|
||||
'-frames:v', '1', '-an', '-sn', '-dn',
|
||||
'-vf', "scale='min(480,iw)':'min(480,ih)':force_original_aspect_ratio=decrease",
|
||||
'-c:v', 'libwebp', '-quality', '78', '-compression_level', '4', '-f', 'webp', temporaryPath
|
||||
]);
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const posterStats = await fs.promises.stat(temporaryPath).catch(function () { return null; });
|
||||
if (!posterStats || !posterStats.size || posterStats.size >= originalStats.size) continue;
|
||||
try {
|
||||
await fs.promises.rename(temporaryPath, posterPath);
|
||||
} catch (error) {
|
||||
if (!error || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
|
||||
await fs.promises.access(posterPath);
|
||||
}
|
||||
return '/media/uploads/' + path.basename(posterPath);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
await fs.promises.unlink(temporaryPath).catch(function () {});
|
||||
}
|
||||
}
|
||||
|
||||
async function createImageThumbnail(file) {
|
||||
const filePath = String(file && file.path || '').trim();
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
if (!filePath || !IMAGE_THUMBNAIL_EXTENSIONS.has(extension)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const thumbnailPath = getImageThumbnailPath(filePath);
|
||||
try {
|
||||
await fs.promises.access(thumbnailPath);
|
||||
return '/media/uploads/' + path.basename(thumbnailPath);
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const originalStats = await fs.promises.stat(filePath);
|
||||
const thumbnailBuffer = await sharp(filePath, { failOn: 'none' })
|
||||
.rotate()
|
||||
.resize({ width: IMAGE_THUMBNAIL_MAX_DIMENSION, height: IMAGE_THUMBNAIL_MAX_DIMENSION, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 78, effort: 4 })
|
||||
.toBuffer();
|
||||
if (!thumbnailBuffer.length || thumbnailBuffer.length >= originalStats.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const temporaryPath = thumbnailPath + '.partial-' + crypto.randomUUID();
|
||||
try {
|
||||
await fs.promises.writeFile(temporaryPath, thumbnailBuffer);
|
||||
try {
|
||||
await fs.promises.rename(temporaryPath, thumbnailPath);
|
||||
} catch (error) {
|
||||
if (!error || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
|
||||
await fs.promises.access(thumbnailPath);
|
||||
}
|
||||
} finally {
|
||||
await fs.promises.unlink(temporaryPath).catch(function () {});
|
||||
}
|
||||
|
||||
return '/media/uploads/' + path.basename(thumbnailPath);
|
||||
}
|
||||
|
||||
async function ensureImageThumbnail(mediaPath, uploadDir) {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
if (!normalizedPath || !String(uploadDir || '').trim()) return null;
|
||||
const extension = path.extname(normalizedPath).toLowerCase();
|
||||
if (!IMAGE_THUMBNAIL_EXTENSIONS.has(extension)) return null;
|
||||
|
||||
const mediaRoot = path.resolve(path.dirname(uploadDir));
|
||||
const filePath = path.resolve(mediaRoot, normalizedPath.slice('/media/'.length));
|
||||
if (!filePath.startsWith(mediaRoot + path.sep)) return null;
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const thumbnailPath = await createImageThumbnail({ path: filePath });
|
||||
if (!thumbnailPath) return null;
|
||||
return normalizedPath.slice(0, normalizedPath.length - extension.length) + '.thumb.webp';
|
||||
}
|
||||
|
||||
async function ensureVideoThumbnail(mediaPath, uploadDir) {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
if (!normalizedPath || !String(uploadDir || '').trim()) return null;
|
||||
const extension = path.extname(normalizedPath).toLowerCase();
|
||||
if (!MEDIA_MIME_TYPES[extension] || MEDIA_MIME_TYPES[extension].indexOf('video/') !== 0) return null;
|
||||
|
||||
const mediaRoot = path.resolve(path.dirname(uploadDir));
|
||||
const filePath = path.resolve(mediaRoot, normalizedPath.slice('/media/'.length));
|
||||
if (!filePath.startsWith(mediaRoot + path.sep)) return null;
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const posterUrl = await createVideoThumbnail({ path: filePath });
|
||||
if (!posterUrl) return null;
|
||||
return normalizedPath.slice(0, normalizedPath.length - extension.length) + '.poster.webp';
|
||||
}
|
||||
|
||||
async function removeImageVariants(filePath) {
|
||||
const normalizedPath = String(filePath || '').trim();
|
||||
const extension = path.extname(normalizedPath).toLowerCase();
|
||||
if (!normalizedPath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
|
||||
return;
|
||||
if (!normalizedPath) return;
|
||||
const derivativePaths = [getImageThumbnailPath(normalizedPath), getVideoPosterPath(normalizedPath)];
|
||||
if (OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
|
||||
derivativePaths.push(...IMAGE_VARIANT_DIMENSIONS.map(function (dimension) {
|
||||
return getImageVariantPath(normalizedPath, dimension);
|
||||
}));
|
||||
}
|
||||
await Promise.all(IMAGE_VARIANT_DIMENSIONS.map(function (dimension) {
|
||||
return fs.promises.unlink(getImageVariantPath(normalizedPath, dimension)).catch(function (error) {
|
||||
await Promise.all(derivativePaths.map(function (derivativePath) {
|
||||
return fs.promises.unlink(derivativePath).catch(function (error) {
|
||||
if (error && error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
@@ -221,7 +377,7 @@ async function syncMediaAssetsFromDirectory(pool, uploadDir, userId) {
|
||||
continue;
|
||||
}
|
||||
const originalName = String(entry.name || '').trim();
|
||||
if (/\.variant-\d+\.(?:jpe?g|png|webp)$/i.test(originalName)) {
|
||||
if (/\.variant-\d+\.(?:jpe?g|png|webp)$/i.test(originalName) || /\.(?:thumb|poster)\.webp$/i.test(originalName)) {
|
||||
continue;
|
||||
}
|
||||
const mimeType = MEDIA_MIME_TYPES[path.extname(originalName).toLowerCase()];
|
||||
@@ -308,6 +464,10 @@ module.exports = {
|
||||
normalizeMediaPath,
|
||||
createImageVariants,
|
||||
removeImageVariants,
|
||||
createImageThumbnail,
|
||||
ensureImageThumbnail,
|
||||
createVideoThumbnail,
|
||||
ensureVideoThumbnail,
|
||||
optimizeUploadedImage,
|
||||
registerMediaAsset,
|
||||
registerMediaAssets,
|
||||
|
||||
@@ -18,7 +18,8 @@ if (search && initialGalleryQuery.has('search')) search.value = initialGalleryQu
|
||||
const galleryCount = gallery && gallery.querySelector('[data-media-gallery-count]');
|
||||
const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]');
|
||||
const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-more-wrap]');
|
||||
const loadMoreButton = gallery && gallery.querySelector('[data-media-gallery-load-more]');
|
||||
const galleryLoading = gallery && gallery.querySelector('[data-media-gallery-loading]');
|
||||
const galleryScroll = gallery && gallery.querySelector('.media-gallery-scroll');
|
||||
const selectModeButton = gallery && gallery.querySelector('[data-media-gallery-select-mode]');
|
||||
const deleteSelectedButton = gallery && gallery.querySelector('[data-media-gallery-delete-selected]');
|
||||
const addMediaButton = gallery && gallery.querySelector('[data-media-library-add]');
|
||||
@@ -29,6 +30,7 @@ let galleryHasMore = gallery ? gallery.dataset.mediaGalleryHasMore === 'true' :
|
||||
let galleryTotal = galleryCount ? Number(galleryCount.textContent || galleryItems.length) : galleryItems.length;
|
||||
let galleryRefreshSequence = 0;
|
||||
let selectionMode = false;
|
||||
let galleryLoadInProgress = false;
|
||||
const selectedAssetIds = new Set();
|
||||
const uploadForm = document.querySelector('form[action="/media-library"]');
|
||||
const uploadInput = document.querySelector('[data-media-library-upload]');
|
||||
@@ -84,11 +86,12 @@ if (addMediaButton && uploadModal) {
|
||||
preview.dataset.mediaName = asset.original_name || '';
|
||||
preview.setAttribute('aria-label', 'Preview ' + (asset.original_name || 'media'));
|
||||
const media = asset.media_type === 'video' ? document.createElement('video') : document.createElement('img');
|
||||
media.src = asset.media_path || '';
|
||||
media.src = asset.thumbnail_path || asset.media_path || '';
|
||||
if (asset.media_type === 'video') {
|
||||
media.muted = true;
|
||||
media.playsInline = true;
|
||||
media.preload = 'metadata';
|
||||
media.preload = 'none';
|
||||
if (asset.thumbnail_path) media.poster = asset.thumbnail_path;
|
||||
} else {
|
||||
media.alt = asset.original_name || '';
|
||||
media.loading = 'lazy';
|
||||
@@ -478,25 +481,38 @@ if (gallery) {
|
||||
}
|
||||
if (selectModeButton) selectModeButton.addEventListener('click', function () { setSelectionMode(!selectionMode); });
|
||||
if (deleteSelectedButton) deleteSelectedButton.addEventListener('click', submitBulkDelete);
|
||||
if (loadMoreButton) {
|
||||
loadMoreButton.addEventListener('click', async function () {
|
||||
loadMoreButton.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/media-library/assets?' + getGalleryQuery(galleryOffset, 4 * getColumnsPerRow()), { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error('Unable to load more media.');
|
||||
const result = await response.json();
|
||||
(result.assets || []).forEach(appendGalleryAsset);
|
||||
galleryOffset = Number(result.nextOffset || galleryOffset);
|
||||
galleryHasMore = Boolean(result.hasMore);
|
||||
galleryTotal = Number(result.total || galleryTotal);
|
||||
visibleRowLimit += 4;
|
||||
updateSearchResults();
|
||||
} catch (error) {
|
||||
window.PulseUpload.showMessage(error.message || 'Unable to load more media.');
|
||||
} finally {
|
||||
loadMoreButton.disabled = false;
|
||||
}
|
||||
});
|
||||
async function loadMoreMedia() {
|
||||
if (!loadMoreWrap || !galleryHasMore || galleryLoadInProgress) return;
|
||||
galleryLoadInProgress = true;
|
||||
if (galleryLoading) galleryLoading.hidden = false;
|
||||
try {
|
||||
const response = await fetch('/media-library/assets?' + getGalleryQuery(galleryOffset, 4 * getColumnsPerRow()), { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error('Unable to load more media.');
|
||||
const result = await response.json();
|
||||
(result.assets || []).forEach(appendGalleryAsset);
|
||||
galleryOffset = Number(result.nextOffset || galleryOffset);
|
||||
galleryHasMore = Boolean(result.hasMore);
|
||||
galleryTotal = Number(result.total || galleryTotal);
|
||||
visibleRowLimit += 4;
|
||||
updateSearchResults();
|
||||
} catch (error) {
|
||||
window.PulseUpload.showMessage(error.message || 'Unable to load more media.');
|
||||
} finally {
|
||||
if (galleryLoading) galleryLoading.hidden = true;
|
||||
galleryLoadInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (loadMoreWrap && galleryScroll && window.IntersectionObserver) {
|
||||
const loadMoreObserver = new window.IntersectionObserver(function (entries) {
|
||||
if (entries.some(function (entry) { return entry.isIntersecting; })) loadMoreMedia();
|
||||
}, { root: galleryScroll, rootMargin: '0px' });
|
||||
loadMoreObserver.observe(loadMoreWrap);
|
||||
} else if (loadMoreWrap && galleryScroll) {
|
||||
galleryScroll.addEventListener('scroll', function () {
|
||||
const remaining = galleryScroll.scrollHeight - galleryScroll.scrollTop - galleryScroll.clientHeight;
|
||||
if (remaining <= 0) loadMoreMedia();
|
||||
}, { passive: true });
|
||||
}
|
||||
updateSearchResults();
|
||||
|
||||
|
||||
@@ -152,8 +152,16 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
});
|
||||
const settings = await fetchAppSettings(deps.pool);
|
||||
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
|
||||
const pageAssets = filteredAssets.slice(0, pageSize);
|
||||
await Promise.all(pageAssets.map(async function (asset) {
|
||||
asset.thumbnail_path = asset.media_type === 'image'
|
||||
? await mediaLibrary.ensureImageThumbnail(asset.media_path, deps.uploadDir)
|
||||
: asset.media_type === 'video'
|
||||
? await mediaLibrary.ensureVideoThumbnail(asset.media_path, deps.uploadDir)
|
||||
: null;
|
||||
}));
|
||||
res.send(deps.pages.renderMediaLibraryPage({
|
||||
assets: filteredAssets.slice(0, pageSize),
|
||||
assets: pageAssets,
|
||||
assetTotal: filteredAssets.length,
|
||||
hasMore: filteredAssets.length > pageSize,
|
||||
uploadLimits: getUploadLimits(settings)
|
||||
@@ -181,6 +189,13 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
const limit = Math.min(pageSize, Math.max(1, Number.parseInt(req.query.limit, 10) || pageSize));
|
||||
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
|
||||
const assets = filteredAssets.slice(offset, offset + limit);
|
||||
await Promise.all(assets.map(async function (asset) {
|
||||
asset.thumbnail_path = asset.media_type === 'image'
|
||||
? await mediaLibrary.ensureImageThumbnail(asset.media_path, deps.uploadDir)
|
||||
: asset.media_type === 'video'
|
||||
? await mediaLibrary.ensureVideoThumbnail(asset.media_path, deps.uploadDir)
|
||||
: null;
|
||||
}));
|
||||
res.json({ assets: assets, total: filteredAssets.length, nextOffset: offset + assets.length, hasMore: offset + assets.length < filteredAssets.length });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -214,6 +229,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
return rejectUpload('File must be ' + limitLabel + ' or smaller.');
|
||||
}
|
||||
await mediaLibrary.createImageVariants(req.file);
|
||||
await mediaLibrary.createImageThumbnail(req.file);
|
||||
const mediaPath = '/media/uploads/' + req.file.filename;
|
||||
await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), mediaPath, deps.getAuditUserId(req));
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
|
||||
@@ -54,10 +54,10 @@
|
||||
<article class="media-gallery-card" data-media-created-at="{{created_at}}" data-media-file-size="{{file_size}}">
|
||||
<button type="button" class="media-gallery-preview" data-media-gallery-preview data-media-path="{{media_path}}" data-media-type="{{media_type}}" data-media-name="{{original_name}}" aria-label="Preview {{original_name}}">
|
||||
{{#if (eq media_type 'video')}}
|
||||
<video src="{{media_path}}" muted playsinline preload="metadata"></video>
|
||||
<video src="{{media_path}}" poster="{{thumbnail_path}}" muted playsinline preload="none"></video>
|
||||
<span class="media-gallery-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span>
|
||||
{{else}}
|
||||
<img src="{{media_path}}" alt="{{original_name}}" loading="lazy" />
|
||||
<img src="{{#if thumbnail_path}}{{thumbnail_path}}{{else}}{{media_path}}{{/if}}" alt="{{original_name}}" loading="lazy" />
|
||||
{{/if}}
|
||||
<span class="media-gallery-type">{{mediaFormat}}</span>
|
||||
</button>
|
||||
@@ -81,7 +81,10 @@
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="text-center mt-4" data-media-gallery-load-more-wrap hidden>
|
||||
<button type="button" class="btn btn-primary px-4" data-media-gallery-load-more>Load more...</button>
|
||||
<div class="d-inline-flex align-items-center gap-2 text-body-secondary small" data-media-gallery-loading hidden role="status" aria-live="polite">
|
||||
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
|
||||
<span>Loading media...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results{{#if assets.length}} hidden{{/if}}>No media matches your search.</div>
|
||||
|
||||
Reference in new issue
Block a user