From b5301f3f164304721b60cf93a11ee49e2efa248f Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Mon, 26 Oct 2026 22:54:17 +0000 Subject: [PATCH] Fix automatic animation duration timing --- CHANGELOG.md | 2 +- src/player/public/js/player-page-animation.js | 45 ++++++++++++++++++- test/player-page-playback.test.js | 37 ++++++++++++++- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bfafa8..bbbe6b2 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 is configured as time before transition, 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 timing follows each preset's computed Animate.css duration, is configured as time before transition, and preview-only region staggering is removed; 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-animation.js b/src/player/public/js/player-page-animation.js index 085e93b..9cb9c6f 100644 --- a/src/player/public/js/player-page-animation.js +++ b/src/player/public/js/player-page-animation.js @@ -1,5 +1,7 @@ // Region animation configuration and lifecycle helpers. +var playerAnimationDurationMultiplierCache = Object.create(null); + function normalizePlayerAnimationConfig(value) { var raw = value; if (typeof raw === 'string') { @@ -44,6 +46,46 @@ function normalizePlayerAnimationStep(value, fallbackPreset) { }; } +function parseAnimationDurationMs(value) { + var match = String(value || '').trim().match(/^([\d.]+)\s*(ms|s)$/i); + if (!match) { + return 0; + } + var duration = Number(match[1]); + return Number.isFinite(duration) ? duration * (match[2].toLowerCase() === 's' ? 1000 : 1) : 0; +} + +function getAnimationDurationMultiplier(preset) { + if (Object.prototype.hasOwnProperty.call(playerAnimationDurationMultiplierCache, preset)) { + return playerAnimationDurationMultiplierCache[preset]; + } + if (typeof document === 'undefined' || !document.body || typeof window.getComputedStyle !== 'function') { + return 1; + } + + var probe = null; + try { + probe = document.createElement('div'); + probe.classList.add('animate__animated', 'animate__' + preset); + probe.style.setProperty('--animate-duration', '1000ms'); + probe.style.setProperty('animation-play-state', 'paused'); + probe.style.setProperty('visibility', 'hidden'); + document.body.appendChild(probe); + var durationMs = parseAnimationDurationMs(window.getComputedStyle(probe).animationDuration); + if (durationMs > 0) { + playerAnimationDurationMultiplierCache[preset] = durationMs / 1000; + } + } catch (_error) { + return 1; + } finally { + if (probe && probe.parentNode) { + probe.parentNode.removeChild(probe); + } + } + + return playerAnimationDurationMultiplierCache[preset] || 1; +} + function getAnimationStepTimingMs(step) { if (!step) { return 0; @@ -69,7 +111,8 @@ function getAnimationStepTimingMs(step) { iterations = 1; } - return delayMs + (durationMs * iterations); + var durationMultiplier = getAnimationDurationMultiplier(preset); + return delayMs + (durationMs * iterations * durationMultiplier); } function getRegionAnimationPhaseTimings(root, phase) { diff --git a/test/player-page-playback.test.js b/test/player-page-playback.test.js index cf6ab57..5a11abb 100644 --- a/test/player-page-playback.test.js +++ b/test/player-page-playback.test.js @@ -1190,7 +1190,7 @@ test('player applies animation delays and schedules outro triggers to finish at const classes = new Set(); const config = JSON.stringify({ intro: { preset: 'fadeIn', duration_ms: 600, delay_ms: 250 }, - outro: { preset: 'fadeOut', duration_ms: 700, delay_ms: 400 } + outro: { preset: 'hinge', duration_ms: 700, delay_ms: 400 } }); const element = { dataset: {}, @@ -1211,20 +1211,53 @@ test('player applies animation delays and schedules outro triggers to finish at }; const root = { querySelectorAll() { return [element]; } }; const timers = []; + const presetDurations = { + bounceIn: '750ms', + flipOutY: '750ms', + heartBeat: '1.3s', + hinge: '2s', + futurePreset: '1.7s' + }; const sandbox = { window: null, app: { querySelectorAll() { return [root]; }, firstElementChild: root }, + document: { + body: { + appendChild(probe) { probe.parentNode = this; }, + removeChild(probe) { probe.parentNode = null; } + }, + createElement() { + const probeClasses = new Set(); + return { + parentNode: null, + classList: { add(...names) { names.forEach(name => probeClasses.add(name)); } }, + style: { setProperty() {} }, + classes: probeClasses + }; + } + }, + getComputedStyle(probe) { + const presetClass = Array.from(probe.classes).find(name => name.indexOf('animate__') === 0 && name !== 'animate__animated'); + return { animationDuration: presetDurations[presetClass.replace('animate__', '')] || '1s' }; + }, setTimeout(callback, delay) { timers.push({ callback, delay }); return timers.length; }, clearTimeout() {} }; sandbox.window = sandbox; vm.runInNewContext(fs.readFileSync(animationPath, 'utf8') + '\n' + fs.readFileSync(transitionPath, 'utf8'), sandbox); + assert.equal(sandbox.getAnimationStepTimingMs({ preset: 'bounceIn', duration_ms: 1000 }), 750); + assert.equal(sandbox.getAnimationStepTimingMs({ preset: 'flipOutY', duration_ms: 1000 }), 750); + assert.equal(sandbox.getAnimationStepTimingMs({ preset: 'heartBeat', duration_ms: 1000 }), 1300); + assert.equal(sandbox.getAnimationStepTimingMs({ preset: 'hinge', duration_ms: 1000 }), 2000); + assert.equal(sandbox.getAnimationStepTimingMs({ preset: 'futurePreset', duration_ms: 1000 }), 1700); + sandbox.playRegionAnimation(element, 'intro'); assert.equal(values['animation-delay'], '250ms'); sandbox.scheduleSlideOutro(5000); - assert.equal(timers[0].delay, 3900); + assert.equal(timers[0].delay, 3200); timers[0].callback(); + assert.equal(values['--animate-duration'], '700ms'); assert.equal(values['animation-delay'], undefined); }); \ No newline at end of file