diff --git a/CHANGELOG.md b/CHANGELOG.md index dfdbc69..5410322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/build/Dockerfile b/build/Dockerfile index e7b8707..8c950f7 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -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 diff --git a/build/package.player.json b/build/package.player.json index 5035b81..46cb46c 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -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": { diff --git a/build/package.web.json b/build/package.web.json index de46eff..8bf8f47 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -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": { diff --git a/package-lock.json b/package-lock.json index 00b59ad..2b34e10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a29fe48..1d268c2 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/web/lib/media/library.js b/src/web/lib/media/library.js index cab8c93..b87ec1a 100644 --- a/src/web/lib/media/library.js +++ b/src/web/lib/media/library.js @@ -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, diff --git a/src/web/public/js/settings/media-library.js b/src/web/public/js/settings/media-library.js index 5954225..dc8677b 100644 --- a/src/web/public/js/settings/media-library.js +++ b/src/web/public/js/settings/media-library.js @@ -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(); diff --git a/src/web/routes/settings/media-library.js b/src/web/routes/settings/media-library.js index cdebdcd..53e1e21 100644 --- a/src/web/routes/settings/media-library.js +++ b/src/web/routes/settings/media-library.js @@ -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') { diff --git a/src/web/views/settings/media-library/list.hbs b/src/web/views/settings/media-library/list.hbs index 35e45bb..50a89e0 100644 --- a/src/web/views/settings/media-library/list.hbs +++ b/src/web/views/settings/media-library/list.hbs @@ -54,10 +54,10 @@