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,12 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.21.4 - 2026-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- The media library generates compact image thumbnails and video posters, and automatically loads more assets while scrolling.
|
||||
|
||||
## 2.21.3 - 2026-10-26
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ FROM node:26.12.1-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache chromium nss freetype harfbuzz ttf-freefont
|
||||
RUN apk add --no-cache chromium ffmpeg nss freetype harfbuzz ttf-freefont
|
||||
|
||||
COPY build/package.web.json ./package.json
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-player",
|
||||
"version": "2.21.3",
|
||||
"version": "2.21.4",
|
||||
"private": false,
|
||||
"description": "Pulse Signage player application bundle",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-web",
|
||||
"version": "2.21.3",
|
||||
"version": "2.21.4",
|
||||
"private": false,
|
||||
"description": "Pulse Signage web and bridge application bundle",
|
||||
"engines": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.21.3",
|
||||
"version": "2.21.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "2.21.3",
|
||||
"version": "2.21.4",
|
||||
"dependencies": {
|
||||
"@better-auth/passkey": "^1.7.13",
|
||||
"animate.css": "^4.1.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.21.3",
|
||||
"version": "2.21.4",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"engines": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+116
-5
@@ -16,7 +16,9 @@ const { removeUnusedImageCopies, removePendingMediaAssets } = require('../src/we
|
||||
const {
|
||||
normalizeMediaPath,
|
||||
createImageVariants,
|
||||
createImageThumbnail,
|
||||
removeImageVariants,
|
||||
ensureImageThumbnail,
|
||||
optimizeUploadedImage,
|
||||
registerMediaAssets,
|
||||
syncMediaAssetsFromDirectory,
|
||||
@@ -566,14 +568,19 @@ test('slide images retain original references and re-editable crops across saves
|
||||
test('media library sorts Date-valued creation timestamps chronologically', async () => {
|
||||
const originalSyncMediaAssets = mediaLibrary.syncMediaAssetsFromDirectory;
|
||||
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
||||
const originalEnsureImageThumbnail = mediaLibrary.ensureImageThumbnail;
|
||||
const originalEnsureVideoThumbnail = mediaLibrary.ensureVideoThumbnail;
|
||||
const assets = [
|
||||
{ id: 1, original_name: 'October 14', media_type: 'image', file_size: 1, created_at: new Date('2026-10-14T12:00:00Z') },
|
||||
{ id: 2, original_name: 'October 15', media_type: 'image', file_size: 1, created_at: new Date('2026-10-15T12:00:00Z') },
|
||||
{ id: 3, original_name: 'October 17', media_type: 'image', file_size: 1, created_at: new Date('2026-10-17T12:00:00Z') }
|
||||
{ id: 1, original_name: 'October 14', media_type: 'image', media_path: '/media/uploads/14.png', file_size: 1, created_at: new Date('2026-10-14T12:00:00Z') },
|
||||
{ id: 2, original_name: 'October 15', media_type: 'image', media_path: '/media/uploads/15.png', file_size: 1, created_at: new Date('2026-10-15T12:00:00Z') },
|
||||
{ id: 3, original_name: 'October 17', media_type: 'image', media_path: '/media/uploads/17.png', file_size: 1, created_at: new Date('2026-10-17T12:00:00Z') },
|
||||
{ id: 4, original_name: 'October 13', media_type: 'video', media_path: '/media/uploads/13.mp4', file_size: 1, created_at: new Date('2026-10-13T12:00:00Z') }
|
||||
];
|
||||
const handlers = {};
|
||||
mediaLibrary.syncMediaAssetsFromDirectory = async function () { return 0; };
|
||||
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
|
||||
mediaLibrary.ensureImageThumbnail = async function (mediaPath) { return mediaPath.replace(/\.png$/, '.thumb.webp'); };
|
||||
mediaLibrary.ensureVideoThumbnail = async function (mediaPath) { return mediaPath.replace(/\.mp4$/, '.poster.webp'); };
|
||||
|
||||
try {
|
||||
registerMediaLibraryRoutes({
|
||||
@@ -591,14 +598,20 @@ test('media library sorts Date-valued creation timestamps chronologically', asyn
|
||||
const next = function (error) { if (error) throw error; };
|
||||
const request = { currentUser: { permissions: [] }, query: { offset: '0', limit: '24', sort: 'date-desc' } };
|
||||
await handlers['/media-library/assets'](request, response, next);
|
||||
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 17', 'October 15', 'October 14']);
|
||||
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 17', 'October 15', 'October 14', 'October 13']);
|
||||
assert.equal(response.data.assets[0].thumbnail_path, '/media/uploads/17.thumb.webp');
|
||||
assert.equal(response.data.assets[0].media_path, '/media/uploads/17.png');
|
||||
assert.equal(response.data.assets[3].thumbnail_path, '/media/uploads/13.poster.webp');
|
||||
assert.equal(response.data.assets[3].media_path, '/media/uploads/13.mp4');
|
||||
|
||||
request.query.sort = 'date-asc';
|
||||
await handlers['/media-library/assets'](request, response, next);
|
||||
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 14', 'October 15', 'October 17']);
|
||||
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 13', 'October 14', 'October 15', 'October 17']);
|
||||
} finally {
|
||||
mediaLibrary.syncMediaAssetsFromDirectory = originalSyncMediaAssets;
|
||||
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
||||
mediaLibrary.ensureImageThumbnail = originalEnsureImageThumbnail;
|
||||
mediaLibrary.ensureVideoThumbnail = originalEnsureVideoThumbnail;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -649,6 +662,48 @@ test('media library creates only smaller image variants and preserves the origin
|
||||
}
|
||||
});
|
||||
|
||||
test('media library creates compact WebP thumbnails and removes them with image derivatives', async () => {
|
||||
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-thumbnails-'));
|
||||
const filePath = path.join(uploadDir, 'large.png');
|
||||
const sourceBuffer = await sharp(crypto.randomBytes(1600 * 900 * 3), {
|
||||
raw: { width: 1600, height: 900, channels: 3 }
|
||||
}).png({ compressionLevel: 0 }).toBuffer();
|
||||
await fs.promises.writeFile(filePath, sourceBuffer);
|
||||
|
||||
try {
|
||||
const thumbnailUrl = await createImageThumbnail({ path: filePath });
|
||||
const thumbnailPath = path.join(uploadDir, 'large.thumb.webp');
|
||||
const thumbnailBuffer = await fs.promises.readFile(thumbnailPath);
|
||||
const metadata = await sharp(thumbnailBuffer).metadata();
|
||||
assert.equal(thumbnailUrl, '/media/uploads/large.thumb.webp');
|
||||
assert.equal(metadata.format, 'webp');
|
||||
assert.ok(metadata.width <= 480 && metadata.height <= 480);
|
||||
assert.ok((await fs.promises.stat(thumbnailPath)).size < sourceBuffer.length);
|
||||
assert.deepEqual(await fs.promises.readFile(filePath), sourceBuffer);
|
||||
await removeImageVariants(filePath);
|
||||
await assert.rejects(fs.promises.access(thumbnailPath), { code: 'ENOENT' });
|
||||
} finally {
|
||||
await fs.promises.rm(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('media library creates thumbnails for older assets when a gallery page requests them', async () => {
|
||||
const mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-backfill-'));
|
||||
const uploadDir = path.join(mediaRoot, 'uploads');
|
||||
await fs.promises.mkdir(uploadDir);
|
||||
const filePath = path.join(uploadDir, 'legacy.jpg');
|
||||
await sharp({ create: { width: 900, height: 600, channels: 3, background: '#16835d' } }).jpeg().toFile(filePath);
|
||||
|
||||
try {
|
||||
assert.equal(await ensureImageThumbnail('/media/uploads/legacy.jpg', uploadDir), '/media/uploads/legacy.thumb.webp');
|
||||
assert.equal(await ensureImageThumbnail('/media/uploads/missing.jpg', uploadDir), null);
|
||||
assert.equal(await ensureImageThumbnail('/media/../outside.jpg', uploadDir), null);
|
||||
assert.ok((await fs.promises.stat(path.join(uploadDir, 'legacy.thumb.webp'))).size > 0);
|
||||
} finally {
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('media library registers uploaded files with their metadata', async () => {
|
||||
const queries = [];
|
||||
const pool = {
|
||||
@@ -681,6 +736,7 @@ test('media library registers uploaded files with their metadata', async () => {
|
||||
test('media library backfills existing image and video uploads', async () => {
|
||||
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-library-'));
|
||||
await fs.promises.writeFile(path.join(uploadDir, 'legacy.png'), Buffer.from('image'));
|
||||
await fs.promises.writeFile(path.join(uploadDir, 'legacy.thumb.webp'), Buffer.from('thumbnail'));
|
||||
await fs.promises.writeFile(path.join(uploadDir, 'legacy.mp4'), Buffer.from('video'));
|
||||
await fs.promises.writeFile(path.join(uploadDir, 'ignore.txt'), Buffer.from('other'));
|
||||
const queries = [];
|
||||
@@ -746,6 +802,7 @@ test('full-size slide previews share the small thumbnail reference', async () =>
|
||||
|
||||
test('media library upload and delete routes audit successful asset changes', async () => {
|
||||
const originalCreateVariants = mediaLibrary.createImageVariants;
|
||||
const originalCreateThumbnail = mediaLibrary.createImageThumbnail;
|
||||
const originalRegisterAsset = mediaLibrary.registerMediaAsset;
|
||||
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
||||
const originalRemoveVariants = mediaLibrary.removeImageVariants;
|
||||
@@ -771,6 +828,7 @@ test('media library upload and delete routes audit successful asset changes', as
|
||||
};
|
||||
|
||||
mediaLibrary.createImageVariants = async function () {};
|
||||
mediaLibrary.createImageThumbnail = async function () {};
|
||||
mediaLibrary.registerMediaAsset = async function () {};
|
||||
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
|
||||
mediaLibrary.removeImageVariants = async function () {};
|
||||
@@ -820,6 +878,7 @@ test('media library upload and delete routes audit successful asset changes', as
|
||||
assert.match(jsonResponse.data.error, /disabled/);
|
||||
} finally {
|
||||
mediaLibrary.createImageVariants = originalCreateVariants;
|
||||
mediaLibrary.createImageThumbnail = originalCreateThumbnail;
|
||||
mediaLibrary.registerMediaAsset = originalRegisterAsset;
|
||||
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
||||
mediaLibrary.removeImageVariants = originalRemoveVariants;
|
||||
@@ -1019,9 +1078,61 @@ test('empty media gallery retains filters and its refreshable results container'
|
||||
assert.match(html, /data-media-gallery-usage-filter/);
|
||||
assert.match(html, /data-media-gallery-sort/);
|
||||
assert.match(html, /class="media-gallery-scroll"/);
|
||||
assert.match(html, /data-media-gallery-loading[^>]*role="status" aria-live="polite"/);
|
||||
assert.doesNotMatch(html, /data-media-gallery-load-more>/);
|
||||
assert.match(html, /data-media-gallery-no-results>/);
|
||||
});
|
||||
|
||||
test('media gallery automatically loads the next page when its load-more sentinel approaches the scroll viewport', async () => {
|
||||
const controls = {};
|
||||
const mediaScroll = {};
|
||||
const loadMoreWrap = { hidden: false };
|
||||
const loading = { hidden: true };
|
||||
let observedTarget;
|
||||
let observerCallback;
|
||||
let observerOptions;
|
||||
let requestedUrl = '';
|
||||
const gallery = {
|
||||
dataset: { mediaGalleryOffset: '24', mediaGalleryHasMore: 'true' },
|
||||
querySelector(selector) {
|
||||
if (selector === '.media-gallery-scroll') return mediaScroll;
|
||||
return controls[selector] || null;
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
addEventListener() {}
|
||||
};
|
||||
controls['[data-media-gallery-load-more-wrap]'] = loadMoreWrap;
|
||||
controls['[data-media-gallery-loading]'] = loading;
|
||||
class TestIntersectionObserver {
|
||||
constructor(callback, options) { observerCallback = callback; observerOptions = options; }
|
||||
observe(target) { observedTarget = target; }
|
||||
}
|
||||
const context = vm.createContext({
|
||||
URLSearchParams,
|
||||
window: { location: { search: '' }, innerWidth: 1200, IntersectionObserver: TestIntersectionObserver },
|
||||
document: {
|
||||
getElementById() { return null; },
|
||||
querySelector(selector) { return selector === '.media-gallery' ? gallery : null; }
|
||||
},
|
||||
fetch: async function (url) {
|
||||
requestedUrl = url;
|
||||
return { ok: true, async json() { return { assets: [], total: 24, nextOffset: 24, hasMore: false }; } };
|
||||
}
|
||||
});
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'), context);
|
||||
|
||||
assert.equal(observedTarget, loadMoreWrap);
|
||||
assert.equal(observerOptions.root, mediaScroll);
|
||||
assert.equal(observerOptions.rootMargin, '0px');
|
||||
observerCallback([{ isIntersecting: true }]);
|
||||
assert.equal(loading.hidden, false);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.match(requestedUrl, /^\/media-library\/assets\?/);
|
||||
assert.equal(new URL(requestedUrl, 'http://localhost').searchParams.get('offset'), '24');
|
||||
assert.equal(loadMoreWrap.hidden, true);
|
||||
assert.equal(loading.hidden, true);
|
||||
});
|
||||
|
||||
test('media rename validates filenames and changes only metadata with update permission and auditing', async () => {
|
||||
const handlers = {};
|
||||
const queries = [];
|
||||
|
||||
Reference in new issue
Block a user