diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16541f6..87da405 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/build/package.player.json b/build/package.player.json
index 1a9cbe2..8cbce23 100644
--- a/build/package.player.json
+++ b/build/package.player.json
@@ -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": {
diff --git a/build/package.web.json b/build/package.web.json
index 8b15b64..236fce8 100644
--- a/build/package.web.json
+++ b/build/package.web.json
@@ -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": {
diff --git a/docker-compose/README.md b/docker-compose/README.md
index a23cba9..716c7b1 100644
--- a/docker-compose/README.md
+++ b/docker-compose/README.md
@@ -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:
diff --git a/docs/user/guide-web.md b/docs/user/guide-web.md
index 2781a14..8c62b26 100644
--- a/docs/user/guide-web.md
+++ b/docs/user/guide-web.md
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 29657cc..ced0e49 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 30f2fa1..0d43cd4 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/data/slides.js b/src/data/slides.js
index a98d120..bb49c0b 100644
--- a/src/data/slides.js
+++ b/src/data/slides.js
@@ -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}`];
diff --git a/src/media-storage.js b/src/media-storage.js
new file mode 100644
index 0000000..c438078
--- /dev/null
+++ b/src/media-storage.js
@@ -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 };
\ No newline at end of file
diff --git a/src/player.js b/src/player.js
index 72a92a4..05b35e1 100644
--- a/src/player.js
+++ b/src/player.js
@@ -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;
diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css
index 49b5c0f..867b431 100644
--- a/src/player/public/css/player.css
+++ b/src/player/public/css/player.css
@@ -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;
diff --git a/src/player/public/js/player-page-media.js b/src/player/public/js/player-page-media.js
index 3cbf8b0..fff4808 100644
--- a/src/player/public/js/player-page-media.js
+++ b/src/player/public/js/player-page-media.js
@@ -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 = 'Loading media...';
+ 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();
diff --git a/src/player/public/js/player-page-playback.js b/src/player/public/js/player-page-playback.js
index 9f84cac..3ba3514 100644
--- a/src/player/public/js/player-page-playback.js
+++ b/src/player/public/js/player-page-playback.js
@@ -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;
}
diff --git a/src/player/public/js/player-page-transition.js b/src/player/public/js/player-page-transition.js
index f792a8d..cecc2cc 100644
--- a/src/player/public/js/player-page-transition.js
+++ b/src/player/public/js/player-page-transition.js
@@ -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;
}
diff --git a/src/player/routes.js b/src/player/routes.js
index ff9ef99..a16b6da 100644
--- a/src/player/routes.js
+++ b/src/player/routes.js
@@ -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) {
diff --git a/src/web.js b/src/web.js
index 1531432..31721cc 100644
--- a/src/web.js
+++ b/src/web.js
@@ -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);
diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js
index 1d93c52..3dd2f13 100644
--- a/src/web/bootstrap.js
+++ b/src/web/bootstrap.js
@@ -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);
diff --git a/src/web/lib/media/image-crops.js b/src/web/lib/media/image-crops.js
new file mode 100644
index 0000000..a02d0ce
--- /dev/null
+++ b/src/web/lib/media/image-crops.js
@@ -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 };
\ No newline at end of file
diff --git a/src/web/lib/media/library.js b/src/web/lib/media/library.js
index 581f5d6..cab8c93 100644
--- a/src/web/lib/media/library.js
+++ b/src/web/lib/media/library.js
@@ -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,
diff --git a/src/web/lib/media/player-assets.js b/src/web/lib/media/player-assets.js
new file mode 100644
index 0000000..ae6be35
--- /dev/null
+++ b/src/web/lib/media/player-assets.js
@@ -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 };
\ No newline at end of file
diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js
index b7ca57b..5cb7df8 100644
--- a/src/web/lib/media/upload-sync.js
+++ b/src/web/lib/media/upload-sync.js
@@ -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,
diff --git a/src/web/lib/region-scripts.js b/src/web/lib/region-scripts.js
index dc7cc7b..531f633 100644
--- a/src/web/lib/region-scripts.js
+++ b/src/web/lib/region-scripts.js
@@ -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 = {
diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css
index 91241d9..8ec50d6 100644
--- a/src/web/public/css/theme-custom.css
+++ b/src/web/public/css/theme-custom.css
@@ -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;
diff --git a/src/web/public/js/regions/type/image.js b/src/web/public/js/regions/type/image.js
index 777344d..af5c9f1 100644
--- a/src/web/public/js/regions/type/image.js
+++ b/src/web/public/js/regions/type/image.js
@@ -26,10 +26,19 @@
return '';
}
+ function renderCropButton(region) {
+ return '';
+ }
+
+ function renderFramingControls(region, value) {
+ var current = value || {};
+ return '' +
+ '';
+ }
+
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 @@
'