Release 2.21.0: non-destructive media and scoped player sync
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m36s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
lzstealth committed 2026-10-26 21:46:33 +00:00
1 parent 24df245ea1
commit 7cff39dc76
35 files changed
+2009 -949

No files matched your search

+9
View File
@@ -2,6 +2,15 @@
All notable changes to this project will be documented in this file.
## 2.21.0 - 2026-10-26
### Added
- Non-destructive image uploads retain full originals, with automatic crop editing after selection, reusable saved crop settings, and playback copies sized to the region and canvas without upscaling. Unchanged images that already fit reuse the original; discarded uploads and obsolete copies are cleaned up without removing referenced media.
- Player-specific media synchronization sends only playback assets needed by assigned screen playlists through direct or bridge connections, retains last-known assignments for offline clients, and safely removes obsolete player copies while protecting shared media storage.
- Media-library uploads show a rounded, primary-colour progress bar, server-processing feedback, and recoverable upload errors without navigating away from the gallery.
- Players show a loading indicator until initial slide images are ready, retain the previous slide while subsequent images load, retry media that is still syncing, and start slide timing only after image readiness.
## 2.20.6 - 2026-10-26
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
"version": "2.20.6",
"version": "2.21.0",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
"version": "2.20.6",
"version": "2.21.0",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
+10
View File
@@ -263,6 +263,16 @@ Remote stack ports:
- `pulse-signage` - shared media and cache volume for the remote player.
### Local Development
- `media/web` - source media and server-side cache shared by web and bridge.
- `media/player-local` - isolated storage for the direct local player.
- `media/player-remote` - isolated storage for the bridge-connected remote dev player.
The local dev player keeps `PLAYER_INTERNAL_URL` pointing to the Docker player service. Set `BRIDGE_PUBLIC_URL` only in the remote player's environment, using the public HTTPS bridge address. Do not set it in the shared local environment, because it switches a player into remote mode.
Web and player services automatically create a `.storage-id` file in their media root. Keep that file when moving the same storage folder. When copying media into an independent player folder, omit or remove the copied `.storage-id` so the service creates a different identity; otherwise separate folders would be mistaken for shared storage. No additional environment setting is required.
## Networks
Each compose file creates its own named network:
+9 -3
View File
@@ -139,7 +139,7 @@ Use **Edit default content** to set a region's starting content with the same ty
The available region types cover fixed content, uploaded media, live data, and external content:
- **Text:** rich text content with font family, font size, font colour, and normal editorial formatting. Use it for headings, labels, paragraphs, and other copy that editors change per slide.
- **Image:** an uploaded image in the media library. The editor supports PNG, JPG, GIF, WebP, and SVG images, with cropping to the region ratio when needed.
- **Image:** a reusable image in the media library. The editor supports PNG, JPG, GIF, WebP, and SVG images, with optional crop, rotate, flip, and zoom tools that leave the original unchanged.
- **Video:** an uploaded MP4, WebM, or Ogg video. Videos preview in the region and loop during playback.
- **HTML:** HTML content rendered inside a sandboxed preview. Use it for controlled custom markup when an ordinary text region cannot express the design.
- **Webpage:** a URL displayed in an embedded webpage region. Use it for a page that should be shown inside the slide, and verify that the destination permits embedding.
@@ -318,7 +318,7 @@ See the [Data Sources guide](guide-data-sources.md) for supported source types,
## Media
Media is added where it is needed in a template or slide. Image and video regions provide their own upload controls, while template backgrounds are uploaded from the template options. An upload in one slide or template is not automatically a reusable selection in every other slide, so add the file to each region or template where it is required.
Media is added where it is needed in a template or slide, or directly through the Media Library. Image and video regions provide their own upload controls, while template backgrounds are uploaded from the template options. Use the shared media picker to reuse an uploaded asset in another region or template without uploading another copy.
### Images
@@ -330,7 +330,13 @@ When adding an image:
- Keep important subjects away from the edges when the region may crop the image.
- Check the crop and scaling in the slide preview before publishing.
- Use a transparent PNG or SVG when the design needs the background to show through.
- Remember that animated image formats may be altered if they are cropped or transformed.
- Uploads retain the full original, including SVG vectors and animated images. Applying a crop creates a separate image copy for that placement; SVG and animated GIF crops become PNGs, while the original vectors or animation remain available.
Images are uploaded in full. The crop editor opens automatically after an image upload completes or an existing image is selected from the library. It offers a crop box, ratio presets (including **Region**), rotation, flip, and zoom controls. Choose **Edit crop** on an image region to reopen it later. **Apply crop** remembers the original and crop settings; applying an untouched full-image selection keeps it uncropped. **Cancel** discards a newly selected image and cleans up a new upload if nothing saved uses it. Existing library images are not deleted. When reopening an already applied image, **Cancel** leaves that previous selection unchanged. Save the slide to retain the changes. Different slides can crop the same original independently. Previously cropped files remain usable, but their missing content can only be recovered by uploading the original again.
Playback images fit the region's pixel dimensions and slide canvas, preserving aspect ratio without enlarging small images. An uncropped original that already fits is used directly, without creating a duplicate file. Larger originals and cropped images get appropriately sized playback copies. Uncropped GIF and WebP copies retain their animation. Saving a larger region or changing its shared template regenerates the copy from the full original when needed. The original file remains unchanged.
Uploaded media synchronization is scoped to each player's client screen assignments and playlists, including last-known assignments for offline clients. Players receive the playback image, not its editing original, unless the original is itself the playback file. This works for direct players and players reached through the bridge. Obsolete player copies are removed when no longer needed by that player; files shared with web storage are retained while the web still needs them. Managed fonts and weather icons keep their separate synchronization rules.
### Videos
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "2.20.6",
"version": "2.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.20.6",
"version": "2.21.0",
"dependencies": {
"@better-auth/passkey": "^1.7.13",
"animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.20.6",
"version": "2.21.0",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
+14 -1
View File
@@ -319,9 +319,22 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
const uploaded = filesByField[`region_image_${region.id}`];
const existing = body[`existing_region_image_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
const previous = existingContent[region.region_key] || {};
const value = uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_image_${region.id}`, current);
const sameImage = value === current;
const original = uploaded ? value : getSubmittedValue(body, `region_image_original_${region.id}`, sameImage ? previous.original_value || value : value);
const submittedCrop = body[`region_image_crop_${region.id}`];
const crop = submittedCrop === undefined ? (sameImage ? previous.crop : null) : parseJsonSafe(submittedCrop);
const normalizedCrop = {};
for (const field of ['x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY']) {
const number = Number(crop && crop[field]);
if (crop && Number.isFinite(number) && Math.abs(number) <= 10000000) normalizedCrop[field] = number;
}
content[region.region_key] = {
type: 'image',
value: uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_image_${region.id}`, current)
value: value,
original_value: value ? original || value : '',
crop: value && normalizedCrop.width > 0 && normalizedCrop.height > 0 ? normalizedCrop : null
};
} else if (region.region_type === 'video') {
const uploaded = filesByField[`region_video_${region.id}`];
+18
View File
@@ -0,0 +1,18 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
async function getMediaStorageId(mediaDir) {
const filename = path.join(path.resolve(mediaDir), '.storage-id');
await fs.promises.mkdir(path.dirname(filename), { recursive: true });
try {
await fs.promises.writeFile(filename, crypto.randomUUID(), { flag: 'wx' });
} catch (error) {
if (error.code !== 'EEXIST') throw error;
}
const value = (await fs.promises.readFile(filename, 'utf8')).trim();
if (!/^[a-f0-9-]{36}$/i.test(value)) throw new Error('Invalid media storage identifier.');
return value;
}
module.exports = { getMediaStorageId };
+1
View File
@@ -34,6 +34,7 @@ async function start() {
const PLAYER_AGENT_RECONNECT_DELAY_MS = Number(process.env.PLAYER_AGENT_RECONNECT_DELAY_MS || 5000);
const ASSET_DIR = path.join(__dirname, 'player', 'public');
const MEDIA_DIR = path.join(__dirname, '..', 'media');
await require('#src/media-storage').getMediaStorageId(MEDIA_DIR);
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
const RECONNECT_SYNC_STALE_MS = 60 * 1000;
+26
View File
@@ -1,3 +1,29 @@
.player-media-loading {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #15191d;
color: #fff;
}
.player-media-loading-spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: currentColor;
border-radius: 50%;
animation: player-media-loading-spin 0.8s linear infinite;
}
@keyframes player-media-loading-spin {
to { transform: rotate(360deg); }
}
html,
body {
margin: 0;
+67
View File
@@ -1,3 +1,70 @@
async function waitForSlideImages(markup, renderRequestId) {
if (typeof Image !== 'function' || typeof document === 'undefined') return markup;
var template = document.createElement('template');
template.innerHTML = markup;
var sources = Array.from(new Set(Array.from(template.content.querySelectorAll('img[src]')).map(function (image) { return image.getAttribute('src'); }).filter(Boolean)));
if (!sources.length) return markup;
if (!lastRenderedViewKey && !app.querySelector('.player-media-loading')) {
var loading = document.createElement('div');
loading.className = 'empty player-media-loading';
loading.setAttribute('role', 'status');
loading.setAttribute('aria-live', 'polite');
loading.innerHTML = '<span class="player-media-loading-spinner" aria-hidden="true"></span><span>Loading media...</span>';
app.appendChild(loading);
}
async function loadSource(source) {
var attempt = 0;
var loadedUrl = source;
while (renderRequestId === slideRenderRequestId) {
var ready = await new Promise(function (resolve) {
var image = new Image();
var settled = false;
var timeout;
function finish(value) {
if (settled) return;
settled = true;
window.clearTimeout(timeout);
image.onload = null;
image.onerror = null;
resolve(value);
}
image.onload = async function () {
try {
if (typeof image.decode === 'function') await image.decode();
loadedUrl = image.src;
finish(image.naturalWidth > 0);
} catch (_error) { finish(false); }
};
image.onerror = function () { finish(false); };
timeout = window.setTimeout(function () { finish(false); }, 10000);
var url = source;
if (attempt) {
try {
var retryUrl = new URL(source, window.location.href);
if (retryUrl.origin === window.location.origin && retryUrl.pathname.startsWith('/media/')) {
retryUrl.searchParams.set('_mediaReady', String(Date.now()));
url = retryUrl.toString();
}
} catch (_error) {}
}
image.src = url;
});
if (ready) {
if (loadedUrl !== source) template.content.querySelectorAll('img[src]').forEach(function (element) {
if (element.getAttribute('src') === source) element.setAttribute('src', loadedUrl);
});
return renderRequestId === slideRenderRequestId;
}
if (renderRequestId !== slideRenderRequestId) return false;
attempt += 1;
await new Promise(function (resolve) { window.setTimeout(resolve, Math.min(1000 * attempt, 5000)); });
}
return false;
}
var results = await Promise.all(sources.map(loadSource));
return renderRequestId === slideRenderRequestId && results.every(Boolean) ? template.innerHTML : false;
}
// Slide media startup and teardown helpers.
var renderedVideoPlaybackCleanup = new WeakMap();
@@ -56,6 +56,11 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
index = currentIndex;
var markup = buildSlideMarkup(slide);
if (typeof waitForSlideImages === 'function') {
var readyMarkup = await waitForSlideImages(markup, renderRequestId);
if (readyMarkup === false || renderRequestId !== slideRenderRequestId) return false;
if (typeof readyMarkup === 'string') markup = readyMarkup;
}
var shouldFade = !(options && options.skipFade) && currentPlaylistFadeBetweenSlides;
var mediaDelayMs = slide && slide.use_video_duration ? 0 : undefined;
renderSlideMarkup(markup, shouldFade, mediaDelayMs);
@@ -159,6 +164,17 @@ function handleRtmpPlaybackFailure(message, options) {
// Fetch and queue data updates; rendering and pause/resume own the slide timer.
function refresh(applyImmediately, bypassDebounce) {
if (applyImmediately && typeof document !== 'undefined' && document.querySelectorAll) {
document.querySelectorAll('img').forEach(function (image) {
if (!image.complete || image.naturalWidth > 0) return;
try {
var mediaUrl = new URL(image.currentSrc || image.src, window.location.href);
if (mediaUrl.origin !== window.location.origin || !mediaUrl.pathname.startsWith('/media/')) return;
mediaUrl.searchParams.set('_mediaRefresh', String(Date.now()));
image.src = mediaUrl.toString();
} catch (_error) {}
});
}
if (playlistRefreshInFlight) {
return;
}
@@ -21,7 +21,7 @@ function startPlayerWatchdog() {
}
playerWatchdogTimer = window.setInterval(function () {
if (document.visibilityState === 'hidden' || isPaused || !slides.length || !lastRenderedSlide) {
if (document.visibilityState === 'hidden' || isPaused || !slides.length || !lastRenderedSlide || (document.querySelector && document.querySelector('.player-media-loading'))) {
return;
}
+8 -24
View File
@@ -303,31 +303,15 @@ function registerPlayerRoutes(app, options) {
}
});
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
if (bridgeBaseUrl) {
void fetch(new URL('/api/media/config', bridgeBaseUrl).toString(), {
method: 'GET',
headers: createRequestAuthHeaders({
method: 'GET',
pathname: '/api/media/config'
})
}).then(async function (response) {
res.status(response.status);
const contentType = response.headers.get('content-type');
if (contentType) {
res.type(contentType);
}
res.send(await response.text());
}).catch(function (_error) {
res.status(502).json({ error: 'Thin client unavailable.' });
app.get('/api/media/config', requireRequestAuth, async function (_req, res, next) {
try {
const { getMediaStorageId } = require('#src/media-storage');
res.json({
mediaDir: mediaDir,
uploadDir: path.join(mediaDir, 'uploads'),
storageId: await getMediaStorageId(mediaDir)
});
return;
}
res.json({
mediaDir: mediaDir,
uploadDir: path.join(mediaDir, 'uploads')
});
} catch (error) { next(error); }
});
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
+2
View File
@@ -30,6 +30,7 @@ const { sendAccountEmail } = require('#src/data/mailer');
const { recordRequestAuditEvent } = require('#src/data/audit-log');
const { hasAnyPermission } = require('#src/rbac');
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
const { getMediaStorageId } = require('#src/media-storage');
const {
formatDashboardDate,
readArrayField,
@@ -53,6 +54,7 @@ async function start() {
const pool = common.createPool();
const webConfig = createWebConfig();
fs.mkdirSync(webConfig.mediaDir, { recursive: true });
await getMediaStorageId(webConfig.mediaDir);
await ensureFontLibrary(webConfig.mediaDir);
await common.ensureSchema(pool, { mediaDir: webConfig.mediaDir });
await common.bootstrapDatabase(pool);
+1
View File
@@ -76,6 +76,7 @@ function createWebBootstrap(options) {
count: Array.isArray(payload.connections) ? payload.connections.length : 0,
connections: Array.isArray(payload.connections) ? payload.connections : []
});
uploadSyncService.scheduleAssignmentSync(uploadDir);
if (broadcastDashboardState) {
broadcastDashboardState().catch(function (error) {
console.error(error);
+91
View File
@@ -0,0 +1,91 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const sharp = require('sharp');
const { outputSize } = require('../../public/js/shared/image-crop');
const { applyLayout } = require('../../public/js/shared/region-layout');
async function prepareImageCopies(content, template, uploadDir) {
const files = [];
if (!uploadDir) return files;
const canvas = { width: template.canvas_size_width, height: template.canvas_size_height };
for (const region of template.regions || []) {
const image = content[region.region_key];
if (region.region_type !== 'image' || !image || !image.value) continue;
const crop = image.crop;
if (crop && (!['x', 'y', 'width', 'height'].every(field => Number.isFinite(crop[field])) || crop.width <= 0 || crop.height <= 0)) {
throw Object.assign(new Error('Invalid image crop.'), { statusCode: 400 });
}
const original = String(image.original_value || image.value);
if (!crop && !original.startsWith('/media/uploads/')) continue;
const filename = original.startsWith('/media/uploads/') ? original.slice('/media/uploads/'.length) : '';
if (!filename || filename !== path.basename(filename) || filename.includes('\\')) {
throw Object.assign(new Error('The original image must be a local upload.'), { statusCode: 400 });
}
const originalPath = path.join(uploadDir, filename);
const sourceStats = await fs.promises.stat(originalPath).catch(error => {
if (error.code === 'ENOENT') throw Object.assign(new Error('The original image is missing. Upload it again before resizing this image.'), { statusCode: 400 });
throw error;
});
const bounds = applyLayout(region, image, canvas);
const sourceBuffer = crop ? null : await fs.promises.readFile(originalPath);
const metadata = sourceBuffer ? await sharp(sourceBuffer, { animated: true }).metadata() : null;
const sourceSize = metadata && { width: metadata.width, height: metadata.pageHeight || metadata.height };
if (metadata && metadata.orientation >= 5) [sourceSize.width, sourceSize.height] = [sourceSize.height, sourceSize.width];
const size = outputSize(crop || sourceSize, bounds, canvas);
if (!crop && size.width >= sourceSize.width && size.height >= sourceSize.height) {
image.original_value = original;
image.value = original;
continue;
}
const extension = !crop && /\.gif$/i.test(filename) ? '.gif' : /\.jpe?g$/i.test(filename) ? '.jpg' : /\.webp$/i.test(filename) ? '.webp' : '.png';
const digest = crypto.createHash('sha256').update(JSON.stringify([1, original, sourceStats.size, sourceStats.mtimeMs, crop || null, size])).digest('hex');
const copyName = (crop ? 'image-crop-' : 'image-playback-') + digest + extension;
const copyPath = path.join(uploadDir, copyName);
let copyStats = await fs.promises.stat(copyPath).catch(error => { if (error.code !== 'ENOENT') throw error; return null; });
if (!copyStats) {
let output;
if (!crop) {
output = sharp(sourceBuffer, { animated: true }).autoOrient().resize(size.width, size.height, { fit: 'inside', withoutEnlargement: true });
} else {
const transformed = await sharp(originalPath).autoOrient().png().toBuffer();
const rotated = await sharp(transformed).flip(crop.scaleY === -1).flop(crop.scaleX === -1)
.rotate(Number(crop.rotate) || 0, { background: '#00000000' }).ensureAlpha().png().toBuffer({ resolveWithObject: true });
const left = Math.max(0, Math.floor(crop.x));
const top = Math.max(0, Math.floor(crop.y));
const right = Math.min(rotated.info.width, Math.ceil(crop.x + crop.width));
const bottom = Math.min(rotated.info.height, Math.ceil(crop.y + crop.height));
output = sharp({ create: { width: size.width, height: size.height, channels: 4, background: '#00000000' } });
if (right > left && bottom > top) {
const outputLeft = Math.max(0, Math.round((left - crop.x) * size.width / crop.width));
const outputTop = Math.max(0, Math.round((top - crop.y) * size.height / crop.height));
const width = Math.min(size.width - outputLeft, Math.max(1, Math.round((right - left) * size.width / crop.width)));
const height = Math.min(size.height - outputTop, Math.max(1, Math.round((bottom - top) * size.height / crop.height)));
if (width > 0 && height > 0) {
const pixels = await sharp(rotated.data).extract({ left, top, width: right - left, height: bottom - top })
.resize(width, height, { fit: 'fill' }).png().toBuffer();
output = output.composite([{ input: pixels, left: outputLeft, top: outputTop }]);
}
}
}
if (extension === '.jpg') output = output.jpeg({ quality: 92 });
else if (extension === '.webp') output = output.webp({ quality: 92 });
else if (extension === '.gif') output = output.gif();
else output = output.png();
const temporaryPath = copyPath + '.partial-' + crypto.randomUUID();
try {
await output.toFile(temporaryPath);
await fs.promises.rename(temporaryPath, copyPath);
} finally {
await fs.promises.unlink(temporaryPath).catch(() => {});
}
copyStats = await fs.promises.stat(copyPath);
}
image.original_value = original;
image.value = '/media/uploads/' + copyName;
files.push({ path: copyPath, filename: copyName, originalname: copyName, size: copyStats.size, mimetype: extension === '.jpg' ? 'image/jpeg' : extension === '.webp' ? 'image/webp' : extension === '.gif' ? 'image/gif' : 'image/png' });
}
return files;
}
module.exports = { prepareImageCopies };
+19
View File
@@ -285,7 +285,26 @@ async function removePendingMediaAssets(pool, uploadDir, mediaPaths) {
}
}
async function removeUnusedImageCopies(pool, uploadDir, mediaPaths) {
if (!pool || !uploadDir) return;
const paths = Array.from(new Set(Array.from(mediaPaths || []).map(normalizeMediaPath).filter(Boolean)));
for (const mediaPath of paths) {
const match = /^\/media\/uploads\/([^/\\]+)$/.exec(mediaPath);
if (!match || match[1] === '.' || match[1] === '..') continue;
const [rows] = await pool.query('SELECT id, original_name, is_published FROM c_media_assets WHERE media_path = ? LIMIT 1', [mediaPath]);
const asset = rows && rows[0];
const generated = /^image-(?:crop|playback)-[a-f0-9]{64}\.(?:jpe?g|png|webp|gif)$/i.test(match[1]);
if (!generated && !(asset && Number(asset.is_published) === 0 && /^slide-image-crop\.(?:jpe?g|png|webp)$/i.test(asset.original_name || ''))) continue;
if (await countMediaAssetReferences(pool, mediaPath) > 0) continue;
const filePath = path.join(uploadDir, match[1]);
await fs.promises.unlink(filePath).catch(function (error) { if (error.code !== 'ENOENT') throw error; });
await removeImageVariants(filePath);
await pool.query('DELETE FROM c_media_assets WHERE media_path = ?', [mediaPath]);
}
}
module.exports = {
removeUnusedImageCopies,
normalizeMediaPath,
createImageVariants,
removeImageVariants,
+69
View File
@@ -0,0 +1,69 @@
const { resolveRegionContent } = require('../../public/js/shared/region-content');
function parseContent(value) {
if (value && typeof value === 'object') return value;
try { return JSON.parse(value || '{}') || {}; } catch (_error) { return {}; }
}
function buildPlayerAssetPlan(data, snapshots, players, previousClients, collectReferences) {
const clients = new Map();
for (const client of previousClients || []) {
const previous = clients.get(client.id);
if (!previous || Number(client.connectedAt || client.seenAt) > Number(previous.connectedAt || previous.seenAt)) clients.set(client.id, Object.assign({}, client));
}
const byIdentifier = new Map(players.map(player => [String(player.identifier), player]));
const byUrl = new Map();
for (const player of players) {
for (const value of [player.public_base_url, player.internal_base_url]) {
if (value) byUrl.set(String(value).replace(/\/$/, ''), String(player.identifier));
}
}
snapshots.forEach(function (snapshot, key) {
const slug = String(snapshot.slug || key || '');
for (const connection of snapshot.connections || []) {
let identifier = String(connection.playerDeviceId || connection.playerIdentifier || '');
if (!identifier) identifier = byUrl.get(String(connection.playerPublicBaseUrl || '').replace(/\/$/, '')) || '';
if (!identifier && players.length === 1) identifier = String(players[0].identifier);
if (!byIdentifier.has(identifier)) continue;
const deviceId = String(connection.deviceId || '');
const id = deviceId || String(connection.clientId || '') || identifier + ':' + slug;
const connectedAt = Date.parse(connection.connectedAt || '') || 0;
const previous = clients.get(id);
if (previous && previous.connectedAt && connectedAt && previous.connectedAt > connectedAt) continue;
clients.set(id, { id, deviceId, playerIdentifier: identifier, screenSlug: slug, connectedAt, seenAt: Date.now() });
}
});
const screensById = new Map((data.screens || []).map(screen => [Number(screen.id), screen]));
const devices = new Map((data.onboardingDevices || []).map(device => [String(device.device_id), device]));
for (const client of clients.values()) {
const device = devices.get(client.deviceId);
if (!device) continue;
const screen = screensById.get(Number(device.screen_id));
client.screenSlug = screen ? String(screen.slug) : '';
}
const assets = new Map(players.map(player => [String(player.identifier), new Set()]));
const screenSlugs = new Map(players.map(player => [String(player.identifier), new Set()]));
for (const client of clients.values()) {
if (screenSlugs.has(client.playerIdentifier) && client.screenSlug) screenSlugs.get(client.playerIdentifier).add(client.screenSlug);
}
const slides = new Map((data.slides || []).map(slide => [Number(slide.id), slide]));
const templates = new Map((data.templates || []).map(template => [Number(template.id), template]));
for (const player of players) {
const identifier = String(player.identifier);
const refs = assets.get(identifier);
const playlistIds = new Set((data.screens || []).filter(screen => screenSlugs.get(identifier).has(String(screen.slug))).map(screen => Number(screen.playlist_id)));
for (const member of data.playlistSlides || []) {
if (!playlistIds.has(Number(member.playlist_id))) continue;
const slide = slides.get(Number(member.slide_id)) || member;
const content = parseContent(slide.content_json);
const template = templates.get(Number(slide.template_id));
if (!template) { collectReferences(content, refs); continue; }
collectReferences(template.background_image_path, refs);
const regions = template.regions || (data.templateRegions || []).filter(region => Number(region.template_id) === Number(template.id));
for (const region of regions) collectReferences(resolveRegionContent(region, content), refs);
}
}
return { assets, screenSlugs, clients: [...clients.values()] };
}
module.exports = { buildPlayerAssetPlan };
+160 -74
View File
@@ -9,6 +9,8 @@ const multer = require('multer');
const { createRequestAuthHeaders } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
const { countContentReferenceOwners } = require('#src/data/content-references');
const { buildPlayerAssetPlan } = require('#src/web/lib/media/player-assets');
const { getMediaStorageId } = require('#src/media-storage');
const {
fetchPlayerRegistrations,
getConfiguredPlayerIdentifier,
@@ -22,7 +24,7 @@ function normalizeUploadRoot(uploadDir) {
}
function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url);
return normalizeBaseUrl(player && player.internal_base_url) || normalizeBaseUrl(player && player.public_base_url);
}
function createUploadSyncService(options) {
@@ -47,6 +49,10 @@ function createUploadSyncService(options) {
let playerInternalBaseUrlPromise = null;
let playerTaskMetadata = null;
let playerTaskMetadataPromise = null;
let assignmentSignature = '';
let assignmentTimer = null;
const playerAssetSyncs = new Map();
const sharedStorageByPlayer = new Map();
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
throw new Error('createUploadSyncService requires the upload dependencies.');
@@ -73,7 +79,7 @@ function createUploadSyncService(options) {
return String(player && player.identifier || '').trim() === normalizedPreferredPlayerIdentifier;
}) || null;
if (exactPlayer) {
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(exactPlayer) || normalizedPreferredPlayerInternalBaseUrl || null;
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(exactPlayer) || normalizedPreferredPlayerInternalBaseUrl || configuredBridgeInternalBaseUrl || null;
const resolvedPublicBaseUrl = normalizeBaseUrl(exactPlayer && exactPlayer.public_base_url);
const resolvedIdentifier = String(exactPlayer && exactPlayer.identifier || '').trim();
playerInternalBaseUrl = resolvedInternalBaseUrl || null;
@@ -81,13 +87,16 @@ function createUploadSyncService(options) {
playerIdentifier: resolvedIdentifier || normalizedPreferredPlayerIdentifier || null,
playerPublicBaseUrl: resolvedPublicBaseUrl || null,
playerInternalBaseUrl: resolvedInternalBaseUrl || null,
playerViaBridge: Boolean(configuredBridgeInternalBaseUrl && (!normalizePlayerRowBaseUrl(exactPlayer) || !isLocalLikeBaseUrl(normalizePlayerRowBaseUrl(exactPlayer)))),
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null,
playerActive: true
};
return playerTaskMetadata;
}
} catch (_error) {
return { playerIdentifier: normalizedPreferredPlayerIdentifier, playerActive: false };
}
return { playerIdentifier: normalizedPreferredPlayerIdentifier, playerActive: false };
}
if (playerTaskMetadata && playerTaskMetadata.playerActive !== false) {
@@ -343,6 +352,128 @@ function createUploadSyncService(options) {
return refs;
}
function collectPlaybackUploadReferences(value, refs) {
refs = refs || new Set();
if (Array.isArray(value)) {
value.forEach(function (item) { collectPlaybackUploadReferences(item, refs); });
} else if (value && typeof value === 'object') {
Object.keys(value).forEach(function (key) {
if (key !== 'original_value') collectPlaybackUploadReferences(value[key], refs);
});
} else {
collectUploadReferencesFromValue(value, refs);
}
return refs;
}
function getAssetManifestDirectory(uploadDir) {
const root = path.resolve(uploadDir);
return path.join(path.basename(root) === 'uploads' ? path.dirname(root) : root, 'player-cache', 'media-sync');
}
async function getPlayerAssetPlan(uploadDir) {
if (!uploadDir || !pool || typeof common.fetchAdminData !== 'function') return null;
const directory = getAssetManifestDirectory(uploadDir);
const names = await fs.promises.readdir(directory).catch(function (error) { if (error.code === 'ENOENT') return []; throw error; });
const states = new Map();
for (const name of names.filter(function (value) { return /^[a-f0-9]{64}\.json$/.test(value); })) {
const state = JSON.parse(await fs.promises.readFile(path.join(directory, name), 'utf8'));
if (state.playerIdentifier && Array.isArray(state.files) && Array.isArray(state.clients)) states.set(state.playerIdentifier, state);
}
const players = await fetchPlayerRegistrations(pool);
const data = await common.fetchAdminData(pool);
const [devices] = await pool.query('SELECT device_id, screen_id FROM d_onboarding_devices');
const clients = Array.from(states.values()).flatMap(function (state) { return state.clients; });
const plan = buildPlayerAssetPlan(Object.assign({}, data, { onboardingDevices: devices }), playerSnapshotCache, players, clients, collectPlaybackUploadReferences);
return { directory: directory, states: states, players: players, plan: plan };
}
function syncPlayerAssetPlan(taskPayload, uploadDir) {
const key = String(taskPayload.playerIdentifier || 'configured');
const previous = playerAssetSyncs.get(key) || Promise.resolve();
const current = previous.catch(function () {}).then(function () { return reconcilePlayerAssetPlan(taskPayload, uploadDir); });
playerAssetSyncs.set(key, current);
return current.finally(function () { if (playerAssetSyncs.get(key) === current) playerAssetSyncs.delete(key); });
}
async function reconcilePlayerAssetPlan(taskPayload, uploadDir) {
const metadata = await getPlayerTaskMetadata(taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
if (!metadata || metadata.playerActive === false || !metadata.playerIdentifier) return false;
const state = await getPlayerAssetPlan(uploadDir);
if (!state || !state.plan.assets.has(metadata.playerIdentifier)) return false;
const previous = state.states.get(metadata.playerIdentifier);
if (!previous && !state.plan.clients.some(function (client) { return client.playerIdentifier === metadata.playerIdentifier; })) return false;
const desired = Array.from(state.plan.assets.get(metadata.playerIdentifier));
const previousFiles = previous ? previous.files : await collectUploadPathsFromDirectory(path.basename(path.resolve(uploadDir)) === 'uploads' ? uploadDir : path.join(uploadDir, 'uploads'));
const clients = state.plan.clients.filter(function (client) { return client.playerIdentifier === metadata.playerIdentifier; });
await fs.promises.mkdir(state.directory, { recursive: true });
const filename = crypto.createHash('sha256').update(metadata.playerIdentifier).digest('hex') + '.json';
const target = path.join(state.directory, filename);
async function saveManifest(files) {
const temporary = target + '.tmp-' + crypto.randomUUID();
try {
await fs.promises.writeFile(temporary, JSON.stringify({ playerIdentifier: metadata.playerIdentifier, files: files, clients: clients }));
await fs.promises.rename(temporary, target);
} finally {
await fs.promises.unlink(temporary).catch(function () {});
}
}
await saveManifest(Array.from(new Set(previousFiles.concat(desired))));
for (const reference of desired) {
if (!await pushUploadFileToPlayer(reference, uploadDir, metadata.playerInternalBaseUrl, metadata.playerIdentifier)) {
throw new Error('Unable to sync required playback asset to ' + metadata.playerIdentifier + ': ' + reference);
}
}
const wanted = new Set(desired);
const sharedFiles = [];
for (const reference of previousFiles) {
if (wanted.has(reference)) continue;
if (!metadata.playerViaBridge && await uploadFileExists(uploadDir, reference) && await sharesWebMediaStorage(metadata, uploadDir)) {
sharedFiles.push(reference);
continue;
}
if (!await removeSingleUploadFileFromPlayer(reference, metadata.playerInternalBaseUrl, metadata.playerIdentifier)) {
throw new Error('Unable to remove obsolete playback asset from ' + metadata.playerIdentifier + ': ' + reference);
}
}
await saveManifest(desired.concat(sharedFiles));
return true;
}
async function sharesWebMediaStorage(metadata, uploadDir) {
const key = metadata.playerIdentifier + ':' + metadata.playerInternalBaseUrl;
if (sharedStorageByPlayer.has(key)) return sharedStorageByPlayer.get(key);
try {
const response = await fetch(metadata.playerInternalBaseUrl + '/api/media/config', {
headers: Object.assign({ 'x-pulse-player-device-id': metadata.playerIdentifier }, createRequestAuthHeaders({ method: 'GET', pathname: '/api/media/config' })),
signal: AbortSignal.timeout(10000)
});
if (!response.ok || typeof response.json !== 'function') return true;
const config = await response.json();
if (!config.storageId) return true;
const root = path.resolve(uploadDir);
const shared = config.storageId === await getMediaStorageId(path.basename(root) === 'uploads' ? path.dirname(root) : root);
sharedStorageByPlayer.set(key, shared);
return shared;
} catch (_error) { return true; }
}
function scheduleAssignmentSync(uploadDir) {
const associations = [];
playerSnapshotCache.forEach(function (snapshot, slug) {
for (const client of snapshot.connections || []) associations.push([slug, client.deviceId || client.clientId || '', client.playerDeviceId || client.playerIdentifier || client.playerPublicBaseUrl || '']);
});
const signature = JSON.stringify(associations.sort(function (left, right) { return JSON.stringify(left).localeCompare(JSON.stringify(right)); }));
if (signature === assignmentSignature) return;
assignmentSignature = signature;
if (assignmentTimer) clearTimeout(assignmentTimer);
assignmentTimer = setTimeout(function () {
assignmentTimer = null;
syncPlaylistUploadsOnChange({ key: 'player-assignments', localUploadDir: uploadDir, refreshScreenSlugs: Array.from(new Set(associations.map(function (association) { return association[0]; }))) }).catch(function (error) { console.warn('Unable to reconcile player media assignments:', error); });
}, 250);
if (assignmentTimer.unref) assignmentTimer.unref();
}
function collectUploadReferencesFromTemplate(template) {
const refs = new Set();
if (!template) {
@@ -396,7 +527,8 @@ function createUploadSyncService(options) {
}
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
return;
const { removeUnusedImageCopies } = require('#src/web/lib/media/library');
await removeUnusedImageCopies(pool, uploadDir, uploadPaths);
}
async function collectUploadPathsFromDirectory(uploadDir) {
@@ -531,12 +663,13 @@ function createUploadSyncService(options) {
return false;
}
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl(preferredPlayerIdentifier);
if (!targetBaseUrl) {
return false;
}
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
if (!playerMetadata || playerMetadata.playerActive === false) return false;
const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl
? configuredBridgeInternalBaseUrl
: targetBaseUrl;
@@ -592,12 +725,13 @@ function createUploadSyncService(options) {
return false;
}
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl(preferredPlayerIdentifier);
if (!targetBaseUrl) {
return false;
}
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
if (!playerMetadata || playerMetadata.playerActive === false) return false;
const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl
? configuredBridgeInternalBaseUrl
: targetBaseUrl;
@@ -787,6 +921,9 @@ function createUploadSyncService(options) {
return;
}
if (operation.previousUploadRefs.length) {
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs);
}
const players = await fetchLivePlayerRegistrations();
if (!players.length) {
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
@@ -795,7 +932,17 @@ function createUploadSyncService(options) {
});
}
return Promise.all(players.map(function (player) {
const state = await getPlayerAssetPlan(operation.localUploadDir);
const changed = new Set(operation.previousUploadRefs.concat(operation.nextUploadRefs));
const targets = state ? players.filter(function (player) {
const identifier = String(player.identifier);
const desired = Array.from(state.plan.assets.get(identifier) || []);
const previous = state.states.get(identifier);
return operation.key === 'player-assignments' || desired.some(function (reference) { return changed.has(reference); }) ||
(previous && previous.files.some(function (reference) { return changed.has(reference); })) ||
Array.from(state.plan.screenSlugs.get(identifier) || []).some(function (slug) { return operation.refreshScreenSlugs.includes(slug); });
}) : [];
return Promise.all(targets.map(function (player) {
const playerIdentifier = String(player && player.identifier || '').trim();
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, '');
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, '');
@@ -937,36 +1084,8 @@ function createUploadSyncService(options) {
const playerMetadata = await getPlayerTaskMetadata(taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
const data = await common.fetchAdminData(pool);
const uploadRefs = new Set();
(data.slides || []).forEach(function (slide) {
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
uploadRefs.add(reference);
});
});
(data.templates || []).forEach(function (template) {
collectUploadReferencesFromTemplate(template).forEach(function (reference) {
uploadRefs.add(reference);
});
});
await syncPlayerAssetPlan(taskPayload, uploadDir);
const fontLibraryOperations = collectFontLibrarySyncOperations(uploadDir).concat(require('#src/web/lib/media/weather-icon-library').collectWeatherIconSyncOperations(uploadDir));
const generatedCachePaths = await collectUploadPathsFromDirectory(uploadDir);
generatedCachePaths.filter(function (uploadPath) {
return String(uploadPath || '').startsWith('/media/player-cache/remote-images/');
}).forEach(function (uploadPath) {
uploadRefs.add(uploadPath);
});
for (const uploadPath of uploadRefs) {
const uploadPaths = await getUploadPathsWithVariants(uploadPath, uploadDir);
uploadPaths.forEach(function (variantPath) {
queuePlayerUploadSync({
type: 'put',
uploadPath: variantPath,
uploadDir: uploadDir,
metadata: playerMetadata
});
});
}
fontLibraryOperations.forEach(function (operation) {
if (!operation || !operation.uploadPath) {
return;
@@ -976,6 +1095,8 @@ function createUploadSyncService(options) {
type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put',
uploadPath: operation.uploadPath,
uploadDir: uploadDir,
playerIdentifier: playerMetadata.playerIdentifier,
playerInternalBaseUrl: playerMetadata.playerInternalBaseUrl,
metadata: playerMetadata
});
});
@@ -985,45 +1106,7 @@ function createUploadSyncService(options) {
if (mode === 'playlist') {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
const generatedCachePaths = await collectUploadPathsFromDirectory(operation.localUploadDir);
const nextUploadRefs = operation.nextUploadRefs.concat(generatedCachePaths.filter(function (uploadPath) {
return String(uploadPath || '').startsWith('/media/player-cache/remote-images/');
}));
if (nextUploadRefs.length) {
await syncUploadRefsToPlayer(nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
}
if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs);
const removedUploadRefs = operation.previousUploadRefs.filter(function (reference) {
return !nextUploadRefSet.has(reference);
});
for (let i = 0; i < removedUploadRefs.length; i += 1) {
const removedUploadRef = removedUploadRefs[i];
const referenceCount = await countUploadReferences(pool, removedUploadRef);
if (referenceCount > 0) {
continue;
}
if (isLocalLikeBaseUrl(taskPayload.playerInternalBaseUrl) && await uploadFileExists(operation.localUploadDir, removedUploadRef)) {
continue;
}
const deleted = await removeUploadFileFromPlayer(removedUploadRef, operation.localUploadDir, taskPayload.playerInternalBaseUrl, taskPayload.playerIdentifier);
if (!deleted) {
queuePlayerUploadSync({
type: 'delete',
uploadPath: removedUploadRef,
uploadDir: operation.localUploadDir,
playerIdentifier: taskPayload.playerIdentifier,
playerInternalBaseUrl: taskPayload.playerInternalBaseUrl,
metadata: playerMetadata
});
}
}
await removeUnusedUploadFiles(pool, operation.localUploadDir, removedUploadRefs);
}
await syncPlayerAssetPlan(taskPayload, operation.localUploadDir);
if (operation.refreshScreenSlugs.length) {
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
@@ -1074,6 +1157,9 @@ function createUploadSyncService(options) {
createUploadMiddleware: createUploadMiddleware,
normalizeUploadReference: normalizeUploadReference,
collectUploadReferencesFromValue: collectUploadReferencesFromValue,
collectPlaybackUploadReferences: collectPlaybackUploadReferences,
scheduleAssignmentSync: scheduleAssignmentSync,
getPlayerAssetPlan: getPlayerAssetPlan,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
+1 -1
View File
@@ -28,7 +28,7 @@ function getRegionModuleScripts(assetVersion) {
}
function getRegionEditorScripts(assetVersion) {
return ['js/shared/region-content.js', 'js/shared/weather-icons.js'].concat(REGION_CORE_SCRIPTS).map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion));
return ['js/shared/region-content.js', 'js/shared/weather-icons.js', 'js/shared/image-crop.js'].concat(REGION_CORE_SCRIPTS).map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion), [withAssetVersion('js/slides/slide-image-cropper.js', assetVersion)]);
}
module.exports = {
+35
View File
@@ -3321,6 +3321,41 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
justify-items: end;
}
.slide-image-region-preview-tools {
display: grid;
grid-template-columns: 2.5rem minmax(0, 1fr);
min-height: 176px;
background: var(--bs-tertiary-bg);
}
.slide-image-region-preview-tools > .slide-image-region-preview-box {
min-width: 0;
}
.slide-image-region-crop {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-right: 1px solid var(--bs-border-color);
border-radius: 0;
background: transparent;
color: var(--bs-secondary-color);
font-size: 1.125rem;
cursor: pointer;
}
.slide-image-region-crop:hover:not(:disabled) {
background: var(--bs-secondary-bg);
color: var(--bs-body-color);
}
.slide-image-region-crop:focus-visible {
outline: 2px solid var(--bs-primary);
outline-offset: -2px;
}
.slide-image-region-preview-shell {
position: relative;
display: block;
+52 -4
View File
@@ -26,10 +26,19 @@
return '<img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="" style="width:100%;height:100%;object-fit:contain;display:block;" />';
}
function renderCropButton(region) {
return '<button type="button" class="slide-image-region-crop" data-edit-image-crop="' + region.id + '" data-region-key="' + escapeHtml(region.region_key) + '" data-region-width="' + escapeHtml(region.width) + '" data-region-height="' + escapeHtml(region.height) + '" data-region-ratio="' + escapeHtml(region.width + ':' + region.height) + '" title="Edit crop" aria-label="Edit crop"><i class="bi bi-crop" aria-hidden="true"></i></button>';
}
function renderFramingControls(region, value) {
var current = value || {};
return '<input type="hidden" name="region_image_original_' + region.id + '" value="' + escapeHtml(current.original_value || current.value || '') + '">' +
'<input type="hidden" name="region_image_crop_' + region.id + '" value="' + escapeHtml(current.crop ? JSON.stringify(current.crop) : '') + '">';
}
function renderEditorCard(context) {
var region = context.region;
var current = String(context.current || '');
var regionRatio = String(context.regionRatio || '1:1');
var uploadConfig = context.uploadConfig || {};
var uploadMaxLabel = String(uploadConfig.limitLabel || context.uploadMaxLabel || '100 MB');
var uploadAccept = Array.isArray(uploadConfig.accept) ? uploadConfig.accept.join(', ') : String(uploadConfig.accept || 'image/png, image/jpeg, image/gif, image/webp, image/svg+xml');
@@ -42,7 +51,7 @@
'<div class="row g-3 align-items-start">' +
'<div class="col-12 col-md-8 d-flex flex-column">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" data-media-picker data-media-picker-type="image" data-media-picker-hidden="existing_region_image_' + region.id + '" for="region_image_' + region.id + '">' +
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" />' +
'<span class="slide-image-region-upload-zone-content">' +
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-copy">' +
@@ -60,6 +69,8 @@
'</label>' +
'</div>' +
'<div class="col-12 col-md-4">' +
'<div class="slide-image-region-preview-tools">' +
renderCropButton(region) +
'<div class="slide-image-region-preview-box">' +
(current
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
@@ -68,13 +79,14 @@
'</div>'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />'
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' + renderFramingControls(region, context.imageContent)
});
}
function buildEditorCardContext(context) {
function buildEditorCardContext(context, existingContent) {
var defaultConfig = context && context.uploadConfig ? context.uploadConfig : {};
var configuredTypes = Array.isArray(context && context.uploadMimeTypes)
? context.uploadMimeTypes.filter(function (mimeType) { return String(mimeType || '').indexOf('image/') === 0; })
@@ -92,6 +104,7 @@
return {
region: context.region,
current: String(context.current || ''),
imageContent: (existingContent || {})[context.region.region_key] || {},
regionRatio: String(context.regionRatio || '1:1'),
uploadConfig: {
accept: accept,
@@ -109,12 +122,47 @@
var imageHidden = card && card.querySelector ? card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]') : null;
var imageFileInput = card && card.querySelector ? card.querySelector('input[type="file"][name="region_image_' + region.id + '"]') : null;
var original = card && card.querySelector ? card.querySelector('[name="region_image_original_' + region.id + '"]') : null;
var crop = card && card.querySelector ? card.querySelector('[name="region_image_crop_' + region.id + '"]') : null;
var cropData = current.crop || null;
try { if (crop) cropData = JSON.parse(crop.value || 'null'); } catch (_error) { cropData = null; }
return {
value: imageFileInput && imageFileInput.dataset.previewUrl ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : (current && current.value !== undefined ? current.value : '')),
original_value: original ? original.value : current.original_value || current.value || '',
crop: cropData,
existingContent: existingContent || {}
};
}
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('change', function (event) {
var input = event.target;
if (!input.name || input.name.indexOf('existing_region_image_') !== 0 || input.dataset.applyingCrop === '1') return;
var card = input.closest('[data-region-id]');
if (!card) return;
var regionId = input.name.slice('existing_region_image_'.length);
var original = card.querySelector('[name="region_image_original_' + regionId + '"]');
var crop = card.querySelector('[name="region_image_crop_' + regionId + '"]');
if (original) original.value = input.value;
if (crop) crop.value = '';
if (input.value) input.dataset.cropSelectionPending = '1';
else delete input.dataset.cropSelectionPending;
if (!input.value) return;
var selectedValue = input.value;
var openCropper = function () {
if (!card.isConnected || input.value !== selectedValue) return;
var button = card.querySelector('[data-edit-image-crop]');
if (button && !button.disabled) button.click();
};
var picker = document.getElementById('media-picker-modal');
if (picker && picker.classList.contains('show')) {
picker.addEventListener('hidden.bs.modal', openCropper, { once: true });
} else {
window.setTimeout(openCropper, 0);
}
}, true);
}
function handleMediaInputChange(context) {
if (!context || !context.file || typeof context.uploadFile !== 'function') {
return false;
+79 -165
View File
@@ -35,17 +35,7 @@ const uploadInput = document.querySelector('[data-media-library-upload]');
const uploadZone = document.querySelector('[data-media-library-upload-zone]');
const uploadName = document.querySelector('[data-media-library-upload-name]');
const uploadModal = document.getElementById('media-library-upload-modal');
const cropModal = document.getElementById('media-library-crop-modal');
const cropImage = cropModal && cropModal.querySelector('#media-library-crop-image');
const cropApply = cropModal && cropModal.querySelector('[data-media-library-crop-apply]');
const cropCancel = cropModal && cropModal.querySelector('[data-media-library-crop-cancel]');
const cropStatus = cropModal && cropModal.querySelector('#media-library-crop-status');
const cropFrame = cropModal && cropModal.querySelector('.slide-image-cropper-frame');
let cropper = null;
let cropObjectUrl = '';
let cropSourceFile = null;
let cropFlipX = 1;
let cropFlipY = 1;
let uploadInProgress = false;
function getColumnsPerRow() {
return window.innerWidth >= 1200 ? 6 : window.innerWidth >= 992 ? 3 : window.innerWidth >= 576 ? 2 : 1;
@@ -73,28 +63,6 @@ if (addMediaButton && uploadModal) {
});
}
function clearCropper() {
if (cropper) {
cropper.destroy();
cropper = null;
}
if (cropObjectUrl) {
URL.revokeObjectURL(cropObjectUrl);
cropObjectUrl = '';
}
cropSourceFile = null;
cropFlipX = 1;
cropFlipY = 1;
if (cropStatus) cropStatus.textContent = '';
if (cropFrame) cropFrame.classList.remove('is-loading');
}
function setCropperActionState(disabled) {
if (!cropModal) return;
cropModal.querySelectorAll('[data-media-library-crop-action]').forEach(function (button) {
button.disabled = Boolean(disabled);
});
}
function appendGalleryAsset(asset) {
if (!gallery || !asset) return;
const column = document.createElement('div');
@@ -178,52 +146,9 @@ function setCropperActionState(disabled) {
column.querySelector('.media-gallery-card').classList.toggle('media-gallery-card-selected', selectedAssetIds.has(String(asset.id || '')));
}
function applyCropRatio(value) {
if (!cropper) return;
cropper.setAspectRatio(value === 'free' ? NaN : Number(value.split(':')[0]) / Number(value.split(':')[1]));
cropModal.querySelectorAll('[data-media-library-crop-action="ratio"]').forEach(function (button) {
const active = button.dataset.mediaLibraryCropRatio === value;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
function initMediaCropper() {
if (!cropSourceFile || !cropImage || !window.Cropper || cropper) return;
if (cropFrame) cropFrame.classList.add('is-loading');
cropper = new window.Cropper(cropImage, {
aspectRatio: NaN,
autoCropArea: 1,
background: false,
dragMode: 'move',
initialAspectRatio: NaN,
movable: true,
responsive: true,
rotatable: true,
scalable: true,
viewMode: 1,
zoomOnTouch: true,
zoomOnWheel: true,
ready: function () {
if (cropFrame) cropFrame.classList.remove('is-loading');
if (cropper && cropper.container) {
cropper.container.style.width = '100%';
cropper.container.style.height = '560px';
cropper.container.style.maxHeight = '70vh';
}
const containerData = cropper.getContainerData();
const imageData = cropper.getImageData();
const fitRatio = Math.min(Number(containerData.width || 0) / Number(imageData.naturalWidth || 1), Number(containerData.height || 0) / Number(imageData.naturalHeight || 1), 1);
if (fitRatio > 0) cropper.zoomTo(fitRatio);
applyCropRatio('free');
setCropperActionState(false);
}
});
}
function setUploadFile(file, shouldCrop) {
if (!uploadInput || !file) {
return;
function setUploadFile(file) {
if (!uploadInput || uploadInput.disabled || !file) {
return false;
}
const allowedTypes = String(uploadInput.accept || '').split(',').map(function (value) { return value.trim().toLowerCase(); }).filter(Boolean);
const fileType = String(file.type || '').toLowerCase();
@@ -232,12 +157,12 @@ function setUploadFile(file, shouldCrop) {
if (!matchesType) {
window.PulseUpload.showMessage('This media type is disabled in Media Uploads settings.');
uploadInput.value = '';
return;
return false;
}
if (maxBytes && file.size > maxBytes) {
window.PulseUpload.showMessage('This file is larger than the current upload limit of ' + window.PulseUpload.formatUploadLimit(maxBytes) + '.');
uploadInput.value = '';
return;
return false;
}
const transfer = new DataTransfer();
@@ -247,15 +172,7 @@ function setUploadFile(file, shouldCrop) {
uploadName.textContent = file.name;
}
if (shouldCrop && fileType.indexOf('image/') === 0 && cropModal && cropImage && window.Cropper) {
clearCropper();
setCropperActionState(true);
cropSourceFile = file;
cropObjectUrl = URL.createObjectURL(file);
cropImage.src = cropObjectUrl;
if (cropFrame) cropFrame.classList.add('is-loading');
showModal(cropModal);
}
return true;
}
function submitUpload() {
@@ -272,11 +189,8 @@ function submitUpload() {
if (uploadInput) {
uploadInput.addEventListener('change', function () {
const file = uploadInput.files && uploadInput.files[0];
if (file) {
setUploadFile(file, true);
if (String(file.type || '').toLowerCase().indexOf('image/') !== 0 || !(cropModal && cropImage && window.Cropper)) {
submitUpload();
}
if (file && setUploadFile(file)) {
submitUpload();
}
});
}
@@ -293,91 +207,91 @@ if (uploadZone) {
event.preventDefault();
uploadZone.classList.remove('is-dragover');
const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
if (file) {
setUploadFile(file, true);
if (String(file.type || '').toLowerCase().indexOf('image/') !== 0 || !(cropModal && cropImage && window.Cropper)) {
submitUpload();
}
}
});
}
if (cropApply) {
cropApply.addEventListener('click', function () {
if (!cropper || !cropSourceFile || !uploadInput) {
hideModal(cropModal);
return;
}
const outputType = cropSourceFile.type === 'image/jpeg' || cropSourceFile.type === 'image/webp' ? cropSourceFile.type : 'image/png';
cropper.getCroppedCanvas().toBlob(function (blob) {
if (!blob) {
window.PulseUpload.showMessage('Unable to crop this image.');
return;
}
const extension = outputType === 'image/jpeg' ? '.jpg' : outputType === 'image/webp' ? '.webp' : '.png';
const croppedFile = new File([blob], cropSourceFile.name.replace(/\.[^.]+$/, '') + extension, { type: outputType, lastModified: Date.now() });
clearCropper();
setUploadFile(croppedFile, false);
hideModal(cropModal);
if (file && setUploadFile(file)) {
submitUpload();
}, outputType, 0.92);
});
}
if (cropCancel) {
cropCancel.addEventListener('click', function () {
clearCropper();
if (uploadInput) uploadInput.value = '';
if (uploadName) uploadName.textContent = 'No file selected';
hideModal(cropModal);
});
}
if (cropModal) {
cropModal.addEventListener('click', function (event) {
const button = event.target && event.target.closest ? event.target.closest('[data-media-library-crop-action]') : null;
if (!button || !cropper) return;
const action = button.dataset.mediaLibraryCropAction;
if (action === 'rotate-left') cropper.rotate(-90);
if (action === 'rotate-right') cropper.rotate(90);
if (action === 'flip-horizontal') {
cropFlipX *= -1;
cropper.scaleX(cropFlipX);
}
if (action === 'flip-vertical') {
cropFlipY *= -1;
cropper.scaleY(cropFlipY);
}
if (action === 'ratio') applyCropRatio(button.dataset.mediaLibraryCropRatio || 'free');
if (action === 'reset') {
cropper.reset();
cropFlipX = 1;
cropFlipY = 1;
applyCropRatio('free');
}
});
}
if (cropModal) {
cropModal.addEventListener('shown.bs.modal', function () {
if (cropSourceFile && cropImage && window.Cropper && !cropper) {
initMediaCropper();
}
});
cropModal.addEventListener('hidden.bs.modal', function () {
clearCropper();
});
function setUploadProgress(percent, message, indeterminate) {
if (!uploadZone) return;
const progress = uploadZone.querySelector('[data-media-library-upload-progress]');
const label = uploadZone.querySelector('[data-media-library-upload-progress-text]');
const bar = uploadZone.querySelector('[data-media-library-upload-progress-bar]');
const fill = uploadZone.querySelector('[data-media-library-upload-progress-fill]');
uploadZone.classList.toggle('is-uploading', uploadInProgress);
uploadZone.setAttribute('aria-busy', uploadInProgress ? 'true' : 'false');
if (progress) progress.hidden = !uploadInProgress;
if (label) label.textContent = message;
if (bar) {
if (indeterminate) bar.removeAttribute('aria-valuenow');
else bar.setAttribute('aria-valuenow', String(percent));
}
if (fill) {
fill.style.width = percent + '%';
fill.textContent = indeterminate ? '' : percent + '%';
fill.classList.toggle('progress-bar-animated', indeterminate);
}
}
if (uploadForm) {
uploadForm.addEventListener('submit', function (event) {
event.preventDefault();
if (uploadInProgress) return;
if (uploadInput && !uploadInput.files.length) {
event.preventDefault();
window.PulseUpload.showMessage('Choose an image or video to upload.');
return;
}
const formData = new FormData(uploadForm);
const xhr = new XMLHttpRequest();
const buttons = uploadModal ? Array.from(uploadModal.querySelectorAll('button')).map(button => ({ button, disabled: button.disabled })) : [];
uploadInProgress = true;
if (uploadInput) uploadInput.disabled = true;
buttons.forEach(state => { state.button.disabled = true; });
setUploadProgress(0, 'Uploading...', true);
function finish() {
uploadInProgress = false;
if (uploadInput) uploadInput.disabled = false;
buttons.forEach(state => { state.button.disabled = state.disabled; });
setUploadProgress(0, '', false);
}
function fail(message) {
finish();
window.PulseUpload.showMessage(message);
}
xhr.open('POST', uploadForm.action, true);
xhr.withCredentials = true;
xhr.timeout = 300000;
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.onprogress = function (event) {
if (!event.lengthComputable || !event.total) { setUploadProgress(100, 'Uploading...', true); return; }
const percent = Math.min(100, Math.round(event.loaded / event.total * 100));
setUploadProgress(percent, percent === 100 ? 'Processing...' : 'Uploading ' + percent + '%', percent === 100);
};
xhr.upload.onload = function () { setUploadProgress(100, 'Processing...', true); };
xhr.onload = async function () {
let result;
try { result = JSON.parse(xhr.responseText || '{}'); } catch (_error) { fail('Unable to upload media. Check your sign-in status and retry.'); return; }
if (xhr.status < 200 || xhr.status >= 300 || !result.success) { fail(result.error || 'Unable to upload media.'); return; }
finish();
if (uploadInput) uploadInput.value = '';
hideModal(uploadModal);
if (typeof window.showToast === 'function') window.showToast('Media uploaded.', 'success');
await refreshGallery();
};
xhr.onerror = function () { fail('Unable to upload media. Check your connection and retry.'); };
xhr.onabort = function () { fail('Upload cancelled.'); };
xhr.ontimeout = function () { fail('Upload timed out. Please try again.'); };
try { xhr.send(formData); } catch (_error) { fail('Unable to upload media. Please try again.'); }
});
}
if (uploadModal) {
uploadModal.addEventListener('hide.bs.modal', function (event) { if (uploadInProgress) event.preventDefault(); });
}
function getGalleryFilters() {
const params = new URLSearchParams();
params.set('search', String(search && search.value || '').trim());
+29
View File
@@ -0,0 +1,29 @@
(function (root, factory) {
var api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.pulseImageCrop = api;
}(typeof window !== 'undefined' ? window : null, function () {
function dimension(value, fallback) {
var number = Number(value);
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function outputSize(crop, region, canvas) {
var width = dimension(crop && crop.width, 1);
var height = dimension(crop && crop.height, 1);
var maximumWidth = Math.min(dimension(region && region.width, width), dimension(canvas && canvas.width, width));
var maximumHeight = Math.min(dimension(region && region.height, height), dimension(canvas && canvas.height, height));
var scale = Math.min(1, maximumWidth / width, maximumHeight / height);
return { width: Math.max(1, Math.floor(width * scale)), height: Math.max(1, Math.floor(height * scale)) };
}
function isFullImage(crop, image) {
if (!crop || !image || !(image.naturalWidth > 0) || !(image.naturalHeight > 0)) return false;
return Math.abs(Number(crop.x) || 0) < 0.01 && Math.abs(Number(crop.y) || 0) < 0.01 &&
Math.abs(Number(crop.width) - image.naturalWidth) < 0.01 && Math.abs(Number(crop.height) - image.naturalHeight) < 0.01 &&
Math.abs((Number(crop.rotate) || 0) % 360) < 0.01 &&
(crop.scaleX === undefined || crop.scaleX === 1) && (crop.scaleY === undefined || crop.scaleY === 1);
}
return { outputSize: outputSize, isFullImage: isFullImage };
}));
+257 -554
View File
@@ -1,581 +1,284 @@
// Slide image cropper modal wiring and file replacement helpers.
(function () {
var templateFields = document.getElementById('template-fields');
var modal = document.getElementById('slide-image-cropper-modal');
var image = document.getElementById('slide-image-cropper-image');
var status = document.getElementById('slide-image-cropper-status');
var modal;
var cropper;
var active;
var busy = false;
var loader;
var draftCopies = new Map();
if (!templateFields || !modal || !image || !status) {
return;
function cleanupCopies(paths) {
var unique = Array.from(new Set(paths));
if (!unique.length) return;
fetch('/slides/uploads/cleanup', {
method: 'POST', credentials: 'same-origin', keepalive: true,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ uploadPaths: unique })
}).catch(function () {});
}
var cropper = null;
var currentInput = null;
var currentFile = null;
var currentObjectUrl = '';
var cropperFrame = modal.querySelector('.slide-image-cropper-frame');
var flipX = 1;
var flipY = 1;
var currentAspectRatio = NaN;
var currentRegionAspectRatio = NaN;
var currentRegionAspectRatioLabel = 'Region';
var listenersAttached = false;
var uploadMaxBytes = 100 * 1024 * 1024;
var uploadMaxLabel = '100 MB';
var editorDataElement = document.getElementById('slide-editor-data');
try {
var editorData = JSON.parse(editorDataElement && (editorDataElement.value || editorDataElement.textContent) || '{}') || {};
uploadMaxBytes = Number(editorData.uploadLimits && editorData.uploadLimits.imageMaxBytes) || uploadMaxBytes;
var megabytes = uploadMaxBytes / 1024 / 1024;
uploadMaxLabel = Number.isInteger(megabytes) ? String(megabytes) + ' MB' : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') + ' MB';
} catch (_error) {
function trackCopy(input, path) {
var previous = draftCopies.get(input) || [];
draftCopies.set(input, [path]);
cleanupCopies(previous.filter(function (value) { return value !== path; }));
}
function getRegionId(input) {
var match = String(input && input.name || '').match(/^region_image_(\d+)$/);
return match ? match[1] : '';
}
function getExistingInput(regionId) {
if (!regionId) {
return null;
}
return document.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
}
function setStatus(message) {
status.textContent = String(message || '');
}
function setCropperLoading(loading) {
if (cropperFrame) {
cropperFrame.classList.toggle('is-loading', Boolean(loading));
}
}
function showModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).show();
}
}
function hideModal() {
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).hide();
}
}
function destroyCropper() {
if (cropper) {
cropper.destroy();
cropper = null;
}
}
function revokeObjectUrl() {
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = '';
}
}
function resetModalState() {
destroyCropper();
revokeObjectUrl();
setCropperLoading(false);
currentInput = null;
currentFile = null;
flipX = 1;
flipY = 1;
image.removeAttribute('src');
image.alt = 'Selected image to crop';
setStatus('');
}
function setButtonState(disabled) {
modal.querySelectorAll('[data-slide-image-cropper-action]').forEach(function (button) {
button.disabled = Boolean(disabled) && button.getAttribute('data-slide-image-cropper-action') !== 'reset';
});
}
function ratioLabelForValue(value) {
if (value === 'free') {
return 'free';
}
if (value === 'region') {
return 'region';
}
return String(value || '').trim();
}
function parseAspectRatio(value) {
var parts = String(value || '').trim().split(/[:/]/);
var width = Number(parts[0]);
var height = Number(parts[1]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return NaN;
}
return width / height;
}
function isSvgFile(file) {
if (!file) {
return false;
}
return String(file.type || '').toLowerCase() === 'image/svg+xml' || /\.svg$/i.test(String(file.name || ''));
}
function isGifFile(file) {
if (!file) {
return false;
}
return String(file.type || '').toLowerCase() === 'image/gif' || /\.gif$/i.test(String(file.name || ''));
}
function isSpecialRasterizationFile(file) {
return isSvgFile(file) || isGifFile(file);
}
function getSpecialFileWarning(file) {
if (isGifFile(file)) {
return 'GIF selected. If you crop, rotate, or flip this image, it will be rasterized and the animation will be lost. Leave the full image selected to keep the original GIF.';
}
return 'SVG selected. If you crop, rotate, or flip this image, it will be rasterized. Leave the full image selected to keep the original SVG.';
}
function isOriginalSpecialSelection() {
if (!cropper || !currentFile || !isSpecialRasterizationFile(currentFile) || typeof cropper.getData !== 'function' || typeof cropper.getImageData !== 'function') {
return false;
}
var cropData = cropper.getData(true) || {};
var imageData = cropper.getImageData() || {};
var width = Number(imageData.naturalWidth || 0);
var height = Number(imageData.naturalHeight || 0);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return false;
}
return Math.abs(Number(cropData.x || 0)) < 0.5 &&
Math.abs(Number(cropData.y || 0)) < 0.5 &&
Math.abs(Number(cropData.width || 0) - width) < 0.5 &&
Math.abs(Number(cropData.height || 0) - height) < 0.5 &&
Math.abs(Number(cropData.rotate || 0)) < 0.5 &&
Number(cropData.scaleX || 1) === 1 &&
Number(cropData.scaleY || 1) === 1;
}
function getRasterizedFileName(sourceName) {
var name = String(sourceName || '').trim();
if (!name) {
return 'slide-region-image.png';
}
if (/\.svg$/i.test(name)) {
return name.replace(/\.svg$/i, '.png');
}
if (/\.[a-z0-9]+$/i.test(name)) {
return name.replace(/\.[a-z0-9]+$/i, '.png');
}
return name + '.png';
}
function setActiveRatioButton(value) {
var targetValue = ratioLabelForValue(value);
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"]').forEach(function (button) {
var buttonValue = ratioLabelForValue(button.getAttribute('data-slide-image-cropper-ratio'));
var isActive = buttonValue === targetValue;
button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
}
function applyAspectRatio(value) {
if (!cropper) {
return;
}
currentAspectRatio = value;
if (value === 'free') {
cropper.setAspectRatio(NaN);
setActiveRatioButton('free');
return;
}
if (value === 'region') {
if (!Number.isFinite(currentRegionAspectRatio) || currentRegionAspectRatio <= 0) {
cropper.setAspectRatio(NaN);
setActiveRatioButton('free');
return;
}
cropper.setAspectRatio(currentRegionAspectRatio);
setActiveRatioButton('region');
return;
}
var parts = String(value || '').split(':');
var width = Number(parts[0]);
var height = Number(parts[1]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return;
}
cropper.setAspectRatio(width / height);
setActiveRatioButton(value);
}
function initCropper() {
if (!currentInput || !currentFile || !window.Cropper) {
return;
}
destroyCropper();
cropper = new window.Cropper(image, {
aspectRatio: NaN,
autoCropArea: 1,
background: false,
dragMode: 'move',
initialAspectRatio: NaN,
movable: true,
responsive: true,
rotatable: true,
scalable: true,
viewMode: 1,
zoomOnTouch: true,
zoomOnWheel: true,
ready: function () {
setCropperLoading(false);
if (cropper && cropper.container) {
cropper.container.style.width = '100%';
cropper.container.style.height = '560px';
cropper.container.style.maxHeight = '70vh';
}
var containerData = cropper.getContainerData();
var imageData = cropper.getImageData();
var fitRatio = Math.min(
Number(containerData.width || 0) / Number(imageData.naturalWidth || 1),
Number(containerData.height || 0) / Number(imageData.naturalHeight || 1),
1
);
if (Number.isFinite(fitRatio) && fitRatio > 0) {
cropper.zoomTo(fitRatio);
} else {
cropper.reset();
}
applyAspectRatio(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
}
});
setButtonState(false);
setActiveRatioButton(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
if (isSpecialRasterizationFile(currentFile)) {
setStatus(getSpecialFileWarning(currentFile));
}
}
function openEditor(input, file) {
currentInput = input;
currentFile = file;
flipX = 1;
flipY = 1;
currentAspectRatio = 'free';
currentRegionAspectRatio = parseAspectRatio(input && input.dataset && input.dataset.slideImageCropperRegionRatio);
currentRegionAspectRatioLabel = String(input && input.dataset && input.dataset.slideImageCropperRegionRatioLabel || 'Region').trim() || 'Region';
setCropperLoading(true);
setButtonState(true);
setStatus(isSpecialRasterizationFile(file) ? getSpecialFileWarning(file) : '');
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"][data-slide-image-cropper-ratio="region"]').forEach(function (button) {
button.title = currentRegionAspectRatioLabel ? 'Region ratio ' + currentRegionAspectRatioLabel : 'Region ratio';
});
revokeObjectUrl();
currentObjectUrl = URL.createObjectURL(file);
image.alt = file.name || 'Selected image to crop';
image.src = currentObjectUrl;
if (!listenersAttached) {
listenersAttached = true;
modal.addEventListener('shown.bs.modal', function () {
if (currentInput && currentFile) {
initCropper();
}
});
modal.addEventListener('hidden.bs.modal', function () {
resetModalState();
});
}
showModal();
}
function setInputFile(input, file) {
var dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
}
function rejectOversizeFile(input) {
if (!input) {
return;
}
if (typeof window.showToast === 'function') {
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
} else {
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
}
input.value = '';
}
function finalizeCropFile(file) {
if (!currentInput) {
return;
}
var input = currentInput;
var existing = getExistingInput(getRegionId(input));
input.dataset.cropperBypass = '1';
setInputFile(input, file);
if (existing) {
existing.value = '';
}
input.dispatchEvent(new Event('change', { bubbles: true }));
window.setTimeout(function () {
delete input.dataset.cropperBypass;
}, 0);
hideModal();
}
function finalizeCurrentSvgSelection() {
if (!currentFile) {
return;
}
if (isOriginalSpecialSelection()) {
finalizeCropFile(currentFile);
return;
}
setButtonState(true);
setStatus('Creating a rasterized image from the crop...');
var canvas = cropper && typeof cropper.getCroppedCanvas === 'function' ? cropper.getCroppedCanvas({
fillColor: 'transparent',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
}) : null;
if (!canvas) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
canvas.toBlob(function (blob) {
if (!blob) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
var finalFile = new File([blob], getRasterizedFileName(currentFile.name), {
lastModified: Date.now(),
type: blob.type || 'image/png'
});
finalizeCropFile(finalFile);
}, 'image/png');
}
function commitCrop() {
if (!currentInput || !currentFile) {
return;
}
if (isSpecialRasterizationFile(currentFile)) {
if (!cropper) {
finalizeCropFile(currentFile);
return;
}
finalizeCurrentSvgSelection();
return;
}
if (!cropper) {
finalizeCropFile(currentFile);
return;
}
setButtonState(true);
setStatus('Creating the cropped image...');
var canvas = cropper.getCroppedCanvas({
fillColor: 'transparent',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
});
if (!canvas) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
var outputType = /^image\/(jpeg|jpg|webp|png)$/i.test(currentFile.type || '') ? currentFile.type : 'image/png';
canvas.toBlob(function (blob) {
if (!blob) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
var finalFile = new File([blob], currentFile.name || 'slide-region-image.png', {
lastModified: Date.now(),
type: blob.type || outputType
});
finalizeCropFile(finalFile);
}, outputType, outputType.indexOf('jpeg') !== -1 ? 0.92 : undefined);
}
function rotateImage(direction) {
if (cropper) {
cropper.rotate(direction);
}
}
function flipImage(axis) {
if (!cropper) {
return;
}
if (axis === 'x') {
flipX *= -1;
cropper.scaleX(flipX);
return;
}
flipY *= -1;
cropper.scaleY(flipY);
}
function resetImage() {
if (!cropper) {
return;
}
cropper.reset();
flipX = 1;
flipY = 1;
}
templateFields.addEventListener('change', function (event) {
document.addEventListener('change', function (event) {
var input = event.target;
if (!input || !input.matches || !input.matches('input[type="file"][name^="region_image_"]')) {
return;
}
if (!input.name || input.name.indexOf('existing_region_image_') !== 0 || input.dataset.applyingCrop === '1') return;
var copies = draftCopies.get(input) || [];
cleanupCopies(copies.filter(function (path) { return path !== input.value; }));
if (copies.indexOf(input.value) !== -1) draftCopies.set(input, [input.value]);
else draftCopies.delete(input);
});
if (input.dataset.cropperBypass === '1') {
return;
}
window.addEventListener('pagehide', function () {
var paths = [];
draftCopies.forEach(function (copies) { paths = paths.concat(copies); });
cleanupCopies(paths);
});
var file = input.files && input.files[0];
if (!file) {
return;
function discardInitialSelection() {
if (!active || !active.initialSelection || active.applied || active.current.value !== active.previousValue) return;
cleanupCopies([active.original]);
if (active.fileInput && active.fileInput.dataset.uploadedPath === active.original) {
active.fileInput.value = '';
delete active.fileInput.dataset.uploadedPath;
delete active.fileInput.dataset.uploadedNeedsCleanup;
}
active.originalInput.value = '';
active.cropInput.value = '';
active.current.value = '';
delete active.current.dataset.cropSelectionPending;
active.current.dispatchEvent(new Event('change', { bubbles: true }));
var preview = active.card.querySelector('.slide-image-region-preview-box');
if (preview) preview.innerHTML = '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
}
if (Number(file.size || 0) > uploadMaxBytes) {
event.stopImmediatePropagation();
event.stopPropagation();
rejectOversizeFile(input);
return;
}
function loadCropper() {
if (window.Cropper) return Promise.resolve();
if (!loader) loader = new Promise(function (resolve, reject) {
var css = document.createElement('link');
css.rel = 'stylesheet';
css.href = '/assets/vendor/cropperjs/cropper.min.css';
document.head.appendChild(css);
var script = document.createElement('script');
script.src = '/assets/vendor/cropperjs/cropper.min.js';
script.onload = resolve;
script.onerror = function () { loader = null; reject(new Error('Unable to load the image editor.')); };
document.head.appendChild(script);
});
return loader;
}
if (!/^image\//i.test(file.type || '') && !/\.svg$/i.test(String(file.name || ''))) {
input.value = '';
return;
}
function status(message) {
modal.querySelector('[data-crop-status]').textContent = message || '';
}
event.stopImmediatePropagation();
event.stopPropagation();
input.value = '';
function setBusy(value) {
busy = value;
modal.querySelectorAll('button').forEach(function (button) { button.disabled = value; });
}
if (!window.Cropper) {
currentInput = input;
currentFile = file;
commitCrop();
return;
}
function setLoading(value) {
var frame = modal.querySelector('.slide-image-cropper-frame');
frame.classList.toggle('is-loading', value);
frame.setAttribute('aria-busy', value ? 'true' : 'false');
frame.querySelector('.slide-image-cropper-loading-overlay').setAttribute('aria-hidden', value ? 'false' : 'true');
}
openEditor(input, file);
}, true);
function createModal() {
if (modal) return;
modal = document.createElement('div');
modal.id = 'slide-image-cropper-modal';
modal.className = 'modal fade';
modal.tabIndex = -1;
modal.setAttribute('aria-labelledby', 'slide-image-cropper-title');
modal.setAttribute('data-bs-backdrop', 'static');
modal.setAttribute('data-bs-keyboard', 'false');
var tools = [
{ label: 'Rotate', buttons: [['rotate-left', 'arrow-counterclockwise', 'Rotate left'], ['rotate-right', 'arrow-clockwise', 'Rotate right']] },
{ label: 'Flip', buttons: [['flip-x', 'symmetry-vertical', 'Flip horizontal'], ['flip-y', 'symmetry-horizontal', 'Flip vertical']] }
].map(function (group) {
return '<div class="btn-group btn-group-sm" role="group" aria-label="' + group.label + '">' + group.buttons.map(function (tool) {
return '<button type="button" class="btn btn-outline-secondary" data-crop-action="' + tool[0] + '"><i class="bi bi-' + tool[1] + '" aria-hidden="true"></i> ' + tool[2] + '</button>';
}).join('') + '</div>';
}).join('');
var ratios = ['Free', '1:1', '4:3', '16:9', 'Region'].map(function (ratio) {
return '<button type="button" class="btn btn-outline-secondary btn-sm" data-crop-ratio="' + ratio + '" aria-pressed="false">' + ratio + '</button>';
}).join('');
modal.innerHTML = '<div class="modal-dialog modal-dialog-centered modal-xl modal-dialog-scrollable"><div class="modal-content">' +
'<div class="modal-header"><h2 class="modal-title fs-5" id="slide-image-cropper-title">Edit image</h2><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>' +
'<div class="modal-body d-grid gap-3"><div class="slide-image-cropper-frame is-loading" aria-busy="true"><img data-crop-image alt="Original image to crop"><div class="slide-image-cropper-loading-overlay" aria-hidden="false"><div class="spinner-border text-primary slide-image-cropper-loading-spinner" role="status"><span class="visually-hidden">Loading image editor</span></div></div></div>' +
'<div class="d-flex flex-wrap gap-2" role="toolbar" aria-label="Image editing controls">' + tools + '<div class="btn-group btn-group-sm" role="group" aria-label="Crop ratio">' + ratios + '</div><button type="button" class="btn btn-outline-danger btn-sm" data-crop-action="reset"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Reset</button></div>' +
'<p class="small text-body-secondary mb-0" data-crop-status role="status" aria-live="polite"></p></div>' +
'<div class="modal-footer"><button type="button" class="btn btn-primary" data-crop-apply>Apply crop</button><button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button></div></div></div>';
document.body.appendChild(modal);
modal.addEventListener('shown.bs.modal', function () {
if (!active) return;
cropper = new window.Cropper(modal.querySelector('[data-crop-image]'), {
viewMode: 1, autoCropArea: 1, background: false, responsive: true, dragMode: 'move',
ready: function () {
if (active.crop) {
cropper.rotateTo(active.crop.rotate || 0);
cropper.scale(active.crop.scaleX || 1, active.crop.scaleY || 1);
cropper.setCropBoxData({ width: 1, height: 1 });
var container = cropper.getContainerData();
var canvas = cropper.getCanvasData();
cropper.zoomTo(Math.min(container.width / canvas.naturalWidth, container.height / canvas.naturalHeight));
cropper.setData(active.crop);
}
setLoading(false);
setBusy(false);
if (/\.(?:svg|gif)(?:[?#]|$)/i.test(active.original)) status('Applying a crop creates a PNG copy. The original stays unchanged.');
else status('');
}
});
});
modal.addEventListener('hide.bs.modal', function (event) {
if (busy) event.preventDefault();
else modal.dataset.cropClosing = '1';
});
modal.addEventListener('hidden.bs.modal', function () {
discardInitialSelection();
if (cropper) cropper.destroy();
cropper = null;
active = null;
setLoading(false);
modal.querySelector('[data-crop-image]').removeAttribute('src');
delete modal.dataset.cropClosing;
});
modal.querySelector('[data-crop-image]').addEventListener('error', function () {
setLoading(false);
setBusy(false);
modal.querySelector('[data-crop-apply]').disabled = true;
status('Unable to load the original image.');
});
modal.addEventListener('click', handleAction);
}
modal.addEventListener('click', function (event) {
var button = event.target && event.target.closest ? event.target.closest('[data-slide-image-cropper-action]') : null;
if (!button) {
return;
}
function setSelection(value, crop) {
active.applied = true;
delete active.current.dataset.cropSelectionPending;
var copies = draftCopies.get(active.current) || [];
cleanupCopies(copies.filter(function (path) { return path !== value; }));
if (copies.indexOf(value) === -1) draftCopies.delete(active.current);
active.originalInput.value = active.original;
active.cropInput.value = crop ? JSON.stringify(crop) : '';
active.current.dataset.applyingCrop = '1';
active.current.value = value;
active.current.dispatchEvent(new Event('change', { bubbles: true }));
delete active.current.dataset.applyingCrop;
var preview = active.card.querySelector('.slide-image-region-preview');
if (preview && preview.tagName === 'IMG') preview.src = value;
window.pulseModal.hide(modal);
}
var action = button.getAttribute('data-slide-image-cropper-action');
if (action === 'apply') {
commitCrop();
async function applyCrop() {
if (!active || !cropper || busy) return;
var selection = active;
var crop = cropper.getData();
if (window.pulseImageCrop.isFullImage(crop, cropper.getImageData())) {
setSelection(selection.original, null);
return;
}
if (action === 'ratio') {
applyAspectRatio(button.getAttribute('data-slide-image-cropper-ratio') || 'free');
return;
setBusy(true);
status('Saving cropped copy...');
try {
var size = window.pulseImageCrop.outputSize(crop, selection.bounds, selection.canvas);
var canvas = cropper.getCroppedCanvas({ width: size.width, height: size.height, maxWidth: size.width, maxHeight: size.height, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
if (!canvas || !canvas.width || !canvas.height) throw new Error('Unable to create the cropped image.');
var outputType = /\.jpe?g(?:[?#]|$)/i.test(selection.original) ? 'image/jpeg' : /\.webp(?:[?#]|$)/i.test(selection.original) ? 'image/webp' : 'image/png';
var extension = outputType === 'image/jpeg' ? '.jpg' : outputType === 'image/webp' ? '.webp' : '.png';
var blob = await new Promise(function (resolve) { canvas.toBlob(resolve, outputType, 0.92); });
if (!blob) throw new Error('Unable to create the cropped image.');
var form = new FormData();
form.append('file', blob, 'slide-image-crop' + extension);
var response = await fetch('/slides/uploads', { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: form });
var payload = await response.json();
if (!response.ok || !payload.path) throw new Error(payload.error || 'Unable to save the cropped copy.');
if (!selection.card.isConnected || selection.current.value !== selection.previousValue) {
cleanupCopies([payload.path]);
throw new Error('The selected image changed. Open the crop editor again.');
}
trackCopy(selection.current, payload.path);
setBusy(false);
setSelection(payload.path, crop);
} catch (error) {
setBusy(false);
status(error.message || 'Unable to save the cropped copy.');
}
if (action === 'rotate-left') {
rotateImage(-90);
return;
}
if (action === 'rotate-right') {
rotateImage(90);
return;
}
if (action === 'flip-horizontal') {
flipImage('x');
return;
}
if (action === 'flip-vertical') {
flipImage('y');
}
function handleAction(event) {
if (busy || !active) return;
if (event.target.closest('[data-crop-apply]')) { applyCrop(); return; }
if (!cropper) return;
var ratio = event.target.closest('[data-crop-ratio]');
if (ratio) {
var label = ratio.dataset.cropRatio;
var parts = String(label === 'Region' ? active.ratio : label).split(':');
var aspect = Number(parts[0]) / Number(parts[1]);
cropper.setAspectRatio(Number.isFinite(aspect) && aspect > 0 ? aspect : NaN);
modal.querySelectorAll('[data-crop-ratio]').forEach(function (button) {
button.classList.toggle('active', button === ratio);
button.setAttribute('aria-pressed', button === ratio ? 'true' : 'false');
});
return;
}
var button = event.target.closest('[data-crop-action]');
if (!button) return;
var action = button.dataset.cropAction;
if (action === 'rotate-left') cropper.rotate(-90);
if (action === 'rotate-right') cropper.rotate(90);
if (action === 'flip-x') cropper.scaleX(-(cropper.getData().scaleX || 1));
if (action === 'flip-y') cropper.scaleY(-(cropper.getData().scaleY || 1));
if (action === 'reset') {
resetImage();
cropper.reset();
modal.querySelector('[data-crop-ratio="Free"]').click();
}
}
document.addEventListener('click', async function (event) {
var button = event.target.closest && event.target.closest('[data-edit-image-crop]');
if (!button || busy) return;
if (modal && modal.dataset.cropClosing === '1') {
modal.addEventListener('hidden.bs.modal', function () { if (button.isConnected) button.click(); }, { once: true });
return;
}
var card = button.closest('[data-region-id]');
var regionId = button.dataset.editImageCrop;
var current = card.querySelector('[name="existing_region_image_' + regionId + '"]');
if (!current || !current.value) {
if (window.showToast) window.showToast('Choose an image first.', 'warning');
return;
}
button.disabled = true;
try {
await loadCropper();
createModal();
var originalInput = card.querySelector('[name="region_image_original_' + regionId + '"]');
var cropInput = card.querySelector('[name="region_image_crop_' + regionId + '"]');
var crop = null;
try { crop = JSON.parse(cropInput.value || 'null'); } catch (_error) {}
var previewRegion = Array.prototype.find.call(document.querySelectorAll('.slide-preview-region'), function (region) { return region.dataset.regionKey === button.dataset.regionKey; });
var previewCanvas = document.getElementById('slide-preview-canvas');
var bounds = { width: previewRegion ? parseFloat(previewRegion.style.width) : Number(button.dataset.regionWidth), height: previewRegion ? parseFloat(previewRegion.style.height) : Number(button.dataset.regionHeight) };
var canvas = { width: previewCanvas ? parseFloat(previewCanvas.style.width) : bounds.width, height: previewCanvas ? parseFloat(previewCanvas.style.height) : bounds.height };
active = { card: card, current: current, originalInput: originalInput, cropInput: cropInput, original: originalInput.value || current.value, crop: crop, bounds: bounds, canvas: canvas, ratio: bounds.width + ':' + bounds.height, previousValue: current.value, initialSelection: current.dataset.cropSelectionPending === '1', applied: false, fileInput: card.querySelector('input[type="file"][name="region_image_' + regionId + '"]') };
modal.querySelectorAll('[data-crop-ratio]').forEach(function (ratio) {
var free = ratio.dataset.cropRatio === 'Free';
ratio.classList.toggle('active', free);
ratio.setAttribute('aria-pressed', free ? 'true' : 'false');
});
setLoading(true);
modal.querySelector('[data-crop-image]').src = active.original;
status('Loading image...');
setBusy(true);
window.pulseModal.show(modal);
} catch (error) {
if (window.showToast) window.showToast(error.message, 'danger');
} finally {
button.disabled = false;
}
});
}());
+62 -5
View File
@@ -12,6 +12,7 @@ const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates');
const { retainLayoutAnimation, getTemplateAnimationSnapshot } = require('#src/web/public/js/shared/region-layout');
const { fetchMediaAssets, createImageVariants, registerMediaAssets, syncMediaAssetsFromDirectory, publishMediaAssets, removePendingMediaAssets } = require('#src/web/lib/media/library');
const { prepareImageCopies } = require('#src/web/lib/media/image-crops');
module.exports = function registerContentRoutes(app, deps) {
const pool = deps.pool;
@@ -76,6 +77,53 @@ module.exports = function registerContentRoutes(app, deps) {
}, {});
}
async function prepareContentImageCopies(database, template, content, actorId) {
const previous = JSON.stringify(content);
const files = await prepareImageCopies(content, template, deps.uploadDir);
await registerMediaAssets(database, files, file => '/media/uploads/' + file.filename, () => 'image', actorId, { published: false, preservePublication: true, preserveOriginalName: true });
return JSON.stringify(content) !== previous;
}
async function prepareSlideImagePayload(payload, actorId) {
if (!deps.uploadDir) return;
const content = common.parseJsonSafe(payload.contentJson) || {};
if (!Object.values(content).some(image => image && image.value && (image.type === 'image' || image.original_value))) return;
const template = await common.fetchTemplateById(pool, payload.templateId);
if (template && await prepareContentImageCopies(pool, template, content, actorId)) payload.contentJson = JSON.stringify(content);
}
async function refreshTemplateImageCopies(database, templateId, actorId) {
const references = { previous: new Set(), next: new Set() };
if (!deps.uploadDir) return references;
const template = await common.fetchTemplateById(database, templateId);
const defaults = JSON.parse(JSON.stringify(mapTemplateDefaultContent(template.regions)));
const previousDefaults = JSON.stringify(defaults);
await prepareContentImageCopies(database, template, defaults, actorId);
if (JSON.stringify(defaults) !== previousDefaults) {
for (const region of template.regions) {
const next = defaults[region.region_key];
if (JSON.stringify(next) === JSON.stringify(region.default_content || {})) continue;
await database.query('UPDATE c_template_regions SET default_content_json = ? WHERE id = ? AND template_id = ?', [JSON.stringify(next), region.id, templateId]);
region.default_content = next;
}
await replaceContentReferences(database, 'template', templateId, defaults);
for (const value of collectUploadReferencesFromPayload({ contentJson: previousDefaults })) references.previous.add(value);
for (const value of collectUploadReferencesFromPayload({ contentJson: JSON.stringify(defaults) })) references.next.add(value);
}
const [slides] = await database.query('SELECT id, content_json FROM c_slides WHERE template_id = ? FOR UPDATE', [templateId]);
for (const slide of slides) {
const content = typeof slide.content_json === 'string' ? common.parseJsonSafe(slide.content_json) || {} : slide.content_json || {};
const previous = JSON.stringify(content);
await prepareContentImageCopies(database, template, content, actorId);
if (JSON.stringify(content) === previous) continue;
await database.query('UPDATE c_slides SET content_json = ?, modified_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id = ? AND template_id = ?', [JSON.stringify(content), actorId, slide.id, templateId]);
await replaceContentReferences(database, 'slide', slide.id, content);
for (const value of collectUploadReferencesFromPayload({ contentJson: previous })) references.previous.add(value);
for (const value of collectUploadReferencesFromPayload({ contentJson: JSON.stringify(content) })) references.next.add(value);
}
return references;
}
async function fetchSlideFormData(includeTemplates) {
const data = includeTemplates === false ? {} : await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
@@ -580,6 +628,7 @@ module.exports = function registerContentRoutes(app, deps) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
const payload = await common.buildSlidePayload(pool, req, null);
await prepareSlideImagePayload(payload, getAuditUserId(req));
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, null, 'title')) {
return res.status(400).send('A slide with that title already exists.');
}
@@ -639,6 +688,7 @@ module.exports = function registerContentRoutes(app, deps) {
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
const existingUploadRefs = collectUploadReferencesFromSlide(slide);
const payload = await common.buildSlidePayload(pool, req, slide);
await prepareSlideImagePayload(payload, getAuditUserId(req));
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, slide.id, 'title')) {
return res.status(400).send('A slide with that title already exists.');
}
@@ -803,16 +853,17 @@ module.exports = function registerContentRoutes(app, deps) {
);
}
await replaceContentReferences(connection, 'template', result.insertId, mapTemplateDefaultContent(payload.regions));
const imageReferences = await refreshTemplateImageCopies(connection, result.insertId, actorId);
await connection.commit();
transaction = false;
connection.release();
connection = null;
await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
await publishMediaAssets(pool, Array.from(new Set([...collectUploadReferencesFromPayload(payload), ...imageReferences.next])));
await syncPlaylistUploadsOnChange({
key: 'template:create:' + result.insertId,
pool: pool,
localUploadDir: deps.uploadDir,
nextUploadRefs: collectUploadReferencesFromPayload(payload)
nextUploadRefs: new Set([...collectUploadReferencesFromPayload(payload), ...imageReferences.next])
});
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.created', actorUserId: actorId, targetType: 'template', targetId: result.insertId, targetLabel: payload.name });
redirectAfterSave(req, res, '/templates/' + result.insertId + '/edit', {
@@ -904,10 +955,15 @@ module.exports = function registerContentRoutes(app, deps) {
);
}
await connection.query('UPDATE c_templates SET modified_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id = ?', [actorId, templateId]);
const imageReferences = await refreshTemplateImageCopies(connection, templateId, actorId);
await connection.commit();
transaction = false;
connection.release();
connection = null;
if (imageReferences.next.size) {
await publishMediaAssets(pool, Array.from(imageReferences.next));
await syncPlaylistUploadsOnChange({ key: 'template:layout:' + templateId, pool: pool, localUploadDir: deps.uploadDir, previousUploadRefs: imageReferences.previous, nextUploadRefs: imageReferences.next });
}
const slideCount = await fetchTemplateSlideCount(templateId);
if (slideCount > 0) {
queueTemplateSlideThumbnailRefresh(templateId).catch(function (error) {
@@ -1079,17 +1135,18 @@ module.exports = function registerContentRoutes(app, deps) {
);
}
await replaceContentReferences(connection, 'template', template.id, mapTemplateDefaultContent(payload.regions));
const imageReferences = await refreshTemplateImageCopies(connection, template.id, actorId);
await connection.commit();
transaction = false;
connection.release();
connection = null;
await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
await publishMediaAssets(pool, Array.from(new Set([...existingUploadRefs, ...nextUploadRefs, ...imageReferences.next])));
await syncPlaylistUploadsOnChange({
key: 'template:update:' + template.id,
pool: pool,
localUploadDir: deps.uploadDir,
previousUploadRefs: existingUploadRefs,
nextUploadRefs: nextUploadRefs
previousUploadRefs: new Set([...existingUploadRefs, ...imageReferences.previous]),
nextUploadRefs: new Set([...nextUploadRefs, ...imageReferences.next])
});
const templateSlides = await fetchSlidesByTemplateId(template.id);
if (templateSlides.length > 0) {
+8 -3
View File
@@ -182,9 +182,13 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
});
app.post('/media-library', requireAccess, deps.requirePermission('media-library.create'), deps.upload.single('file'), async function (req, res, next) {
const wantsJson = String(req.headers && req.headers.accept || '').includes('application/json');
function rejectUpload(message) {
return wantsJson ? res.status(400).json({ error: message }) : res.redirect('/media-library?message=' + encodeURIComponent(message));
}
try {
if (!req.file) {
return res.redirect('/media-library?message=' + encodeURIComponent('Choose an image or video to upload.'));
return rejectUpload('Choose an image or video to upload.');
}
const mediaType = getMediaType(req.file);
const settings = await fetchAppSettings(deps.pool);
@@ -194,14 +198,14 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
const maxBytes = mediaType === 'video' ? uploadLimits.videoMaxBytes : uploadLimits.imageMaxBytes;
if (allowedMimeTypes.indexOf(mimeType) === -1 || (mediaType !== 'image' && mediaType !== 'video')) {
await fs.promises.unlink(req.file.path).catch(function () {});
return res.redirect('/media-library?message=' + encodeURIComponent('This media type is disabled in Media Uploads settings.'));
return rejectUpload('This media type is disabled in Media Uploads settings.');
}
if (Number(req.file.size || 0) > maxBytes) {
await fs.promises.unlink(req.file.path).catch(function () {});
const limitLabel = maxBytes >= 1024 * 1024 * 1024 && maxBytes % (1024 * 1024 * 1024) === 0
? (maxBytes / (1024 * 1024 * 1024)) + ' GB'
: Math.round(maxBytes / (1024 * 1024)) + ' MB';
return res.redirect('/media-library?message=' + encodeURIComponent('File must be ' + limitLabel + ' or smaller.'));
return rejectUpload('File must be ' + limitLabel + ' or smaller.');
}
await mediaLibrary.createImageVariants(req.file);
const mediaPath = '/media/uploads/' + req.file.filename;
@@ -217,6 +221,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
details: { mediaPath: mediaPath, mediaType: mediaType, mimeType: mimeType, fileSize: Number(req.file.size) || 0 }
});
}
if (wantsJson) return res.json({ success: true, path: mediaPath });
res.redirect('/media-library?message=' + encodeURIComponent('Media uploaded.'));
} catch (error) {
if (req.file && req.file.path) {
+6 -46
View File
@@ -5,8 +5,6 @@
</div>
</div>
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
<section class="media-gallery" data-media-gallery-offset="{{assets.length}}" data-media-gallery-has-more="{{#if hasMore}}true{{else}}false{{/if}}">
<div class="media-gallery-toolbar">
{{#if (hasPermission currentUser 'media-library.create')}}
@@ -126,55 +124,17 @@
<span class="slide-image-region-upload-zone-limit">Images max {{uploadLimits.imageMaxLabel}}; videos max {{uploadLimits.videoMaxLabel}}</span>
</span>
</span>
<span class="slide-image-region-upload-zone-progress" data-media-library-upload-progress hidden>
<span class="slide-image-region-upload-zone-progress-label" data-media-library-upload-progress-text role="status" aria-live="polite">Uploading...</span>
<span class="progress rounded-pill slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Media upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-media-library-upload-progress-bar>
<span class="progress-bar bg-primary rounded-pill progress-bar-striped progress-bar-animated" style="width:0%" data-media-library-upload-progress-fill>0%</span>
</span>
</span>
</label>
</div>
</form>
{{/modal-shell}}
{{/if}}
{{#> modal-shell modalId="media-library-crop-modal" modalLabelId="media-library-crop-modal-label" modalDialogClass="modal-dialog-centered modal-xl" modalBackdropStatic=true modalKeyboardDisabled=true}}
<div class="modal-header">
<div>
<h2 class="modal-title fs-5" id="media-library-crop-modal-label">Edit image</h2>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-grid gap-3">
<div class="slide-image-cropper-frame">
<img id="media-library-crop-image" alt="Selected image to crop" />
<div class="slide-image-cropper-loading-overlay" aria-hidden="true">
<div class="spinner-border text-primary slide-image-cropper-loading-spinner" role="status">
<span class="visually-hidden">Loading image editor</span>
</div>
</div>
</div>
<div class="btn-toolbar flex-wrap gap-2 slide-image-cropper-toolbar" role="toolbar" aria-label="Image editing controls">
<div class="btn-group btn-group-sm" role="group" aria-label="Rotate">
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="rotate-left"><i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i> Rotate left</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="rotate-right"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i> Rotate right</button>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Flip">
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="flip-horizontal"><i class="bi bi-symmetry-vertical" aria-hidden="true"></i> Flip horizontal</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="flip-vertical"><i class="bi bi-symmetry-horizontal" aria-hidden="true"></i> Flip vertical</button>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Crop ratio presets">
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="ratio" data-media-library-crop-ratio="free">Free</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="ratio" data-media-library-crop-ratio="1:1">1:1</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="ratio" data-media-library-crop-ratio="4:3">4:3</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="ratio" data-media-library-crop-ratio="16:9">16:9</button>
</div>
<button type="button" class="btn btn-outline-danger btn-sm" data-media-library-crop-action="reset"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Reset</button>
</div>
<p class="small text-body-secondary mb-0" id="media-library-crop-status"></p>
</div>
<div class="modal-footer justify-content-end">
<div class="btn-group" role="group" aria-label="Crop actions">
<button type="button" class="btn btn-primary" data-media-library-crop-apply>Apply crop</button>
<button type="button" class="btn btn-warning" data-media-library-crop-cancel>Cancel</button>
</div>
</div>
{{/modal-shell}}
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
<script src="/assets/js/shared/upload-utils.js?v={{appVersion}}"></script>
<script type="module" src="/assets/js/settings/media-library.js?v={{appVersion}}"></script>
-47
View File
@@ -6,7 +6,6 @@
</div>
<div class="slide-editor-shell">
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
<form method="post" action="{{action}}" enctype="multipart/form-data" id="slide-form" class="slide-form-stack" {{{formAttrs}}}>
<input type="hidden" id="slide-layouts-json" name="region_layouts_json" value="{}" />
<div class="card card-outline card-primary admin-form-card slide-template-card">
@@ -181,50 +180,6 @@
{{> signage/templates/animation-advanced-modal}}
{{#> modal-shell modalId="slide-image-cropper-modal" modalLabelId="slide-image-cropper-modal-label" modalDialogClass="modal-dialog-centered modal-xl modal-dialog-scrollable" modalBackdropStatic=true modalKeyboardDisabled=true}}
<div class="modal-header">
<div>
<h2 class="modal-title fs-5" id="slide-image-cropper-modal-label">Edit image</h2>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-grid gap-3">
<div class="slide-image-cropper-frame">
<img id="slide-image-cropper-image" alt="Selected image to crop" />
<div class="slide-image-cropper-loading-overlay" aria-hidden="true">
<div class="spinner-border text-primary slide-image-cropper-loading-spinner" role="status">
<span class="visually-hidden">Loading image editor</span>
</div>
</div>
</div>
<div class="btn-toolbar flex-wrap gap-2 slide-image-cropper-toolbar" role="toolbar" aria-label="Image editing controls">
<div class="btn-group btn-group-sm" role="group" aria-label="Rotate">
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="rotate-left"><i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i> Rotate left</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="rotate-right"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i> Rotate right</button>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Flip">
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="flip-horizontal"><i class="bi bi-symmetry-vertical" aria-hidden="true"></i> Flip horizontal</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="flip-vertical"><i class="bi bi-symmetry-horizontal" aria-hidden="true"></i> Flip vertical</button>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Crop ratio presets">
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="free">Free</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="1:1">1:1</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="4:3">4:3</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="16:9">16:9</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="region">Region</button>
</div>
<button type="button" class="btn btn-outline-danger btn-sm" data-slide-image-cropper-action="reset"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Reset</button>
</div>
<p class="small text-body-secondary mb-0" id="slide-image-cropper-status"></p>
</div>
<div class="modal-footer justify-content-end">
<div class="btn-group" role="group" aria-label="Crop actions">
<button type="button" class="btn btn-primary" data-slide-image-cropper-action="apply">Apply crop</button>
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
{{/modal-shell}}
{{> media-picker-modal mediaAssets=slideEditorData.mediaAssets}}
{{#if isEdit}}
@@ -239,13 +194,11 @@
<script src="/assets/js/shared/region-animation-controls.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/qr-code-svg.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/upload-utils.js?v={{appVersion}}"></script>
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
<script src="/assets/vendor/tinymce/tinymce.min.js"></script>
<script src="/assets/vendor/qr-code-styling/qr-code-styling-loader.js?v={{appVersion}}"></script>
{{#each slideEditorScripts}}
<script type="module" src="/assets/{{this}}?v={{../appVersion}}"></script>
{{/each}}
<script type="module" src="/assets/js/slides/slide-form.js?v={{assetVersion}}"></script>
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v={{assetVersion}}"></script>
<script type="module" src="/assets/js/shared/ui/media-picker.js?v={{appVersion}}"></script>
+627 -1
View File
@@ -9,6 +9,10 @@ const sharp = require('sharp');
require('../src/common');
const mediaLibrary = require('../src/web/lib/media/library');
const registerMediaLibraryRoutes = require('../src/web/routes/settings/media-library');
const { buildSlidePayload } = require('../src/data/slides');
const { outputSize, isFullImage } = require('../src/web/public/js/shared/image-crop');
const { prepareImageCopies } = require('../src/web/lib/media/image-crops');
const { removeUnusedImageCopies, removePendingMediaAssets } = require('../src/web/lib/media/library');
const {
normalizeMediaPath,
createImageVariants,
@@ -25,6 +29,530 @@ test('media library normalizes only media URLs', () => {
assert.equal(normalizeMediaPath('/assets/photo.png'), null);
});
test('unused generated copies and their variants are deleted without deleting originals or referenced copies', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-copy-cleanup-'));
const generated = 'image-crop-' + 'a'.repeat(64) + '.png';
const referenced = 'image-playback-' + 'b'.repeat(64) + '.png';
const files = [generated, generated.replace('.png', '.variant-2048.png'), referenced, 'original.png', 'original-crop-name.png', 'draft.png'];
for (const filename of files) await fs.promises.writeFile(path.join(uploadDir, filename), Buffer.from('fixture'));
const deleted = [];
const pool = { async query(sql, params) {
if (sql.startsWith('SELECT id, original_name')) return [[{ id: 1, is_published: params[0].endsWith('/draft.png') ? 0 : 1, original_name: /\/(?:draft|original-crop-name)\.png$/.test(params[0]) ? 'slide-image-crop.png' : path.basename(params[0]) }]];
if (sql.includes('COUNT(DISTINCT owner_type, owner_id)')) return [[{ owner_count: params.includes('/media/uploads/' + referenced) ? 1 : 0 }]];
if (sql.startsWith('DELETE')) deleted.push(params[0]);
return [[{ ref_count: 0 }]];
} };
try {
await removeUnusedImageCopies(pool, uploadDir, files.map(filename => '/media/uploads/' + filename));
assert.deepEqual((await fs.promises.readdir(uploadDir)).sort(), ['original.png', 'original-crop-name.png', referenced].sort());
assert.ok(deleted.includes('/media/uploads/' + generated));
assert.ok(deleted.includes('/media/uploads/draft.png'));
assert.ok(!deleted.includes('/media/uploads/original.png'));
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('pending crop cleanup removes physical copies and variants but protects published and referenced files', async () => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-pending-crop-'));
const uploadDir = path.join(directory, 'uploads');
await fs.promises.mkdir(uploadDir);
const files = ['draft.png', 'draft.variant-2048.png', 'referenced.png', 'published.png', 'original.png'];
for (const filename of files) await fs.promises.writeFile(path.join(uploadDir, filename), Buffer.from('fixture'));
const pool = { async query(sql, params) {
if (sql.startsWith('SELECT id FROM c_media_assets')) return [params[0].endsWith('/published.png') ? [] : [{ id: 1 }]];
if (sql.includes('COUNT(DISTINCT owner_type, owner_id)')) return [[{ owner_count: params.includes('/media/uploads/referenced.png') ? 1 : 0 }]];
return [[{ ref_count: 0 }]];
} };
try {
await removePendingMediaAssets(pool, uploadDir, ['/media/uploads/draft.png', '/media/uploads/referenced.png', '/media/uploads/published.png']);
assert.deepEqual((await fs.promises.readdir(uploadDir)).sort(), ['original.png', 'published.png', 'referenced.png']);
} finally {
await fs.promises.rm(directory, { recursive: true, force: true });
}
});
test('crop playback sizes fit the region and canvas without upscaling or changing aspect ratio', () => {
assert.deepEqual(outputSize({ width: 6000, height: 4000 }, { width: 600, height: 300 }, { width: 1920, height: 1080 }), { width: 450, height: 300 });
assert.deepEqual(outputSize({ width: 6000, height: 4000 }, { width: 5000, height: 3000 }, { width: 1920, height: 1080 }), { width: 1620, height: 1080 });
assert.deepEqual(outputSize({ width: 120, height: 80 }, { width: 600, height: 300 }, { width: 1920, height: 1080 }), { width: 120, height: 80 });
assert.deepEqual(outputSize({ width: 4000, height: 6000 }, { width: 1080, height: 1920 }, { width: 1080, height: 1920 }), { width: 1080, height: 1620 });
});
test('untouched full-image selections are not treated as crops', () => {
const image = { naturalWidth: 600, naturalHeight: 400 };
const full = { x: 0, y: 0, width: 600, height: 400, rotate: 0, scaleX: 1, scaleY: 1 };
assert.equal(isFullImage(full, image), true);
assert.equal(isFullImage({ ...full, x: 0.000001 }, image), true);
for (const change of [{ width: 599 }, { x: 1 }, { rotate: 90 }, { scaleX: -1 }, { scaleY: -1 }]) assert.equal(isFullImage({ ...full, ...change }, image), false);
});
test('cancelling an initial crop clears the selection and cleans its upload, but cancelling a re-crop keeps it', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const start = source.indexOf('function discardInitialSelection()');
const end = source.indexOf('\n function loadCropper()', start);
const cleaned = [];
const current = { value: '/media/uploads/new.png', dataset: { cropSelectionPending: '1' }, dispatchEvent() {} };
const preview = { innerHTML: '' };
const active = { initialSelection: true, applied: false, previousValue: current.value, current, original: current.value, originalInput: { value: current.value }, cropInput: { value: '' }, fileInput: { value: '', dataset: { uploadedPath: current.value, uploadedNeedsCleanup: '1' } }, card: { querySelector() { return preview; } } };
const discard = vm.runInNewContext('(' + source.slice(start, end).trim() + ')', { active, Event, cleanupCopies(paths) { cleaned.push(...paths); } });
active.initialSelection = false;
discard();
assert.equal(current.value, '/media/uploads/new.png');
assert.equal(cleaned.length, 0);
active.initialSelection = true;
active.applied = true;
discard();
assert.equal(cleaned.length, 0);
active.applied = false;
discard();
assert.deepEqual(cleaned, ['/media/uploads/new.png']);
assert.equal(current.value, '');
assert.equal(active.originalInput.value, '');
assert.equal(active.fileInput.dataset.uploadedNeedsCleanup, undefined);
assert.match(preview.innerHTML, /No image/);
});
test('playback crops regenerate from the original for larger layouts and preserve source bytes', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-crop-size-'));
const original = await sharp({ create: { width: 1200, height: 800, channels: 3, background: '#fa4030' } }).png().toBuffer();
await fs.promises.writeFile(path.join(uploadDir, 'original.png'), original);
const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', x: 0, y: 0, width: 300, height: 200 }] };
const content = { photo: { type: 'image', value: '/media/uploads/old.png', original_value: '/media/uploads/original.png', crop: { x: 0, y: 0, width: 1200, height: 800, rotate: 0, scaleX: 1, scaleY: 1 } } };
try {
let files = await prepareImageCopies(content, template, uploadDir);
let metadata = await sharp(files[0].path).metadata();
assert.equal(metadata.width, 300);
assert.equal(metadata.height, 200);
const firstPath = content.photo.value;
await prepareImageCopies(content, template, uploadDir);
assert.equal(content.photo.value, firstPath);
await fs.promises.unlink(files[0].path);
content.photo.layout = { x: 0, y: 0, width: 900, height: 600 };
files = await prepareImageCopies(content, template, uploadDir);
metadata = await sharp(files[0].path).metadata();
assert.equal(metadata.width, 900);
assert.equal(metadata.height, 600);
assert.notEqual(content.photo.value, firstPath);
content.photo.layout = { x: 0, y: 0, width: 1920, height: 1080 };
files = await prepareImageCopies(content, template, uploadDir);
metadata = await sharp(files[0].path).metadata();
assert.equal(metadata.width, 1200);
assert.equal(metadata.height, 800);
assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, 'original.png')), original);
content.photo.original_value = '/media/uploads/../original.png';
await assert.rejects(prepareImageCopies(content, template, uploadDir), /local upload/);
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('uncropped 8K images get bounded full-frame playback copies and regenerate from the original', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-full-image-size-'));
try {
const original = await sharp({ create: { width: 7680, height: 4320, channels: 3, background: '#236944' } }).jpeg().toBuffer();
await fs.promises.writeFile(path.join(uploadDir, 'original.jpg'), original);
const image = { type: 'image', value: '/media/uploads/original.jpg', crop: null };
const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', width: 960, height: 540 }] };
let files = await prepareImageCopies({ photo: image }, template, uploadDir);
let metadata = await sharp(files[0].path).metadata();
assert.equal(metadata.width, 960);
assert.equal(metadata.height, 540);
assert.equal(image.original_value, '/media/uploads/original.jpg');
assert.equal(image.crop, null);
const firstPath = image.value;
await prepareImageCopies({ photo: image }, template, uploadDir);
assert.equal(image.value, firstPath);
await fs.promises.unlink(files[0].path);
image.layout = { x: 0, y: 0, width: 1920, height: 1080 };
files = await prepareImageCopies({ photo: image }, template, uploadDir);
metadata = await sharp(files[0].path).metadata();
assert.equal(metadata.width, 1920);
assert.equal(metadata.height, 1080);
assert.notEqual(image.value, firstPath);
assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, 'original.jpg')), original);
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('pre-crop-change images remain usable across edit, save, preview and player rendering', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-legacy-image-'));
const region = { id: 22, region_key: 'photo', region_type: 'image', width: 800, height: 600, label: 'Legacy image', baseStyle: '' };
const template = { id: 7, canvas_size_width: 1920, canvas_size_height: 1080, regions: [region] };
const pool = { async query(sql) {
if (sql.includes('FROM c_templates st')) return [[template]];
if (sql.includes('FROM c_template_regions')) return [[region]];
return [[]];
} };
const modules = {};
const browserWindow = {
pulseRegionTypes: { register(type, definition) { modules.editor = definition; }, renderEditorCardShell(context) { return context.headerActions + context.bodyHtml; } },
pulsePlayerRegionTypes: { register(type, definition) { modules.player = definition; } }
};
const context = vm.createContext({ window: browserWindow, escapeHtml: String, selectPlayerMediaSource: value => value });
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/image.js'), 'utf8'), context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/player/regions/image.js'), 'utf8'), context);
try {
for (const [filename, width, height] of [['old-cropped.png', 600, 400], ['old-large.png', 2400, 800]]) {
const original = await sharp({ create: { width, height, channels: 3, background: '#237744' } }).png().toBuffer();
await fs.promises.writeFile(path.join(uploadDir, filename), original);
const legacy = { type: 'image', value: '/media/uploads/' + filename };
const editorContext = modules.editor.buildEditorCardContext({ region, current: legacy.value }, { photo: legacy });
const card = modules.editor.renderEditorCard(editorContext);
assert.ok(card.includes('name="region_image_original_22" value="' + legacy.value + '"'));
const preview = modules.editor.renderPreview(region, modules.editor.buildPreviewRenderContext(region, null, { photo: legacy }));
assert.ok(preview.includes(legacy.value));
assert.ok(modules.player.renderRegion(region, legacy).includes(legacy.value));
const payload = await buildSlidePayload(pool, { body: { title: 'Legacy saved again', template_id: '7' }, files: [] }, { template_id: 7, content_json: JSON.stringify({ photo: legacy }) });
const saved = JSON.parse(payload.contentJson);
assert.equal(saved.photo.value, legacy.value);
assert.equal(saved.photo.original_value, legacy.value);
assert.equal(saved.photo.crop, null);
const copies = await prepareImageCopies(saved, template, uploadDir);
if (width === 600) {
assert.equal(copies.length, 0);
assert.equal(saved.photo.value, legacy.value);
} else {
assert.equal(copies.length, 1);
const metadata = await sharp(copies[0].path).metadata();
assert.ok(metadata.width <= region.width);
assert.ok(metadata.height <= region.height);
assert.equal(metadata.width / metadata.height, width / height);
assert.equal(saved.photo.original_value, legacy.value);
}
assert.ok(modules.player.renderRegion(region, saved.photo).includes(saved.photo.value));
assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, filename)), original);
assert.equal(legacy.original_value, undefined);
assert.equal(legacy.crop, undefined);
}
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('uncropped playback preserves animation and never enlarges small images', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-full-image-animation-'));
try {
const red = await sharp({ create: { width: 400, height: 200, channels: 4, background: '#ff0000' } }).raw().toBuffer();
const green = await sharp({ create: { width: 400, height: 200, channels: 4, background: '#00ff00' } }).raw().toBuffer();
const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', width: 100, height: 100 }] };
for (const format of ['gif', 'webp']) {
const original = await sharp(Buffer.concat([red, green]), { raw: { width: 400, height: 400, channels: 4, pageHeight: 200 } })[format]({ delay: [120, 240], loop: 0 }).toBuffer();
const filename = 'original.' + format;
await fs.promises.writeFile(path.join(uploadDir, filename), original);
const image = { value: '/media/uploads/' + filename, crop: null };
const files = await prepareImageCopies({ photo: image }, template, uploadDir);
const metadata = await sharp(await fs.promises.readFile(files[0].path), { animated: true }).metadata();
assert.equal(metadata.width, 100);
assert.equal(metadata.pageHeight, 50);
assert.equal(metadata.pages, 2);
assert.deepEqual(metadata.delay, [120, 240]);
assert.equal(metadata.loop, 0);
assert.equal(image.crop, null);
assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, filename)), original);
}
await sharp({ create: { width: 40, height: 20, channels: 4, background: '#00ff0080' } }).png().toFile(path.join(uploadDir, 'small.png'));
const before = (await fs.promises.readdir(uploadDir)).sort();
const small = { value: '/media/uploads/previous-copy.png', original_value: '/media/uploads/small.png', crop: null };
const files = await prepareImageCopies({ photo: small }, template, uploadDir);
assert.equal(files.length, 0);
assert.equal(small.value, '/media/uploads/small.png');
assert.equal(small.crop, null);
assert.deepEqual((await fs.promises.readdir(uploadDir)).sort(), before);
const metadata = await sharp(path.join(uploadDir, 'small.png')).metadata();
assert.equal(metadata.width, 40);
assert.equal(metadata.height, 20);
assert.equal(metadata.hasAlpha, true);
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('regenerated crop pixels preserve cropper rotation and flips', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-crop-transform-'));
const red = [255, 0, 0, 255];
const green = [0, 255, 0, 255];
const pixels = Buffer.from([...red, ...red, ...green, ...green, ...red, ...red, ...green, ...green]);
try {
await sharp(pixels, { raw: { width: 4, height: 2, channels: 4 } }).png().toFile(path.join(uploadDir, 'original.png'));
const content = { photo: { value: '/media/uploads/old.png', original_value: '/media/uploads/original.png', crop: { x: 0, y: 0, width: 2, height: 4, rotate: 90, scaleX: -1, scaleY: 1 } } };
const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', width: 2, height: 4 }] };
const files = await prepareImageCopies(content, template, uploadDir);
const output = await sharp(files[0].path).raw().toBuffer();
assert.deepEqual([...output.subarray(0, 4)], green);
assert.deepEqual([...output.subarray(output.length - 4)], red);
content.photo.crop = { x: -2, y: 0, width: 6, height: 2 };
template.regions[0].width = 6;
template.regions[0].height = 2;
const padded = await prepareImageCopies(content, template, uploadDir);
const padding = await sharp(padded[0].path).raw().toBuffer();
assert.equal(padding[3], 0);
assert.deepEqual([...padding.subarray(8, 12)], red);
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('editor offers the cropper and player uses the crop copy while retaining original metadata', () => {
const modules = {};
const browserWindow = {
pulseRegionTypes: { register(type, definition) { modules.editor = definition; }, renderEditorCardShell(context) { return '<div class="card-header">' + context.headerActions + '</div><div class="card-body">' + context.bodyHtml + '</div>'; } },
pulsePlayerRegionTypes: { register(type, definition) { modules.player = definition; } }
};
const context = vm.createContext({ window: browserWindow, escapeHtml: String, selectPlayerMediaSource: value => value });
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/image.js'), 'utf8'), context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/player/regions/image.js'), 'utf8'), context);
const photo = { value: '/media/uploads/crop.png', original_value: '/media/uploads/original.svg', crop: { x: 20, y: 30, width: 400, height: 200, rotate: 90, scaleX: -1, scaleY: 1 } };
const region = { id: 22, region_key: 'photo', baseStyle: '', label: 'Photo' };
const editorContext = modules.editor.buildEditorCardContext({ region, current: photo.value }, { photo });
assert.deepEqual(editorContext.imageContent, photo);
const card = modules.editor.renderEditorCard(editorContext);
assert.match(card, /data-edit-image-crop="22"/);
const header = card.slice(0, card.indexOf('class="card-body"'));
assert.doesNotMatch(header, /data-edit-image-crop/);
assert.match(card, /class="slide-image-region-preview-tools"><button type="button" class="slide-image-region-crop"/);
assert.ok(card.indexOf('data-edit-image-crop') < card.indexOf('slide-image-region-preview-box'));
assert.match(card, /title="Edit crop" aria-label="Edit crop"><i class="bi bi-crop" aria-hidden="true"><\/i><\/button>/);
assert.match(card, /name="region_image_original_22"[^>]*original\.svg/);
assert.doesNotMatch(card, /type="range"|region_image_fit/);
const previewContext = modules.editor.buildPreviewRenderContext(region, null, { photo });
const preview = modules.editor.renderPreview(region, previewContext);
const player = modules.player.renderRegion(region, photo);
for (const html of [preview, player]) {
assert.match(html, /crop\.png/);
assert.doesNotMatch(html, /original\.svg/);
}
assert.equal(previewContext.original_value, photo.original_value);
assert.deepEqual(previewContext.crop, photo.crop);
});
test('image uploads open the cropper and library selections wait for the picker to close', () => {
const handlers = {};
const timers = [];
let opens = 0;
let pickerVisible = false;
let afterPickerHidden;
const original = { value: '' };
const crop = { value: 'old crop' };
const button = { disabled: false, click() { opens += 1; } };
const card = { isConnected: true, querySelector(selector) { return selector.includes('original') ? original : selector.includes('crop_') ? crop : button; } };
const input = { name: 'existing_region_image_22', value: '/media/uploads/photo.jpg', dataset: {}, closest() { return card; } };
const picker = { classList: { contains() { return pickerVisible; } }, addEventListener(name, handler, options) { assert.equal(name, 'hidden.bs.modal'); assert.equal(options.once, true); afterPickerHidden = handler; } };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/image.js'), 'utf8'), {
window: { pulseRegionTypes: { register() {} }, setTimeout(handler) { timers.push(handler); } },
document: { addEventListener(name, handler) { handlers[name] = handler; }, getElementById() { return picker; } }
});
handlers.change({ target: input });
assert.equal(original.value, input.value);
assert.equal(crop.value, '');
assert.equal(opens, 0);
timers.shift()();
assert.equal(opens, 1);
pickerVisible = true;
input.value = '/media/uploads/library.jpg';
handlers.change({ target: input });
assert.equal(opens, 1);
assert.equal(timers.length, 0);
pickerVisible = false;
afterPickerHidden();
assert.equal(opens, 2);
input.dataset.applyingCrop = '1';
input.value = '/media/uploads/copy.png';
handlers.change({ target: input });
assert.equal(original.value, '/media/uploads/library.jpg');
assert.equal(timers.length, 0);
delete input.dataset.applyingCrop;
input.value = '';
handlers.change({ target: input });
assert.equal(timers.length, 0);
input.value = '/media/uploads/pending.jpg';
handlers.change({ target: input });
input.value = '';
timers.shift()();
assert.equal(opens, 2);
});
test('opening another image waits until the previous cropper has finished closing', async () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const marker = "document.addEventListener('click', ";
const start = source.lastIndexOf(marker) + marker.length;
const end = source.lastIndexOf('\n });');
let afterHidden;
let clicks = 0;
const button = { isConnected: true, click() { clicks += 1; } };
const modal = { dataset: { cropClosing: '1' }, addEventListener(name, callback, options) { assert.equal(name, 'hidden.bs.modal'); assert.equal(options.once, true); afterHidden = callback; } };
const open = vm.runInNewContext('(' + source.slice(start, end + 4) + ')', { modal, busy: false });
await open({ target: { closest() { return button; } } });
assert.equal(clicks, 0);
delete modal.dataset.cropClosing;
afterHidden();
assert.equal(clicks, 1);
});
test('cropped draft copies are cleaned on replacement, removal and editor cancellation without deleting originals', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const handlers = {};
const requests = [];
const context = vm.createContext({
Map, Set,
document: { addEventListener(name, handler) { handlers[name] = handler; } },
window: { addEventListener(name, handler) { handlers[name] = handler; } },
fetch(url, options) { requests.push({ url, paths: JSON.parse(options.body).uploadPaths, keepalive: options.keepalive }); return Promise.resolve({ ok: true }); }
});
vm.runInContext(source.slice(source.indexOf('var draftCopies'), source.indexOf('function loadCropper()')), context);
const input = { name: 'existing_region_image_22', value: '/media/uploads/crop-1.png', dataset: {} };
context.input = input;
vm.runInContext("trackCopy(input, '/media/uploads/crop-1.png')", context);
assert.equal(requests.length, 0);
input.value = '/media/uploads/crop-2.png';
vm.runInContext("trackCopy(input, '/media/uploads/crop-2.png')", context);
assert.deepEqual(requests.pop().paths, ['/media/uploads/crop-1.png']);
input.dataset.applyingCrop = '1';
handlers.change({ target: input });
assert.equal(requests.length, 0);
delete input.dataset.applyingCrop;
input.value = '';
handlers.change({ target: input });
assert.deepEqual(requests.pop().paths, ['/media/uploads/crop-2.png']);
input.value = '/media/uploads/original.png';
handlers.change({ target: input });
assert.equal(requests.length, 0);
vm.runInContext("trackCopy(input, '/media/uploads/abandoned.png')", context);
assert.equal(handlers.beforeunload, undefined);
handlers.pagehide();
const cancelled = requests.pop();
assert.equal(cancelled.url, '/slides/uploads/cleanup');
assert.equal(cancelled.keepalive, true);
assert.deepEqual(cancelled.paths, ['/media/uploads/abandoned.png']);
});
test('cropper footer contains apply and cancel without a separate original-image action', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
assert.doesNotMatch(source, /data-crop-original|Use original/);
assert.match(source, /data-crop-apply>Apply crop/);
assert.match(source, /data-bs-dismiss="modal">Cancel/);
assert.match(source, /data-crop-action="reset"/);
});
test('cropper toolbar retains labeled rotate and flip groups, ratio presets and a separate danger reset', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const start = source.indexOf('function createModal()');
const end = source.indexOf('document.body.appendChild(modal);', start);
const context = vm.createContext({ modal: null, document: { createElement() { return { setAttribute() {} }; } } });
const html = vm.runInContext(source.slice(start, end) + '\nreturn modal.innerHTML; }\ncreateModal();', context);
assert.match(html, /aria-label="Rotate"/);
assert.match(html, /aria-label="Flip"/);
for (const label of ['Rotate left', 'Rotate right', 'Flip horizontal', 'Flip vertical']) assert.ok(html.includes('</i> ' + label + '</button>'));
for (const ratio of ['Free', '1:1', '4:3', '16:9', 'Region']) assert.ok(html.includes('data-crop-ratio="' + ratio + '"'));
assert.match(html, /class="btn btn-outline-danger btn-sm" data-crop-action="reset"/);
assert.match(html, /slide-image-cropper-frame is-loading" aria-busy="true"/);
assert.match(html, /slide-image-cropper-loading-overlay" aria-hidden="false"/);
assert.match(html, /spinner-border text-primary slide-image-cropper-loading-spinner" role="status"/);
assert.doesNotMatch(html, /zoom-in|zoom-out|data-crop-original/);
});
test('cropper fits the transformed original before restoring a saved crop box', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const start = source.indexOf('function () {', source.indexOf('ready:'));
const end = source.indexOf('\n });', start);
const crop = { x: 20, y: 30, width: 400, height: 200, rotate: 90, scaleX: -1, scaleY: 1 };
const calls = [];
const ready = vm.runInNewContext('(' + source.slice(start, end) + ')', {
active: { crop, original: '/media/uploads/original.jpg' },
cropper: {
rotateTo(value) { calls.push(['rotate', value]); },
scale(horizontal, vertical) { calls.push(['scale', horizontal, vertical]); },
setCropBoxData(value) { calls.push(['box', value.width, value.height]); },
getContainerData() { return { width: 900, height: 600 }; },
getCanvasData() { return { naturalWidth: 600, naturalHeight: 1200 }; },
zoomTo(value) { calls.push(['zoom', value]); },
setData(value) { calls.push(['data', value]); }
},
setLoading(value) { calls.push(['loading', value]); }, setBusy() {}, status() {}
});
ready();
assert.deepEqual(calls, [['rotate', 90], ['scale', -1, 1], ['box', 1, 1], ['zoom', 0.5], ['data', crop], ['loading', false]]);
});
test('crop loading toggles the frame overlay and accessible busy state', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const start = source.indexOf('function setLoading(');
const end = source.indexOf('\n function createModal()', start);
const attributes = {};
const overlay = { setAttribute(name, value) { attributes['overlay.' + name] = value; } };
const frame = {
classList: { toggle(name, value) { attributes[name] = value; } },
setAttribute(name, value) { attributes[name] = value; },
querySelector() { return overlay; }
};
const loading = vm.runInNewContext('(' + source.slice(start, end).trim() + ')', { modal: { querySelector() { return frame; } } });
loading(true);
assert.deepEqual(attributes, { 'is-loading': true, 'aria-busy': 'true', 'overlay.aria-hidden': 'false' });
loading(false);
assert.deepEqual(attributes, { 'is-loading': false, 'aria-busy': 'false', 'overlay.aria-hidden': 'true' });
assert.ok(source.indexOf('setLoading(true);', source.indexOf('active = {')) < source.indexOf(".src = active.original"));
assert.match(source, /addEventListener\('error', function \(\) \{\s+setLoading\(false\);/);
});
test('library upload selection and drop submit the original File and reject invalid files', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8');
const handlers = {};
let submissions = 0;
const input = { accept: 'image/png, image/gif, image/svg+xml', dataset: { imageMaxBytes: '1000' }, files: [], addEventListener(name, handler) { handlers[name] = handler; } };
const zone = { classList: { add() {}, remove() {} }, addEventListener(name, handler) { handlers[name] = handler; } };
const context = vm.createContext({
uploadInput: input, uploadZone: zone, uploadName: {},
uploadForm: { requestSubmit() { submissions += 1; } },
window: { PulseUpload: { showMessage() {}, formatUploadLimit: String } },
DataTransfer: class { constructor() { this.files = []; this.items = { add: file => this.files.push(file) }; } }
});
vm.runInContext(source.slice(source.indexOf('function setUploadFile('), source.indexOf('if (uploadForm) {')), context);
for (const type of ['image/png', 'image/gif', 'image/svg+xml']) {
const file = { name: 'original', type, size: 42 };
input.files = [file];
handlers.change();
assert.equal(input.files[0], file);
handlers.drop({ preventDefault() {}, dataTransfer: { files: [file] } });
assert.equal(input.files[0], file);
}
assert.equal(submissions, 6);
input.files = [{ type: 'image/png', size: 1001 }];
handlers.change();
handlers.drop({ preventDefault() {}, dataTransfer: { files: [{ type: 'text/plain', size: 1 }] } });
assert.equal(submissions, 6);
const slideForm = fs.readFileSync(path.join(__dirname, '../src/web/views/signage/slides/form.hbs'), 'utf8');
assert.doesNotMatch(slideForm, /slide-image-cropper|cropper\.min/);
});
test('slide images retain original references and re-editable crops across saves', async () => {
const pool = { async query(sql) {
if (sql.includes('FROM c_templates st')) return [[{ id: 7, canvas_size_width: 1920, canvas_size_height: 1080 }]];
if (sql.includes('FROM c_template_regions')) return [[{ id: 22, region_key: 'photo', region_type: 'image' }]];
return [[]];
} };
const crop = { x: 20, y: 30, width: 400, height: 200, rotate: 90, scaleX: -1, scaleY: 1 };
const body = { title: 'Photo', template_id: '7', existing_region_image_22: '/media/uploads/crop.png', region_image_original_22: '/media/uploads/original.jpg', region_image_crop_22: JSON.stringify(crop) };
const saved = await buildSlidePayload(pool, { body, files: [] }, null);
const photo = JSON.parse(saved.contentJson).photo;
assert.equal(photo.value, '/media/uploads/crop.png');
assert.equal(photo.original_value, '/media/uploads/original.jpg');
assert.deepEqual(photo.crop, crop);
const reopened = await buildSlidePayload(pool, { body: { title: 'Renamed', template_id: '7' }, files: [] }, { template_id: 7, content_json: saved.contentJson });
assert.deepEqual(JSON.parse(reopened.contentJson).photo, photo);
const references = require('../src/data/content-references').collectContentReferences('slide', 1, { photo });
assert.deepEqual(references.filter(reference => reference.reference_type === 'media').map(reference => reference.reference_value).sort(), ['/media/uploads/crop.png', '/media/uploads/original.jpg']);
const reset = await buildSlidePayload(pool, { body: { ...body, existing_region_image_22: photo.original_value, region_image_crop_22: '' }, files: [] }, { content_json: saved.contentJson });
assert.equal(JSON.parse(reset.contentJson).photo.value, photo.original_value);
assert.equal(JSON.parse(reset.contentJson).photo.crop, null);
const replaced = await buildSlidePayload(pool, { body: { title: 'Photo', template_id: '7', existing_region_image_22: '/media/uploads/new.jpg' }, files: [] }, { content_json: saved.contentJson });
assert.equal(JSON.parse(replaced.contentJson).photo.original_value, '/media/uploads/new.jpg');
assert.equal(JSON.parse(replaced.contentJson).photo.crop, null);
const cleared = await buildSlidePayload(pool, { body: { ...body, existing_region_image_22: '' }, files: [] }, { content_json: saved.contentJson });
assert.equal(JSON.parse(cleared.contentJson).photo.original_value, '');
assert.equal(JSON.parse(cleared.contentJson).photo.crop, null);
});
test('media library sorts Date-valued creation timestamps chronologically', async () => {
const originalSyncMediaAssets = mediaLibrary.syncMediaAssetsFromDirectory;
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
@@ -99,7 +627,7 @@ test('media library creates only smaller image variants and preserves the origin
try {
const variants = await createImageVariants({ path: filePath });
assert.deepEqual(variants.map(function (variant) { return variant.width; }), [2048, 4096]);
assert.equal((await fs.promises.stat(filePath)).size, sourceBuffer.length);
assert.deepEqual(await fs.promises.readFile(filePath), sourceBuffer);
for (const variant of variants) {
const variantPath = path.join(uploadDir, path.basename(variant.path));
assert.ok((await fs.promises.stat(variantPath)).size < sourceBuffer.length);
@@ -264,6 +792,22 @@ test('media library upload and delete routes audit successful asset changes', as
assert.equal(events[0].targetLabel, 'Photo.png');
assert.equal(events[1].targetId, 9);
assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 4; }));
await fs.promises.writeFile(filePath, 'image');
const jsonResponse = {
statusCode: 200,
status(value) { this.statusCode = value; return this; },
json(value) { this.data = value; return this; },
redirect() { throw new Error('JSON upload must not redirect.'); }
};
await routeHandlers['POST /media-library'].at(-1)({ ...request, headers: { accept: 'application/json' } }, jsonResponse, next);
assert.deepEqual(jsonResponse.data, { success: true, path: '/media/uploads/photo.png' });
assert.equal(jsonResponse.statusCode, 200);
await routeHandlers['POST /media-library'].at(-1)({ headers: { accept: 'application/json' } }, jsonResponse, next);
assert.equal(jsonResponse.statusCode, 400);
assert.match(jsonResponse.data.error, /Choose an image or video/);
await routeHandlers['POST /media-library'].at(-1)({ ...request, headers: { accept: 'application/json' }, file: { ...request.file, mimetype: 'text/plain' } }, jsonResponse, next);
assert.equal(jsonResponse.statusCode, 400);
assert.match(jsonResponse.data.error, /disabled/);
} finally {
mediaLibrary.createImageVariants = originalCreateVariants;
mediaLibrary.registerMediaAsset = originalRegisterAsset;
@@ -273,6 +817,88 @@ test('media library upload and delete routes audit successful asset changes', as
}
});
test('media library drop-zone upload reports transfer progress, processing, errors and success', async () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8');
const handlers = {};
const requests = [];
const messages = [];
const attributes = {};
const progress = { hidden: true };
const label = { textContent: '' };
const bar = { setAttribute(name, value) { attributes[name] = value; }, removeAttribute(name) { delete attributes[name]; } };
const fill = { style: {}, classList: { toggle() {} }, textContent: '' };
const input = { disabled: false, files: [{ name: 'original.png' }], value: 'original.png' };
const closeButton = { disabled: false };
let refreshed = 0;
let hidden = 0;
const zone = {
classList: { toggle(name, value) { attributes[name] = value; } },
setAttribute(name, value) { attributes[name] = value; },
querySelector(selector) { return selector.endsWith('progress]') ? progress : selector.endsWith('text]') ? label : selector.endsWith('bar]') ? bar : fill; }
};
const context = vm.createContext({
uploadForm: { action: '/media-library', addEventListener(name, handler) { handlers[name] = handler; } },
uploadInput: input, uploadZone: zone,
uploadModal: { querySelectorAll() { return [closeButton]; }, addEventListener(name, handler) { handlers[name] = handler; } },
window: { PulseUpload: { showMessage(message) { messages.push(message); } }, showToast() {} },
hideModal() { hidden += 1; }, async refreshGallery() { refreshed += 1; },
FormData: class { constructor() { assert.equal(input.disabled, false); } },
XMLHttpRequest: class {
constructor() { this.upload = {}; requests.push(this); }
open(method, url) { this.method = method; this.url = url; }
setRequestHeader() {}
send(data) { this.data = data; }
}
});
const start = source.indexOf('function setUploadProgress(');
const end = source.indexOf('function getGalleryFilters()', start);
vm.runInContext('let uploadInProgress = false;\n' + source.slice(start, end), context);
const submit = () => handlers.submit({ preventDefault() {} });
submit();
assert.equal(progress.hidden, false);
assert.equal(input.disabled, true);
assert.equal(closeButton.disabled, true);
assert.equal(attributes['aria-busy'], 'true');
requests[0].upload.onprogress({ lengthComputable: true, loaded: 250, total: 1000 });
assert.equal(fill.style.width, '25%');
assert.equal(label.textContent, 'Uploading 25%');
assert.equal(attributes['aria-valuenow'], '25');
requests[0].upload.onload();
assert.equal(label.textContent, 'Processing...');
assert.equal(fill.style.width, '100%');
assert.equal(attributes['aria-valuenow'], undefined);
submit();
assert.equal(requests.length, 1);
let prevented = false;
handlers['hide.bs.modal']({ preventDefault() { prevented = true; } });
assert.equal(prevented, true);
requests[0].status = 400;
requests[0].responseText = JSON.stringify({ error: 'Upload rejected.' });
await requests[0].onload();
assert.deepEqual(messages, ['Upload rejected.']);
assert.equal(input.disabled, false);
assert.equal(closeButton.disabled, false);
assert.equal(progress.hidden, true);
submit();
requests[1].ontimeout();
assert.equal(input.disabled, false);
assert.match(messages[1], /timed out/);
submit();
requests[2].status = 200;
requests[2].responseText = JSON.stringify({ success: true });
await requests[2].onload();
assert.equal(hidden, 1);
assert.equal(refreshed, 1);
assert.equal(input.value, '');
assert.equal(progress.hidden, true);
assert.equal(attributes['aria-busy'], 'false');
const view = fs.readFileSync(path.join(__dirname, '../src/web/views/settings/media-library/list.hbs'), 'utf8');
assert.match(view, /data-media-library-upload-progress/);
assert.match(view, /role="progressbar" aria-label="Media upload progress"/);
assert.match(view, /class="progress rounded-pill slide-image-region-upload-zone-progress-bar"/);
assert.match(view, /class="progress-bar bg-primary rounded-pill progress-bar-striped progress-bar-animated"/);
});
test('media library delete redirects preserve filters for individual and bulk outcomes', async () => {
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
const originalRemoveVariants = mediaLibrary.removeImageVariants;
+79 -6
View File
@@ -462,7 +462,7 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
]);
});
function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate) {
function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate, imageOptions) {
const registerContentRoutes = require('../src/web/routes/admin/content');
const handlers = {};
const registrations = [];
@@ -475,6 +475,13 @@ function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate) {
id: 11, region_key: 'hero', region_type: 'text', x: 10, y: 20, width: 300, height: 100,
z_index: 1, lock_ratio: null, animation_json: '{"intro":{"preset":"fadeIn"}}'
}] };
if (imageOptions) {
template.regions[0].region_type = 'image';
template.regions[0].width = 300;
template.regions[0].height = 200;
if (imageOptions.defaultContent) template.regions[0].default_content = imageOptions.defaultContent;
savedContent.hero = imageOptions.content;
}
const connection = {
async beginTransaction() { calls.push(['begin']); },
async commit() { calls.push(['commit']); },
@@ -483,6 +490,10 @@ function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate) {
async query(sql, params) {
calls.push([sql, params]);
if (sql.startsWith('SELECT id, template_id, content_json')) return [[{ id: 42, template_id: 7, content_json: JSON.stringify(savedContent) }]];
if (sql.startsWith('SELECT id, content_json FROM c_slides')) return [[{ id: 42, content_json: JSON.stringify(savedContent) }]];
if (imageOptions && sql.startsWith('UPDATE c_template_regions SET x')) {
Object.assign(template.regions[0], { x: params[0], y: params[1], width: params[2], height: params[3] });
}
if (failUpdate && sql.startsWith('UPDATE c_template_regions')) throw new Error('Write failed');
if (sql.startsWith('UPDATE c_template_regions SET animation_json')) {
template.regions.find(region => region.id === params[2]).animation_json = params[0];
@@ -496,13 +507,14 @@ function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate) {
get(route, ...actions) { handlers['GET ' + route] = actions; registrations.push('GET ' + route); },
post(route, ...actions) { handlers['POST ' + route] = actions; registrations.push('POST ' + route); }
}, {
pool: { async getConnection() { return connection; }, async query() { return [[{ slide_count: 3 }]]; } },
common: { fetchTemplateById: async () => template, parseJsonSafe: JSON.parse }, pages: {},
uploadDir: imageOptions && imageOptions.uploadDir,
pool: { async getConnection() { return connection; }, async query(sql, params) { calls.push([sql, params]); return [[{ slide_count: 3 }]]; } },
common: { fetchTemplateById: async () => imageOptions ? JSON.parse(JSON.stringify(template)) : template, parseJsonSafe: JSON.parse }, pages: {},
upload: { any: () => noop, single: () => noop }, setAuthMessageCookie: noop,
fetchScreensBySlideId: async () => [], fetchScreensByTemplateId: async () => [{ slug: 'lobby' }],
collectUploadReferencesFromSlide: () => [], collectUploadReferencesFromTemplate: () => [],
collectUploadReferencesFromPayload: () => [], removeUnusedUploadFiles: noop,
syncPlaylistUploadsOnChange: noop, getAuditUserId: () => 9, redirectAfterSave: noop,
collectUploadReferencesFromPayload: payload => imageOptions ? require('../src/data/content-references').collectContentReferences('slide', 42, JSON.parse(payload.contentJson)).filter(reference => reference.reference_type === 'media').map(reference => reference.reference_value) : [], removeUnusedUploadFiles: noop,
syncPlaylistUploadsOnChange: async value => calls.push(['sync', value]), getAuditUserId: () => 9, redirectAfterSave: noop,
notifyPlayerScreens: async (...args) => calls.push(['notify', ...args]), broadcastDashboardState: noop,
getSlideDeleteBlockMessage: noop, getTemplateDeleteBlockMessage: noop, getCanvasSizeDeleteBlockMessage: noop,
hasAnyPermission: () => Boolean(allowSlideUpdate),
@@ -691,4 +703,65 @@ test('thumbnail markup uses template defaults for read-only regions', function (
}, 'http://localhost');
assert.match(html, /\/media\/brand\.png/);
assert.doesNotMatch(html, /old-brand/);
});
});
test('shared layout save regenerates cropped playback images and syncs the new file', async () => {
const fs = require('node:fs/promises');
const path = require('node:path');
const uploadDir = await fs.mkdtemp(path.join(require('node:os').tmpdir(), 'pulse-layout-crop-'));
try {
await require('sharp')({ create: { width: 1200, height: 800, channels: 3, background: '#ff0000' } }).png().toFile(path.join(uploadDir, 'original.png'));
const content = { type: 'image', value: '/media/uploads/small.png', original_value: '/media/uploads/original.png', crop: { x: 0, y: 0, width: 1200, height: 800 } };
const { handlers, calls, template } = createSharedLayoutRouteHarness(false, true, { uploadDir, content });
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: {
baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 0, y: 0, width: 900, height: 600, z_index: 1, lock_ratio: '' }]
} }, { json() {} }, error => { throw error; });
const write = calls.find(call => call[0].startsWith('UPDATE c_slides SET content_json'));
const saved = JSON.parse(write[1][0]).hero;
assert.equal(saved.original_value, content.original_value);
assert.deepEqual(saved.crop, content.crop);
const metadata = await require('sharp')(path.join(uploadDir, path.basename(saved.value))).metadata();
assert.equal(metadata.width, 900);
assert.equal(metadata.height, 600);
assert.ok(calls.some(call => call[0].startsWith('UPDATE c_media_assets SET is_published')));
assert.ok(calls.some(call => call[0] === 'sync' && call[1].nextUploadRefs.has(saved.value)));
assert.ok(calls.some(call => call[0].startsWith('INSERT IGNORE INTO c_content_references')));
} finally {
await fs.rm(uploadDir, { recursive: true, force: true });
}
});
for (const uncropped of [false, true]) {
test('shared image layouts resize ' + (uncropped ? 'uncropped' : 'cropped') + ' defaults but retain explicit slide geometry', async () => {
const fs = require('node:fs/promises');
const path = require('node:path');
const uploadDir = await fs.mkdtemp(path.join(require('node:os').tmpdir(), 'pulse-default-crop-'));
try {
await require('sharp')({ create: { width: 1200, height: 800, channels: 3, background: '#00ff00' } }).png().toFile(path.join(uploadDir, 'original.png'));
const original = { type: 'image', value: '/media/uploads/old.png', original_value: '/media/uploads/original.png', crop: uncropped ? null : { x: 0, y: 0, width: 1200, height: 800 } };
const { handlers, calls, template } = createSharedLayoutRouteHarness(false, true, {
uploadDir, defaultContent: { ...original }, content: { ...original, layout: { x: 0, y: 0, width: 150, height: 100 } }
});
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: {
baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 0, y: 0, width: 900, height: 600, z_index: 1, lock_ratio: '' }]
} }, { json() {} }, error => { throw error; });
const defaultWrite = calls.find(call => call[0].startsWith('UPDATE c_template_regions SET default_content_json'));
const defaultImage = JSON.parse(defaultWrite[1][0]);
const defaultMetadata = await require('sharp')(path.join(uploadDir, path.basename(defaultImage.value))).metadata();
assert.equal(defaultMetadata.width, 900);
assert.equal(defaultMetadata.height, 600);
const slideWrite = calls.find(call => call[0].startsWith('UPDATE c_slides SET content_json'));
const slideImage = JSON.parse(slideWrite[1][0]).hero;
const slideMetadata = await require('sharp')(path.join(uploadDir, path.basename(slideImage.value))).metadata();
assert.equal(slideMetadata.width, 150);
assert.equal(slideMetadata.height, 100);
assert.equal(slideImage.layout.width, 150);
assert.equal(defaultImage.original_value, original.original_value);
assert.deepEqual(defaultImage.crop, original.crop);
} finally {
await fs.rm(uploadDir, { recursive: true, force: true });
}
});
}
+10 -7
View File
@@ -19,19 +19,22 @@ test('slide table thumbnails size to the canvas ratio and show the whole image',
assert.match(placeholderRule, /min-height:\s*0;/);
});
test('slide list thumbnails open and release an authorized preview modal', () => {
test('slide list thumbnails open and release the shared saved-image preview modal', () => {
const listTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'slides', 'list.hbs'), 'utf8');
const slidesRoutes = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'routes', 'signage', 'slides', 'routes.js'), 'utf8');
const previewTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'shared', 'slide-preview-modal.hbs'), 'utf8');
const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8');
assert.match(listTemplate, /class="playlist-slide-thumb slide-list-thumbnail-button"[^>]*data-bs-toggle="modal"/);
assert.match(listTemplate, /data-slide-preview-url="\/api\/internal\/slide-thumbnails\/\{\{id\}\}\/popup-preview"/);
assert.match(listTemplate, /data-slide-preview-url="\{\{thumbnail_path\}\}"/);
assert.match(listTemplate, /data-canvas-width="\{\{canvas_width\}\}" data-canvas-height="\{\{canvas_height\}\}"/);
assert.match(listTemplate, /currentAspectRatio = canvasWidth > 0 && canvasHeight > 0 \? canvasWidth \/ canvasHeight : 16 \/ 9/);
assert.match(listTemplate, /\{\{> slide-preview-modal\}\}/);
assert.match(previewTemplate, /currentAspectRatio = canvasWidth > 0 && canvasHeight > 0 \? canvasWidth \/ canvasHeight : 16 \/ 9/);
assert.match(listTemplate, /aria-label="Preview slide: \{\{title\}\}"/);
assert.match(listTemplate, /data-slide-preview-frame/);
assert.match(listTemplate, /frame\.src = 'about:blank'/);
assert.match(slidesRoutes, /\/api\/internal\/slide-thumbnails\/:id\/popup-preview', requirePopupPreviewAccess, async function/);
assert.match(previewTemplate, /<img[^>]+data-slide-preview-image hidden/);
assert.match(previewTemplate, /image\.src = thumbnailUrl\.replace/);
assert.match(previewTemplate, /image\.src = thumbnailUrl;/);
assert.match(previewTemplate, /modal\.addEventListener\('hidden\.bs\.modal', function \(\) \{\s*thumbnailUrl = '';\s*image\.hidden = true;\s*image\.removeAttribute\('src'\)/);
assert.doesNotMatch(previewTemplate, /<iframe\b|popup-preview|about:blank/);
assert.match(stylesheet, /^\.slide-list-thumbnail-button:focus-visible\s*\{/m);
const previewFrameRule = stylesheet.match(/^\.slide-list-preview-frame\s*\{([^}]+)\}/m)[1];
assert.match(previewFrameRule, /background:\s*var\(--bs-body-bg\);/);
+238 -2
View File
@@ -8,6 +8,49 @@ const express = require('express');
require('../src/common');
const { createUploadSyncService } = require('../src/web/lib/media');
const { buildPlayerAssetPlan } = require('../src/web/lib/media/player-assets');
const { getMediaStorageId } = require('../src/media-storage');
test('media storage IDs distinguish separate player volumes from the shared web volume', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-storage-identity-'));
try {
const web = path.join(root, 'web');
const local = path.join(root, 'player-local');
const remote = path.join(root, 'player-remote');
const webId = await getMediaStorageId(web);
assert.equal(await getMediaStorageId(web), webId);
assert.notEqual(await getMediaStorageId(local), webId);
assert.notEqual(await getMediaStorageId(remote), webId);
const moved = path.join(root, 'web-moved');
fs.renameSync(web, moved);
assert.equal(await getMediaStorageId(moved), webId);
const copied = path.join(root, 'web-independent-copy');
fs.cpSync(moved, copied, { recursive: true });
assert.equal(await getMediaStorageId(copied), webId);
fs.unlinkSync(path.join(copied, '.storage-id'));
assert.notEqual(await getMediaStorageId(copied), webId);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('unused generated copies are removed before media sync even with no connected players', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-no-player-copy-cleanup-'));
const filename = 'image-crop-' + 'c'.repeat(64) + '.png';
fs.writeFileSync(path.join(uploadDir, filename), Buffer.from('copy'));
fs.writeFileSync(path.join(uploadDir, 'original.png'), Buffer.from('original'));
const pool = { async query(sql) {
if (sql.startsWith('SELECT id, original_name')) return [[{ id: 1, original_name: filename, is_published: 1 }]];
if (sql.includes('COUNT(DISTINCT owner_type, owner_id)')) return [[{ owner_count: 0 }]];
return [[{ ref_count: 0 }]];
} };
const service = createUploadSyncService({ pool, common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {}, fetchPlayerRegistrations: async () => [], backgroundTaskQueue: { async enqueueTaskAndWait() {} } });
try {
await service.syncPlaylistUploadsOnChange({ key: 'slide:update:1', pool, localUploadDir: uploadDir, previousUploadRefs: ['/media/uploads/' + filename, '/media/uploads/original.png'], nextUploadRefs: [] });
assert.equal(fs.existsSync(path.join(uploadDir, filename)), false);
assert.equal(fs.existsSync(path.join(uploadDir, 'original.png')), true);
} finally {
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('upload reference collection includes WYSIWYG images across slide regions', () => {
const uploadSyncService = createUploadSyncService({
@@ -307,6 +350,9 @@ test('slide update sync removes uploads only after the web-managed file is gone'
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'keep.bin'), Buffer.from('keep'));
fs.writeFileSync(path.join(uploadDir, 'uploads', 'remove.bin'), Buffer.from('remove'));
const manifestDir = path.join(uploadDir, 'player-cache', 'media-sync');
fs.mkdirSync(manifestDir, { recursive: true });
fs.writeFileSync(path.join(manifestDir, require('node:crypto').createHash('sha256').update('player-one').digest('hex') + '.json'), JSON.stringify({ playerIdentifier: 'player-one', files: ['/media/uploads/remove.bin'], clients: [{ id: 'client-one', deviceId: 'client-one', playerIdentifier: 'player-one', screenSlug: 'screen-one', seenAt: Date.now() }] }));
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const fetchCalls = [];
@@ -332,8 +378,11 @@ test('slide update sync removes uploads only after the web-managed file is gone'
common: {
async fetchAdminData() {
return {
screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }],
playlistSlides: [{ playlist_id: 1, slide_id: 1 }],
slides: [
{
id: 1,
content_json: JSON.stringify({
imageRegion: {
type: 'image',
@@ -441,7 +490,11 @@ test('slide update sync queues one media task per live player and targets each p
let uploadSyncService;
uploadSyncService = createUploadSyncService({
common: {},
common: { async fetchAdminData() { return {
screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }, { id: 2, slug: 'screen-two', playlist_id: 1 }],
playlistSlides: [{ playlist_id: 1, slide_id: 1 }],
slides: [{ id: 1, content_json: { photo: { value: '/media/uploads/sample.bin' } } }], templates: []
}; } },
pool: {
async query() {
return [[
@@ -463,7 +516,10 @@ test('slide update sync queues one media task per live player and targets each p
]];
}
},
playerSnapshotCache: new Map(),
playerSnapshotCache: new Map([
['screen-one', { connections: [{ deviceId: 'client-one', playerDeviceId: 'player-one' }] }],
['screen-two', { connections: [{ deviceId: 'client-two', playerDeviceId: 'player-two' }] }]
]),
notifyPlayerScreens: async () => {},
backgroundTaskQueue: {
async enqueueTaskAndWait(definition) {
@@ -569,4 +625,184 @@ test('remote media deletion uses the bridge device route', async () => {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('playback reference collection excludes editing originals without weakening retention references', () => {
const service = createUploadSyncService({ common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {} });
const content = {
photo: { type: 'image', value: '/media/uploads/playback.png', original_value: '/media/uploads/original.png' },
text: { value: '<img src="/media/uploads/inline.png">' },
small: { type: 'image', value: '/media/uploads/small.png', original_value: '/media/uploads/small.png' }
};
assert.deepEqual([...service.collectPlaybackUploadReferences(content)].sort(), ['/media/uploads/inline.png', '/media/uploads/playback.png', '/media/uploads/small.png']);
const retained = new Set();
service.collectUploadReferencesFromValue(content, retained);
assert.ok(retained.has('/media/uploads/original.png'));
});
test('player asset plans separate direct and bridged players and retain offline client assignments', () => {
const service = createUploadSyncService({ common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {} });
const players = [{ identifier: 'local', public_base_url: 'http://player:8081' }, { identifier: 'remote', public_base_url: '' }];
const data = {
screens: [{ id: 1, slug: 'one', playlist_id: 10 }, { id: 2, slug: 'two', playlist_id: 20 }],
playlistSlides: [{ playlist_id: 10, slide_id: 1 }, { playlist_id: 20, slide_id: 2 }],
slides: [{ id: 1, template_id: 1, content_json: { photo: { value: '/media/uploads/local-copy.png', original_value: '/media/uploads/local-original.png' }, brand: { value: '/media/uploads/ignored.png' } } }, { id: 2, template_id: 2, content_json: { photo: { value: '/media/uploads/remote-copy.png', original_value: '/media/uploads/remote-original.png' } } }],
templates: [{ id: 1, background_image_path: '/media/uploads/background.png' }, { id: 2 }],
templateRegions: [{ template_id: 1, region_key: 'photo', region_type: 'image' }, { template_id: 1, region_key: 'brand', region_type: 'image', read_only: 1, default_content: { value: '/media/uploads/brand.png' } }, { template_id: 2, region_key: 'photo', region_type: 'image' }]
};
const snapshots = new Map([['one', { connections: [{ deviceId: 'client-one', playerPublicBaseUrl: 'http://player:8081' }] }], ['two', { connections: [{ deviceId: 'client-two', playerDeviceId: 'remote' }] }]]);
const plan = buildPlayerAssetPlan(data, snapshots, players, [], service.collectPlaybackUploadReferences);
assert.deepEqual([...plan.assets.get('local')].sort(), ['/media/uploads/background.png', '/media/uploads/brand.png', '/media/uploads/local-copy.png']);
assert.deepEqual([...plan.assets.get('remote')], ['/media/uploads/remote-copy.png']);
const offline = buildPlayerAssetPlan(data, new Map(), players, plan.clients, service.collectPlaybackUploadReferences);
assert.deepEqual([...offline.assets.get('remote')], ['/media/uploads/remote-copy.png']);
const moved = buildPlayerAssetPlan({ ...data, onboardingDevices: [{ device_id: 'client-two', screen_id: 1 }] }, new Map(), players, plan.clients, service.collectPlaybackUploadReferences);
assert.ok(moved.assets.get('remote').has('/media/uploads/local-copy.png'));
assert.ok(!moved.assets.get('remote').has('/media/uploads/remote-copy.png'));
});
test('scoped sync sends only assigned playback files through direct and bridge transports and persists offline assignments', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-scoped-players-'));
const uploads = path.join(root, 'uploads');
fs.mkdirSync(uploads);
for (const name of ['local.png', 'remote.png', 'original.png', 'unassigned.png', 'remote.variant-2048.png']) fs.writeFileSync(path.join(uploads, name), name);
const players = [
{ identifier: 'local', internal_base_url: 'http://player:8081', public_base_url: 'http://local.test', last_seen_at: new Date() },
{ identifier: 'remote', internal_base_url: '', public_base_url: '', last_seen_at: new Date() }
];
const data = {
screens: [{ id: 1, slug: 'one', playlist_id: 1 }, { id: 2, slug: 'two', playlist_id: 2 }],
playlistSlides: [{ playlist_id: 1, slide_id: 1 }, { playlist_id: 2, slide_id: 2 }],
slides: [{ id: 1, content_json: { photo: { value: '/media/uploads/local.png', original_value: '/media/uploads/original.png' } } }, { id: 2, content_json: { photo: { value: '/media/uploads/remote.png', original_value: '/media/uploads/original.png' } } }], templates: []
};
const pool = { async query(sql) {
if (sql.includes('FROM d_players')) return [players];
if (sql.includes('FROM d_onboarding_devices')) return [[{ device_id: 'a', screen_id: 1 }, { device_id: 'b', screen_id: 2 }]];
return [[]];
} };
const calls = [];
const originalFetch = global.fetch;
let failTransfer = false;
global.fetch = async function (url, init) { calls.push({ url, method: init.method, player: init.headers['x-pulse-player-device-id'] }); return { ok: !(failTransfer && init.method === 'PUT'), status: failTransfer ? 503 : 200 }; };
const snapshots = new Map([['one', { connections: [{ deviceId: 'a', playerDeviceId: 'local' }] }], ['two', { connections: [{ deviceId: 'b', playerDeviceId: 'remote' }] }]]);
let service;
const options = { pool, common: { async fetchAdminData() { return data; } }, bridgeInternalBaseUrl: 'http://player-bridge:8090', playerSnapshotCache: snapshots, notifyPlayerScreens: async () => {}, backgroundTaskQueue: { async enqueueTaskAndWait(task) { await service.runMediaSyncTask(task.payload); } } };
try {
service = createUploadSyncService(options);
const savedSnapshots = [...snapshots];
snapshots.clear();
await service.runMediaSyncTask({ mode: 'playlist', playerIdentifier: 'remote', operation: { localUploadDir: uploads } });
assert.equal(calls.length, 0);
for (const [slug, snapshot] of savedSnapshots) snapshots.set(slug, snapshot);
failTransfer = true;
await assert.rejects(service.syncPlaylistUploadsOnChange({ key: 'slide:update:2', localUploadDir: uploads, nextUploadRefs: ['/media/uploads/remote.png'] }), /required playback asset/);
snapshots.delete('two');
failTransfer = false;
calls.length = 0;
service = createUploadSyncService(options);
await service.syncPlaylistUploadsOnChange({ key: 'slide:update:2', localUploadDir: uploads, nextUploadRefs: ['/media/uploads/remote.png'] });
assert.deepEqual(calls.filter(call => call.method === 'PUT'), [{ url: 'http://player-bridge:8090/api/media/uploads%2Fremote.png', method: 'PUT', player: 'remote' }]);
assert.ok(calls.some(call => call.method === 'DELETE' && call.url.endsWith('original.png')));
calls.length = 0;
await service.runMediaSyncTask({ mode: 'initial', uploadDir: root, playerIdentifier: 'local' });
assert.deepEqual(calls.filter(call => call.method === 'PUT'), [{ url: 'http://player:8081/api/media/uploads%2Flocal.png', method: 'PUT', player: 'local' }]);
snapshots.clear();
service = createUploadSyncService(options);
calls.length = 0;
await service.runMediaSyncTask({ mode: 'playlist', playerIdentifier: 'remote', operation: { localUploadDir: uploads } });
assert.deepEqual(calls.filter(call => call.method === 'PUT').map(call => call.url), ['http://player-bridge:8090/api/media/uploads%2Fremote.png']);
assert.equal(fs.existsSync(path.join(uploads, 'original.png')), true);
assert.equal(fs.existsSync(path.join(uploads, 'local.png')), true);
} finally {
global.fetch = originalFetch;
fs.rmSync(root, { recursive: true, force: true });
}
});
test('client-assignment sync refreshes its display after required media transfer', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-assignment-refresh-'));
const originalTimer = global.setTimeout;
let scheduled;
let queuedResolve;
const queued = new Promise(resolve => { queuedResolve = resolve; });
const service = createUploadSyncService({
pool: { async query(sql) { return sql.includes('FROM d_players') ? [[{ identifier: 'one', internal_base_url: 'http://player:8081', last_seen_at: new Date() }]] : [[]]; } },
common: { async fetchAdminData() { return { screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }], playlistSlides: [], slides: [], templates: [] }; } },
playerSnapshotCache: new Map([['screen-one', { connections: [{ deviceId: 'client-one', playerDeviceId: 'one' }] }]]),
notifyPlayerScreens: async () => {},
backgroundTaskQueue: { async enqueueTaskAndWait(task) { queuedResolve(task); } }
});
try {
global.setTimeout = callback => { scheduled = callback; return { unref() {} }; };
service.scheduleAssignmentSync(root);
global.setTimeout = originalTimer;
scheduled();
const task = await queued;
assert.deepEqual(task.payload.operation.refreshScreenSlugs, ['screen-one']);
} finally {
global.setTimeout = originalTimer;
fs.rmSync(root, { recursive: true, force: true });
}
});
test('explicit player refresh retries failed local media even when playlist refresh is already in flight', () => {
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-playback.js'), 'utf8');
const start = source.indexOf('function refresh(applyImmediately, bypassDebounce)');
const end = source.indexOf(' playlistRefreshInFlight = true;', start);
const failed = { complete: true, naturalWidth: 0, src: 'http://player.test/media/uploads/crop.png' };
const loaded = { complete: true, naturalWidth: 1280, src: 'http://player.test/media/uploads/loaded.png' };
const remote = { complete: true, naturalWidth: 0, src: 'https://other.test/image.png' };
const pending = { complete: false, naturalWidth: 0, src: 'http://player.test/media/uploads/pending.png' };
const refresh = vm.runInNewContext('(' + source.slice(start, end) + '\n})', { document: { querySelectorAll() { return [failed, loaded, remote, pending]; } }, window: { location: { href: 'http://player.test/screen/test', origin: 'http://player.test' } }, URL, Date, playlistRefreshInFlight: true });
refresh(true);
assert.match(failed.src, /crop\.png\?_mediaRefresh=\d+/);
assert.equal(loaded.src, 'http://player.test/media/uploads/loaded.png');
assert.equal(remote.src, 'https://other.test/image.png');
assert.equal(pending.src, 'http://player.test/media/uploads/pending.png');
});
test('player keeps an initial loading screen until slide images decode and cancels stale readiness waits', async () => {
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-media.js'), 'utf8');
const start = source.indexOf('async function waitForSlideImages(');
const images = [];
const loaders = [];
const context = vm.createContext({
Image: class { constructor() { images.push(this); } decode() { return Promise.resolve(); } },
document: { createElement(type) { return type === 'template' ? { content: { querySelectorAll() { return [{ getAttribute() { return '/media/uploads/crop.png'; } }]; } } } : { setAttribute() {} }; } },
app: { querySelector() { return loaders[0] || null; }, appendChild(element) { loaders.push(element); } },
window: { setTimeout() { return 1; }, clearTimeout() {}, location: { href: 'http://player.test/screen/test', origin: 'http://player.test' } },
lastRenderedViewKey: '', slideRenderRequestId: 1, URL, Date
});
vm.runInContext(source.slice(start), context);
const waiting = context.waitForSlideImages('<img src="/media/uploads/crop.png">', 1);
assert.equal(loaders.length, 1);
assert.match(loaders[0].innerHTML, /Loading media/);
assert.equal(images.length, 1);
images[0].naturalWidth = 1280;
await images[0].onload();
assert.equal(await waiting, '<img src="/media/uploads/crop.png">');
context.slideRenderRequestId = 2;
const stale = context.waitForSlideImages('<img src="/media/uploads/crop.png">', 2);
context.slideRenderRequestId = 3;
images[1].naturalWidth = 1280;
await images[1].onload();
assert.equal(await stale, false);
});
test('player watchdog does not reload while initial media is still loading', () => {
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-transition.js'), 'utf8');
const start = source.indexOf('function startPlayerWatchdog()');
const end = source.indexOf('// Cancel the current slide-advance timer.', start);
let tick;
let reloaded = false;
const context = vm.createContext({ playerWatchdogTimer: null, playerLastProgressAt: 0, playerWatchdogRecoveryKey: 'check', isPaused: false, slides: [{}], lastRenderedSlide: {}, document: { visibilityState: 'visible', querySelector() { return {}; } }, window: { setInterval(callback) { tick = callback; return 1; }, sessionStorage: { getItem() { return '0'; }, setItem() {} }, location: { reload() { reloaded = true; } } }, Date, getSlideAdvanceDelay() { return 1000; } });
vm.runInContext(source.slice(start, end) + '\nstartPlayerWatchdog();', context);
tick();
assert.equal(reloaded, false);
context.document.querySelector = () => null;
tick();
assert.equal(reloaded, true);
});