Align outro animations at slide end
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m21s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s

This commit is contained in:
lzstealth committed 2026-10-26 23:13:11 +00:00
1 parent 8ee59e97d3
commit 24a566a1a4
7 files changed
+88 -49

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- Animation delays now work in slide and template previews and player playback. Outro timing follows each preset's computed Animate.css duration and is configured as time before transition; preview cleanup waits for each preset's computed runtime, preview-only region staggering is removed, and slide animation controls enforce the same advanced limits as templates. - Animation delays now work in slide and template previews and player playback. Outro animations use each preset's computed Animate.css runtime and align to a shared end time; playback extends the slide hold when needed, and slide animation controls enforce the same advanced limits as templates.
## 2.21.1 - 2026-10-26 ## 2.21.1 - 2026-10-26
+10 -4
View File
@@ -5,6 +5,7 @@ var slideTransitionRequestId = 0;
var playerWatchdogTimer = null; var playerWatchdogTimer = null;
var playerLastProgressAt = Date.now(); var playerLastProgressAt = Date.now();
var playerWatchdogRecoveryKey = 'pulse-signage-player-watchdog-recoveries'; var playerWatchdogRecoveryKey = 'pulse-signage-player-watchdog-recoveries';
var slideOutroFrameBufferMs = 50;
function markPlayerPlaybackProgress() { function markPlayerPlaybackProgress() {
playerLastProgressAt = Date.now(); playerLastProgressAt = Date.now();
@@ -89,18 +90,22 @@ function scheduleSlideOutro(holdDelayMs) {
var currentRoot = getCurrentSlideRoot(); var currentRoot = getCurrentSlideRoot();
if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') { if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') {
return; return Math.max(1, Math.round(Number(holdDelayMs || 0)));
} }
var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro'); var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro');
if (!regionTimings.length) { if (!regionTimings.length) {
return; return Math.max(1, Math.round(Number(holdDelayMs || 0)));
} }
var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0))); var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0)));
var longestTimingMs = regionTimings.reduce(function (longest, entry) {
return Math.max(longest, Math.max(0, Math.round(Number(entry && entry.timingMs || 0))));
}, 0);
var transitionDelayMs = Math.max(slideDurationMs, longestTimingMs + slideOutroFrameBufferMs);
slideOutroTimers = regionTimings.map(function (entry) { slideOutroTimers = regionTimings.map(function (entry) {
var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0))); var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0)));
var triggerDelayMs = Math.max(0, slideDurationMs - timingMs); var triggerDelayMs = Math.max(0, transitionDelayMs - timingMs - slideOutroFrameBufferMs);
return window.setTimeout(function () { return window.setTimeout(function () {
if (!entry || !entry.element || !entry.element.isConnected) { if (!entry || !entry.element || !entry.element.isConnected) {
return; return;
@@ -108,14 +113,15 @@ function scheduleSlideOutro(holdDelayMs) {
playRegionAnimation(entry.element, 'outro'); playRegionAnimation(entry.element, 'outro');
}, triggerDelayMs); }, triggerDelayMs);
}); });
return transitionDelayMs;
} }
// Schedule the next slide transition. // Schedule the next slide transition.
function scheduleSlideAdvance(delayMs) { function scheduleSlideAdvance(delayMs) {
clearSlideTimer(); clearSlideTimer();
var holdDelayMs = Math.max(1, Number(delayMs || 0)); var holdDelayMs = Math.max(1, Number(delayMs || 0));
holdDelayMs = scheduleSlideOutro(holdDelayMs);
slideExpiresAt = Date.now() + holdDelayMs; slideExpiresAt = Date.now() + holdDelayMs;
scheduleSlideOutro(holdDelayMs);
timer = window.setTimeout(function () { timer = window.setTimeout(function () {
timer = null; timer = null;
slideExpiresAt = null; slideExpiresAt = null;
@@ -179,6 +179,7 @@ export function createSlideAnimationEditor(options) {
previewMode = mode; previewMode = mode;
var token = previewToken; var token = previewToken;
var longest = 0; var longest = 0;
var pending = [];
getPreviewElements().forEach(function (element) { getPreviewElements().forEach(function (element) {
var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(element.dataset.regionKey)); var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(element.dataset.regionKey));
var step = config[mode] || {}; var step = config[mode] || {};
@@ -196,7 +197,19 @@ export function createSlideAnimationEditor(options) {
void element.offsetWidth; void element.offsetWidth;
element.classList.add('animate__animated', 'animate__' + preset); element.classList.add('animate__animated', 'animate__' + preset);
if (mode === 'loop') element.classList.add('animate__repeat-1'); if (mode === 'loop') element.classList.add('animate__repeat-1');
longest = Math.max(longest, delay + getAnimationDurationMs(element, duration) * iterations); var runtime = getAnimationDurationMs(element, duration) * iterations;
pending.push({ element: element, delay: delay, runtime: runtime });
longest = Math.max(longest, delay + runtime);
});
pending.forEach(function (entry) {
var delay = mode === 'outro' ? longest - entry.runtime : entry.delay;
if (delay > 0) {
entry.element.style.setProperty('--animate-delay', delay + 'ms');
entry.element.style.setProperty('animation-delay', delay + 'ms');
} else {
entry.element.style.setProperty('--animate-delay', '0ms');
entry.element.style.setProperty('animation-delay', '0ms');
}
}); });
updatePreviewButtons(); updatePreviewButtons();
previewTimer = window.setTimeout(function () { previewTimer = window.setTimeout(function () {
@@ -364,6 +364,7 @@
rect.style.removeProperty('--animate-delay'); rect.style.removeProperty('--animate-delay');
rect.style.removeProperty('--animate-repeat'); rect.style.removeProperty('--animate-repeat');
rect.style.removeProperty('animation-delay'); rect.style.removeProperty('animation-delay');
rect.style.removeProperty('animation-play-state');
rect.style.opacity = ''; rect.style.opacity = '';
rect.style.transform = ''; rect.style.transform = '';
rect.style.filter = ''; rect.style.filter = '';
@@ -469,30 +470,15 @@
var MAX_ANIMATION_REPEAT_COUNT = 999; var MAX_ANIMATION_REPEAT_COUNT = 999;
function getPreviewAnimationStepTimingMs(step, mode, animateCssMode) { function getComputedAnimationDurationMs(element, fallbackDurationMs) {
var normalizedMode = String(mode || '').trim().toLowerCase(); if (typeof window.getComputedStyle !== 'function') return fallbackDurationMs;
var durationMs = Number(step && step.duration_ms); var durations = String(window.getComputedStyle(element).animationDuration || '').split(',');
var delayMs = Number(step && step.delay_ms); return durations.reduce(function (longest, value) {
var iterations = Number(step && step.iterations); var match = value.trim().match(/^([\d.]+)\s*(ms|s)$/i);
if (!match) return longest;
if (!Number.isFinite(durationMs) || durationMs <= 0) { var duration = Number(match[1]) * (match[2].toLowerCase() === 's' ? 1000 : 1);
durationMs = animateCssMode ? 1000 : 900; return Number.isFinite(duration) ? Math.max(longest, duration) : longest;
} }, 0) || fallbackDurationMs;
if (!Number.isFinite(delayMs) || delayMs < 0) {
delayMs = 0;
}
if (normalizedMode === 'out') {
delayMs = 0;
}
if (!Number.isFinite(iterations) || iterations < 1) {
iterations = 1;
}
if (normalizedMode === 'continuous') {
return delayMs + (durationMs * iterations);
}
return delayMs + durationMs;
} }
function normalizeAnimationStep(value, fallbackPreset) { function normalizeAnimationStep(value, fallbackPreset) {
@@ -708,42 +694,55 @@
function animatePreviewRects(mode, regions) { function animatePreviewRects(mode, regions) {
var rects = getPreviewRects(); var rects = getPreviewRects();
var token = previewState.token; var token = previewState.token;
var duration = 900; var fallbackDuration = 900;
var overlayRect = getOverlayRect(); var overlayRect = getOverlayRect();
var canvasMovement = Math.max(12, Math.round(overlayRect.height * 0.02)); var canvasMovement = Math.max(12, Math.round(overlayRect.height * 0.02));
var longestTimingMs = 0; var longestTimingMs = 0;
var pending = [];
previewState.animations = []; previewState.animations = [];
rects.forEach(function (rect) { rects.forEach(function (rect) {
var region = regions && regions[index] ? regions[index] : null; var regionIndex = Number(rect.getAttribute('data-index'));
var region = regions && Number.isInteger(regionIndex) ? regions[regionIndex] : null;
var step = getPreviewRegionAnimationStep(region, mode); var step = getPreviewRegionAnimationStep(region, mode);
var preset = normalizePreviewAnimation(step && step.preset); var preset = normalizePreviewAnimation(step && step.preset);
if (preset === 'none') return;
var delay = mode === 'out' ? 0 : Math.max(0, Number(step && step.delay_ms) || 0); var delay = mode === 'out' ? 0 : Math.max(0, Number(step && step.delay_ms) || 0);
var animation = null;
var animateCssMode = isAnimateCssPreset(preset); var animateCssMode = isAnimateCssPreset(preset);
var timingMs = delay + getPreviewAnimationStepTimingMs(step, mode, animateCssMode); var durationMs = Number(step && step.duration_ms);
if (!Number.isFinite(durationMs) || durationMs <= 0) durationMs = animateCssMode ? 1000 : fallbackDuration;
var iterations = String(mode || '').trim().toLowerCase() === 'continuous'
? Math.max(1, Number(step && step.iterations) || 1) : 1;
var runtimeMs = durationMs * iterations;
rect.style.willChange = 'transform, opacity, filter'; rect.style.willChange = 'transform, opacity, filter';
if (timingMs > longestTimingMs) {
longestTimingMs = timingMs;
}
if (preset === 'none') {
return;
}
if (animateCssMode) { if (animateCssMode) {
rect.style.setProperty('animation-play-state', 'paused');
applyAnimateCssPreview(rect, preset, step, mode); applyAnimateCssPreview(rect, preset, step, mode);
runtimeMs = getComputedAnimationDurationMs(rect, durationMs) * iterations;
}
pending.push({ rect: rect, mode: mode, preset: preset, delay: delay, runtimeMs: runtimeMs, animateCssMode: animateCssMode });
longestTimingMs = Math.max(longestTimingMs, delay + runtimeMs);
});
pending.forEach(function (entry) {
var rect = entry.rect;
var delay = mode === 'out' ? Math.max(0, longestTimingMs - entry.runtimeMs) : entry.delay;
if (entry.animateCssMode) {
rect.style.setProperty('--animate-delay', delay + 'ms');
rect.style.setProperty('animation-delay', delay + 'ms');
rect.style.removeProperty('animation-play-state');
return; return;
} }
var animation = null;
if (mode === 'intro') { if (mode === 'intro') {
animation = rect.animate([ animation = rect.animate([
{ opacity: 0, transform: 'translate3d(0, ' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' }, { opacity: 0, transform: 'translate3d(0, ' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' },
{ opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' } { opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' }
], { ], {
duration: duration, duration: entry.runtimeMs,
delay: delay, delay: delay,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)', easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'both' fill: 'both'
@@ -753,7 +752,7 @@
{ opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' }, { opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' },
{ opacity: 0, transform: 'translate3d(0, -' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' } { opacity: 0, transform: 'translate3d(0, -' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' }
], { ], {
duration: duration, duration: entry.runtimeMs,
delay: delay, delay: delay,
easing: 'cubic-bezier(0.4, 0, 1, 1)', easing: 'cubic-bezier(0.4, 0, 1, 1)',
fill: 'both' fill: 'both'
+5 -1
View File
@@ -1256,8 +1256,12 @@ test('player applies animation delays and schedules outro triggers to finish at
assert.equal(values['animation-delay'], '250ms'); assert.equal(values['animation-delay'], '250ms');
sandbox.scheduleSlideOutro(5000); sandbox.scheduleSlideOutro(5000);
assert.equal(timers[0].delay, 3200); assert.equal(timers[0].delay, 3150);
timers[0].callback(); timers[0].callback();
assert.equal(values['--animate-duration'], '700ms'); assert.equal(values['--animate-duration'], '700ms');
assert.equal(values['animation-delay'], undefined); assert.equal(values['animation-delay'], undefined);
timers.length = 0;
assert.equal(sandbox.scheduleSlideOutro(1000), 1850);
assert.equal(timers[0].delay, 0);
}); });
+7 -3
View File
@@ -386,8 +386,8 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
const previewTimers = []; const previewTimers = [];
const context = { window: { innerWidth: 1200, const context = { window: { innerWidth: 1200,
setTimeout(_callback, delay) { previewTimers.push(delay); return previewTimers.length; }, clearTimeout() {}, setTimeout(_callback, delay) { previewTimers.push(delay); return previewTimers.length; }, clearTimeout() {},
getComputedStyle() { getComputedStyle(element) {
return { animationDuration: '400ms' }; return { animationDuration: element.dataset.regionKey === 'fixed_brand' ? '400ms' : '100ms' };
}, },
pulseModal: { pulseModal: {
show(modal) { this.getOrCreate(modal)._scrollBar.hide(); modal.shown = true; }, show(modal) { this.getOrCreate(modal)._scrollBar.hide(); modal.shown = true; },
@@ -419,7 +419,10 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
outro: { preset: 'hinge', duration_ms: 200, delay_ms: 500 }, outro: { preset: 'hinge', duration_ms: 200, delay_ms: 500 },
loop: { preset: 'pulse', duration_ms: 200, delay_ms: 240, iterations: 1 } loop: { preset: 'pulse', duration_ms: 200, delay_ms: 240, iterations: 1 }
} }, } },
{ region_key: 'fixed_brand_second', read_only: true, animation_json: { loop: { preset: 'pulse', duration_ms: 200, iterations: 1 } } } { region_key: 'fixed_brand_second', read_only: true, animation_json: {
outro: { preset: 'fadeOut', duration_ms: 100 },
loop: { preset: 'pulse', duration_ms: 200, iterations: 1 }
} }
] }; }, ] }; },
getAnimationOverrides() { return {}; }, requestPreviewRender() {}, markEdited() {}, getAnimationOverrides() { return {}; }, requestPreviewRender() {}, markEdited() {},
setAnimationOverrides(value) { applied = value; } setAnimationOverrides(value) { applied = value; }
@@ -437,6 +440,7 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
animationPreviewButtons[2].handlers.click(); animationPreviewButtons[2].handlers.click();
animationPreviewButtons[1].handlers.click(); animationPreviewButtons[1].handlers.click();
assert.equal(readonlyPreviewRegion.style.values['animation-delay'], '0ms'); assert.equal(readonlyPreviewRegion.style.values['animation-delay'], '0ms');
assert.equal(secondPreviewRegion.style.values['animation-delay'], '300ms');
animationPreviewButtons[1].handlers.click(); animationPreviewButtons[1].handlers.click();
assert.equal(previewTimers[previewTimers.length - 1], 580); assert.equal(previewTimers[previewTimers.length - 1], 580);
const resetRegion = ids['slide-animation-reset-region']; const resetRegion = ids['slide-animation-reset-region'];
+13
View File
@@ -610,3 +610,16 @@ test('template animation preview applies configured delays to the CSS animation
context.applyAnimateCssPreview(rect, 'fadeOut', { duration_ms: 1000, delay_ms: 425 }, 'out'); context.applyAnimateCssPreview(rect, 'fadeOut', { duration_ms: 1000, delay_ms: 425 }, 'out');
assert.equal(values['animation-delay'], undefined); assert.equal(values['animation-delay'], undefined);
}); });
test('template outro preview aligns regions using computed CSS animation duration', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
assert.match(script, /getComputedAnimationDurationMs\(rect, durationMs\) \* iterations/);
assert.match(script, /mode === 'out' \? Math\.max\(0, longestTimingMs - entry\.runtimeMs\) : entry\.delay/);
assert.match(script, /Number\(rect\.getAttribute\('data-index'\)\)/);
const helper = script.match(/ function getComputedAnimationDurationMs\([^]*?\n }/);
assert.ok(helper);
const context = vm.createContext({ window: { getComputedStyle() { return { animationDuration: '1s, 2.5s' }; } } });
vm.runInContext(helper[0] + '\nthis.getComputedAnimationDurationMs = getComputedAnimationDurationMs;', context);
assert.equal(context.getComputedAnimationDurationMs({}, 900), 2500);
});