From 24a566a1a4ce3a2cf6aa7ac4e052997fc0519da9 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Mon, 26 Oct 2026 23:13:11 +0000 Subject: [PATCH] Align outro animations at slide end --- CHANGELOG.md | 2 +- .../public/js/player-page-transition.js | 14 +++- .../js/slides/slide-animation-editor.js | 15 +++- .../public/js/templates/template-designer.js | 77 +++++++++---------- test/player-page-playback.test.js | 6 +- test/region-layout.test.js | 10 ++- test/templates.test.js | 13 ++++ 7 files changed, 88 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5769a..ff1adb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### 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 diff --git a/src/player/public/js/player-page-transition.js b/src/player/public/js/player-page-transition.js index cecc2cc..4900967 100644 --- a/src/player/public/js/player-page-transition.js +++ b/src/player/public/js/player-page-transition.js @@ -5,6 +5,7 @@ var slideTransitionRequestId = 0; var playerWatchdogTimer = null; var playerLastProgressAt = Date.now(); var playerWatchdogRecoveryKey = 'pulse-signage-player-watchdog-recoveries'; +var slideOutroFrameBufferMs = 50; function markPlayerPlaybackProgress() { playerLastProgressAt = Date.now(); @@ -89,18 +90,22 @@ function scheduleSlideOutro(holdDelayMs) { var currentRoot = getCurrentSlideRoot(); if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') { - return; + return Math.max(1, Math.round(Number(holdDelayMs || 0))); } var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro'); if (!regionTimings.length) { - return; + return 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) { 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 () { if (!entry || !entry.element || !entry.element.isConnected) { return; @@ -108,14 +113,15 @@ function scheduleSlideOutro(holdDelayMs) { playRegionAnimation(entry.element, 'outro'); }, triggerDelayMs); }); + return transitionDelayMs; } // Schedule the next slide transition. function scheduleSlideAdvance(delayMs) { clearSlideTimer(); var holdDelayMs = Math.max(1, Number(delayMs || 0)); + holdDelayMs = scheduleSlideOutro(holdDelayMs); slideExpiresAt = Date.now() + holdDelayMs; - scheduleSlideOutro(holdDelayMs); timer = window.setTimeout(function () { timer = null; slideExpiresAt = null; diff --git a/src/web/public/js/slides/slide-animation-editor.js b/src/web/public/js/slides/slide-animation-editor.js index 27cb9fe..4b9489c 100644 --- a/src/web/public/js/slides/slide-animation-editor.js +++ b/src/web/public/js/slides/slide-animation-editor.js @@ -179,6 +179,7 @@ export function createSlideAnimationEditor(options) { previewMode = mode; var token = previewToken; var longest = 0; + var pending = []; getPreviewElements().forEach(function (element) { var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(element.dataset.regionKey)); var step = config[mode] || {}; @@ -196,7 +197,19 @@ export function createSlideAnimationEditor(options) { void element.offsetWidth; element.classList.add('animate__animated', 'animate__' + preset); 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(); previewTimer = window.setTimeout(function () { diff --git a/src/web/public/js/templates/template-designer.js b/src/web/public/js/templates/template-designer.js index e635615..7a52474 100644 --- a/src/web/public/js/templates/template-designer.js +++ b/src/web/public/js/templates/template-designer.js @@ -364,6 +364,7 @@ rect.style.removeProperty('--animate-delay'); rect.style.removeProperty('--animate-repeat'); rect.style.removeProperty('animation-delay'); + rect.style.removeProperty('animation-play-state'); rect.style.opacity = ''; rect.style.transform = ''; rect.style.filter = ''; @@ -469,30 +470,15 @@ var MAX_ANIMATION_REPEAT_COUNT = 999; - function getPreviewAnimationStepTimingMs(step, mode, animateCssMode) { - var normalizedMode = String(mode || '').trim().toLowerCase(); - var durationMs = Number(step && step.duration_ms); - var delayMs = Number(step && step.delay_ms); - var iterations = Number(step && step.iterations); - - if (!Number.isFinite(durationMs) || durationMs <= 0) { - durationMs = animateCssMode ? 1000 : 900; - } - 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 getComputedAnimationDurationMs(element, fallbackDurationMs) { + if (typeof window.getComputedStyle !== 'function') return fallbackDurationMs; + var durations = String(window.getComputedStyle(element).animationDuration || '').split(','); + return durations.reduce(function (longest, value) { + var match = value.trim().match(/^([\d.]+)\s*(ms|s)$/i); + if (!match) return longest; + var duration = Number(match[1]) * (match[2].toLowerCase() === 's' ? 1000 : 1); + return Number.isFinite(duration) ? Math.max(longest, duration) : longest; + }, 0) || fallbackDurationMs; } function normalizeAnimationStep(value, fallbackPreset) { @@ -708,42 +694,55 @@ function animatePreviewRects(mode, regions) { var rects = getPreviewRects(); var token = previewState.token; - var duration = 900; + var fallbackDuration = 900; var overlayRect = getOverlayRect(); var canvasMovement = Math.max(12, Math.round(overlayRect.height * 0.02)); var longestTimingMs = 0; + var pending = []; previewState.animations = []; 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 preset = normalizePreviewAnimation(step && step.preset); + if (preset === 'none') return; var delay = mode === 'out' ? 0 : Math.max(0, Number(step && step.delay_ms) || 0); - var animation = null; 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'; - - if (timingMs > longestTimingMs) { - longestTimingMs = timingMs; - } - - if (preset === 'none') { - return; - } - if (animateCssMode) { + rect.style.setProperty('animation-play-state', 'paused'); 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; } + + var animation = null; if (mode === 'intro') { animation = rect.animate([ { 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)' } ], { - duration: duration, + duration: entry.runtimeMs, delay: delay, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'both' @@ -753,7 +752,7 @@ { 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)' } ], { - duration: duration, + duration: entry.runtimeMs, delay: delay, easing: 'cubic-bezier(0.4, 0, 1, 1)', fill: 'both' diff --git a/test/player-page-playback.test.js b/test/player-page-playback.test.js index 5a11abb..fa56f68 100644 --- a/test/player-page-playback.test.js +++ b/test/player-page-playback.test.js @@ -1256,8 +1256,12 @@ test('player applies animation delays and schedules outro triggers to finish at assert.equal(values['animation-delay'], '250ms'); sandbox.scheduleSlideOutro(5000); - assert.equal(timers[0].delay, 3200); + assert.equal(timers[0].delay, 3150); timers[0].callback(); assert.equal(values['--animate-duration'], '700ms'); assert.equal(values['animation-delay'], undefined); + + timers.length = 0; + assert.equal(sandbox.scheduleSlideOutro(1000), 1850); + assert.equal(timers[0].delay, 0); }); \ No newline at end of file diff --git a/test/region-layout.test.js b/test/region-layout.test.js index faf0292..055be3c 100644 --- a/test/region-layout.test.js +++ b/test/region-layout.test.js @@ -386,8 +386,8 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par const previewTimers = []; const context = { window: { innerWidth: 1200, setTimeout(_callback, delay) { previewTimers.push(delay); return previewTimers.length; }, clearTimeout() {}, - getComputedStyle() { - return { animationDuration: '400ms' }; + getComputedStyle(element) { + return { animationDuration: element.dataset.regionKey === 'fixed_brand' ? '400ms' : '100ms' }; }, pulseModal: { 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 }, 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() {}, 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[1].handlers.click(); assert.equal(readonlyPreviewRegion.style.values['animation-delay'], '0ms'); + assert.equal(secondPreviewRegion.style.values['animation-delay'], '300ms'); animationPreviewButtons[1].handlers.click(); assert.equal(previewTimers[previewTimers.length - 1], 580); const resetRegion = ids['slide-animation-reset-region']; diff --git a/test/templates.test.js b/test/templates.test.js index 5c71b03..968732c 100644 --- a/test/templates.test.js +++ b/test/templates.test.js @@ -609,4 +609,17 @@ test('template animation preview applies configured delays to the CSS animation assert.equal(values['animation-delay'], '425ms'); context.applyAnimateCssPreview(rect, 'fadeOut', { duration_ms: 1000, delay_ms: 425 }, 'out'); 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); }); \ No newline at end of file