diff --git a/examples/tests/animation-sequence.ts b/examples/tests/animation-sequence.ts new file mode 100644 index 000000000..135b76bec --- /dev/null +++ b/examples/tests/animation-sequence.ts @@ -0,0 +1,206 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Visual Regression Test: animationSequence() + * + * Exercises the L2-inspired animation sequence API introduced in L3: + * + * Scenario 1 — single step, two keyframes (x slide) + * Scenario 2 — single step, three keyframes (bounce path) + * Scenario 3 — two-step sequence (slide then fade) + * Scenario 4 — step with repeat: 1 (plays twice) + * Scenario 5 — pause() mid-sequence then restore() + * Scenario 6 — stop() mid-sequence + */ + +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export async function automation(settings: ExampleSettings) { + await test(settings); +} + +/** Wait for the 'stopped' event on an animation controller. */ +function waitForStopped(seqCtrl: { + once(event: 'stopped', cb: () => void): void; +}): Promise { + return new Promise((resolve) => seqCtrl.once('stopped', resolve)); +} + +export default async function test({ + renderer, + testRoot, + snapshot, +}: ExampleSettings) { + // Shared canvas layout + testRoot.w = 780; + testRoot.h = 660; + testRoot.color = 0x111111ff; + + // ── helpers ─────────────────────────────────────────────────────────────── + + // Label at bottom-left + const label = renderer.createTextNode({ + parent: testRoot, + x: 20, + y: testRoot.h - 48, + fontSize: 28, + fontFamily: 'Ubuntu', + color: 0xffffffff, + text: '', + }); + + /** Create a coloured square used as the animated subject. */ + function makeBox(x: number, y: number, color: number) { + return renderer.createNode({ + x, + y, + w: 100, + h: 100, + color, + parent: testRoot, + }); + } + + // ── Scenario 1: single step — two keyframes (x slide) ──────────────────── + label.text = 'S1 — single step, 2 keyframes: initial'; + const s1box = makeBox(40, 40, 0x00aaffff); + await snapshot({ name: 's1-initial' }); + + const s1ctrl = s1box.animationSequence({ + duration: 600, + actions: [{ p: 'x', v: { '0': 40, '1': 640 } }], + }); + + const s1done = waitForStopped(s1ctrl); + s1ctrl.start(); + await s1done; + + label.text = 'S1 — complete (x=640)'; + await snapshot({ name: 's1-end' }); + + // ── Scenario 2: single step — three keyframes (bounce) ─────────────────── + label.text = 'S2 — single step, 3 keyframes: initial'; + const s2box = makeBox(40, 160, 0xff6600ff); + await snapshot({ name: 's2-initial' }); + + const s2ctrl = s2box.animationSequence({ + duration: 900, + actions: [{ p: 'x', v: { '0': 40, '0.5': 640, '1': 40 } }], + }); + + // Hide during flight; snapshot the end state (returned to start) + const s2done = waitForStopped(s2ctrl); + s2ctrl.start(); + await s2done; + + label.text = 'S2 — complete (x=40, bounced back)'; + await snapshot({ name: 's2-end' }); + + // ── Scenario 3: two-step sequence (slide then fade) ─────────────────────── + label.text = 'S3 — 2-step sequence: initial'; + const s3box = makeBox(40, 280, 0x00cc44ff); + await snapshot({ name: 's3-initial' }); + + const s3ctrl = s3box.animationSequence([ + { + duration: 400, + actions: [{ p: 'x', v: { '0': 40, '1': 640 } }], + }, + { + duration: 400, + actions: [{ p: 'alpha', v: { '0': 1, '1': 0 } }], + }, + ]); + + const s3done = waitForStopped(s3ctrl); + s3ctrl.start(); + await s3done; + + label.text = 'S3 — complete (x=640, alpha=0)'; + await snapshot({ name: 's3-end' }); + + // ── Scenario 4: step with repeat: 1 (plays twice) ───────────────────────── + label.text = 'S4 — repeat:1 step: initial'; + const s4box = makeBox(40, 400, 0xffcc00ff); + await snapshot({ name: 's4-initial' }); + + const s4ctrl = s4box.animationSequence({ + duration: 300, + repeat: 1, // plays twice total + actions: [{ p: 'x', v: { '0': 40, '1': 340 } }], + }); + + const s4done = waitForStopped(s4ctrl); + s4ctrl.start(); + + await s4done; + label.text = 'S4 — complete (x=340, 2 plays done)'; + await snapshot({ name: 's4-end' }); + + // ── Scenario 5: pause() then restore() ───────────────────────────────── + // Deterministic: pause() is called synchronously before any render frame + // advances, so the box stays at its start position (x=40). restore() then + // writes the captured start values back — also x=40. Both snapshots are + // stable regardless of CI machine speed. + label.text = 'S5 — pause + restore: initial'; + const s5box = makeBox(40, 520, 0xcc44ffff); + await snapshot({ name: 's5-initial' }); + + const s5ctrl = s5box.animationSequence({ + duration: 600, + actions: [{ p: 'x', v: { '0': 40, '1': 640 } }], + }); + + // Pause before any frame runs — box stays at x=40. + s5ctrl.start(); + s5ctrl.pause(); + label.text = 'S5 — paused (x=40, no frames advanced)'; + await snapshot({ name: 's5-paused' }); + + // restore() writes captured start values back. + s5ctrl.restore(); + label.text = 'S5 — restored (x=40)'; + await snapshot({ name: 's5-restored' }); + + // ── Scenario 6: stop() before any frame ────────────────────────────────── + // Deterministic: stop() is called synchronously after start() so no render + // frame runs. The box stays at its initial position (x=40). + label.text = 'S6 — stop before first frame: initial'; + const s6box = makeBox(40, 40, 0xff4444ff); + await snapshot({ name: 's6-initial' }); + + const s6ctrl = s6box.animationSequence([ + { + duration: 600, + actions: [{ p: 'x', v: { '0': 40, '1': 640 } }], + }, + { + duration: 400, + actions: [{ p: 'alpha', v: { '0': 1, '1': 0 } }], + }, + ]); + + // Stop immediately — no frames advance, x remains 40. + s6ctrl.start(); + s6ctrl.stop(); + + label.text = 'S6 — stopped mid-step-1'; + await snapshot({ name: 's6-stopped' }); +} diff --git a/exports/index.ts b/exports/index.ts index 0dbde5a46..b9f0872f2 100644 --- a/exports/index.ts +++ b/exports/index.ts @@ -57,9 +57,23 @@ export type { TextRenderer } from '../src/core/text-rendering/TextRenderer.js'; export type { MemoryInfo } from '../src/core/TextureMemoryManager.js'; export type { AnimationSettings } from '../src/core/animations/CoreAnimation.js'; export type { TimingFunction } from '../src/core/utils.js'; +export type { + AnimationKeyframeValue, + AnimationKeyframes, + AnimationAction, + AnimationSequenceStep, + AnimationSequenceSettings, +} from '../src/common/AnimationSequenceTypes.js'; export type { Inspector } from '../src/main-api/Inspector.js'; -export { CoreNode, type CoreNodeProps, type CoreNodeRenderState } from '../src/core/CoreNode.js'; -export { CoreTextNode, type CoreTextNodeProps } from '../src/core/CoreTextNode.js'; +export { + CoreNode, + type CoreNodeProps, + type CoreNodeRenderState, +} from '../src/core/CoreNode.js'; +export { + CoreTextNode, + type CoreTextNodeProps, +} from '../src/core/CoreTextNode.js'; export * from '../src/core/renderers/CoreShaderNode.js'; export * from '../src/core/shaders/templates/BorderTemplate.js'; diff --git a/src/common/AnimationSequenceTypes.ts b/src/common/AnimationSequenceTypes.ts new file mode 100644 index 000000000..974a872bf --- /dev/null +++ b/src/common/AnimationSequenceTypes.ts @@ -0,0 +1,100 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { TimingFunction } from '../core/utils.js'; + +/** + * A keyframe value — either a plain number or a value+smoothing pair. + * + * @remarks + * The `s` field is a smoothness scalar (0–1) applied as a cubic-bezier + * tangent weight, matching Lightning 2's `{v, s}` shorthand. + */ +export type AnimationKeyframeValue = number | { v: number; s?: number }; + +/** + * A per-property keyframe map. + * + * @remarks + * Keys are normalised progress fractions (0–1), e.g.: + * ```ts + * { 0: 0, 0.5: 50, 1: 100 } + * ``` + * At least a `0` and `1` entry should be provided. + */ +export type AnimationKeyframes = Record; + +/** + * A single property animation action, modelled after Lightning 2's action + * objects. + * + * @example + * ```ts + * { p: 'x', v: { 0: 0, 0.5: 300, 1: 600 } } + * ``` + */ +export interface AnimationAction { + /** The name of the property to animate. */ + p: string; + /** Keyframe map for this property. */ + v: AnimationKeyframes; +} + +/** + * Configuration for a single step in an {@link AnimationSequenceSettings}. + * + * @remarks + * A step is conceptually equivalent to one Lightning 2 `animation()` call. + * Multiple steps are played end-to-end, forming a sequence. + * + * Duration is expressed in **milliseconds** (the L3 convention). + */ +export interface AnimationSequenceStep { + /** Duration of this step in milliseconds. */ + duration: number; + /** Delay before this step starts in milliseconds. */ + delay?: number; + /** + * Number of additional repetitions of this step. + * - `0` — plays once (default) + * - `-1` — loops forever (next step will never play) + * - `N` — plays N+1 times total + */ + repeat?: number; + /** Whether to loop this step indefinitely (alias for `repeat: -1`). */ + loop?: boolean; + /** + * How the step behaves when it stops. + * - `false` — stay at the final value (default) + * - `'reverse'` — animate back to the start value before completing + * - `'reset'` — snap back to the start value when done + */ + stopMethod?: 'reverse' | 'reset' | false; + /** Easing function or name to apply to all actions in this step. */ + easing?: string | TimingFunction; + /** The property animations to perform in this step. */ + actions: AnimationAction[]; +} + +/** + * Sequence of one or more animation steps to play end-to-end. + */ +export type AnimationSequenceSettings = + | AnimationSequenceStep + | AnimationSequenceStep[]; diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 363666666..84e70762c 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -59,6 +59,8 @@ import type { AnimationSettings } from './animations/CoreAnimation.js'; import type { IAnimationController } from '../common/IAnimationController.js'; import { CoreAnimation } from './animations/CoreAnimation.js'; import { CoreAnimationController } from './animations/CoreAnimationController.js'; +import { CoreAnimationSequenceController } from './animations/CoreAnimationSequenceController.js'; +import type { AnimationSequenceSettings } from '../common/AnimationSequenceTypes.js'; import type { CoreShaderNode } from './renderers/CoreShaderNode.js'; import { AutosizeMode, Autosizer } from './Autosizer.js'; import { bucketSortByZIndex, removeChild } from './lib/collectionUtils.js'; @@ -2710,6 +2712,14 @@ export class CoreNode extends EventEmitter { return controller; } + animationSequence(settings: AnimationSequenceSettings): IAnimationController { + return new CoreAnimationSequenceController( + this.stage.animationManager, + this, + settings, + ); + } + flush() { // no-op } diff --git a/src/core/animations/AnimationManager.test.ts b/src/core/animations/AnimationManager.test.ts new file mode 100644 index 000000000..f8ca5da09 --- /dev/null +++ b/src/core/animations/AnimationManager.test.ts @@ -0,0 +1,88 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AnimationManager } from './AnimationManager.js'; +import { CoreAnimation } from './CoreAnimation.js'; +import type { CoreNode } from '../CoreNode.js'; + +function makeNode(): CoreNode { + return { destroyed: false, x: 0, shader: null } as unknown as CoreNode; +} + +function makeAnim(duration = 1000) { + return new CoreAnimation(makeNode(), { x: 100 }, { duration }); +} + +describe('AnimationManager', () => { + describe('registerAnimation()', () => { + it('adds the animation to activeAnimations', () => { + const manager = new AnimationManager(); + const anim = makeAnim(); + manager.registerAnimation(anim); + expect(manager.activeAnimations.has(anim)).toBe(true); + }); + }); + + describe('unregisterAnimation()', () => { + it('removes the animation from activeAnimations', () => { + const manager = new AnimationManager(); + const anim = makeAnim(); + manager.registerAnimation(anim); + manager.unregisterAnimation(anim); + expect(manager.activeAnimations.has(anim)).toBe(false); + }); + + it('does not throw when unregistering an unknown animation', () => { + const manager = new AnimationManager(); + const anim = makeAnim(); + expect(() => manager.unregisterAnimation(anim)).not.toThrow(); + }); + }); + + describe('update()', () => { + it('calls update(dt) on every active animation', () => { + const manager = new AnimationManager(); + const a1 = makeAnim(); + const a2 = makeAnim(); + const spy1 = vi.spyOn(a1, 'update'); + const spy2 = vi.spyOn(a2, 'update'); + manager.registerAnimation(a1); + manager.registerAnimation(a2); + manager.update(16); + expect(spy1).toHaveBeenCalledWith(16); + expect(spy2).toHaveBeenCalledWith(16); + }); + + it('does not call update on unregistered animations', () => { + const manager = new AnimationManager(); + const anim = makeAnim(); + const spy = vi.spyOn(anim, 'update'); + manager.registerAnimation(anim); + manager.unregisterAnimation(anim); + manager.update(16); + expect(spy).not.toHaveBeenCalled(); + }); + + it('handles an empty activeAnimations set without error', () => { + const manager = new AnimationManager(); + expect(() => manager.update(16)).not.toThrow(); + }); + }); +}); diff --git a/src/core/animations/AnimationManager.ts b/src/core/animations/AnimationManager.ts index 102bca916..f630a24be 100644 --- a/src/core/animations/AnimationManager.ts +++ b/src/core/animations/AnimationManager.ts @@ -19,14 +19,23 @@ import { CoreAnimation } from './CoreAnimation.js'; +/** + * Minimal interface required by AnimationManager. + * Both CoreAnimation and CoreAnimationSequenceController's internal runners + * implement this interface. + */ +export interface IAnimatable { + update(dt: number): void; +} + export class AnimationManager { - activeAnimations: Set = new Set(); + activeAnimations: Set = new Set(); - registerAnimation(animation: CoreAnimation) { + registerAnimation(animation: IAnimatable) { this.activeAnimations.add(animation); } - unregisterAnimation(animation: CoreAnimation) { + unregisterAnimation(animation: IAnimatable) { this.activeAnimations.delete(animation); } @@ -36,3 +45,7 @@ export class AnimationManager { }); } } + +// Keep backward-compat type alias so existing code compiled against +// CoreAnimation stays working. +export type { CoreAnimation }; diff --git a/src/core/animations/CoreAnimation.test.ts b/src/core/animations/CoreAnimation.test.ts new file mode 100644 index 000000000..d24874df4 --- /dev/null +++ b/src/core/animations/CoreAnimation.test.ts @@ -0,0 +1,276 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CoreAnimation } from './CoreAnimation.js'; +import type { CoreNode } from '../CoreNode.js'; + +/** + * Build a minimal CoreNode-like mock. We only need the props + * that CoreAnimation reads/writes (numeric node properties + destroyed flag). + */ +function makeNode(overrides: Record = {}): CoreNode { + return { + destroyed: false, + x: 0, + y: 0, + w: 100, + h: 100, + alpha: 1, + rotation: 0, + scaleX: 1, + scaleY: 1, + mountX: 0, + mountY: 0, + pivotX: 0.5, + pivotY: 0.5, + color: 0xffffffff, + colorTl: 0xffffffff, + colorTr: 0xffffffff, + colorBl: 0xffffffff, + colorBr: 0xffffffff, + shader: null, + ...overrides, + } as unknown as CoreNode; +} + +describe('CoreAnimation', () => { + describe('constructor', () => { + it('captures start values from the node', () => { + const node = makeNode({ x: 50, alpha: 0.5 }); + const anim = new CoreAnimation( + node, + { x: 200, alpha: 1 }, + { duration: 1000 }, + ); + expect(anim.propValuesMap['props']!['x']!.start).toBe(50); + expect(anim.propValuesMap['props']!['alpha']!.start).toBe(0.5); + }); + + it('uses supplied target values', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation(node, { x: 300 }, { duration: 500 }); + expect(anim.propValuesMap['props']!['x']!.target).toBe(300); + }); + + it('applies default animation settings', () => { + const node = makeNode(); + const anim = new CoreAnimation(node, { x: 100 }, {}); + expect(anim.settings.duration).toBe(0); + expect(anim.settings.delay).toBe(0); + expect(anim.settings.easing).toBe('linear'); + expect(anim.settings.loop).toBe(false); + expect(anim.settings.repeat).toBe(0); + expect(anim.settings.stopMethod).toBe(false); + }); + }); + + describe('update()', () => { + it('emits "finished" immediately when duration and delay are 0', () => { + const node = makeNode(); + const anim = new CoreAnimation(node, { x: 100 }, { duration: 0 }); + const cb = vi.fn(); + anim.once('finished', cb); + anim.update(16); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('counts down the delay before animating', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation( + node, + { x: 100 }, + { duration: 1000, delay: 500 }, + ); + // First tick: still in delay phase + anim.update(400); + expect(node.x).toBe(0); + // Second tick: exceeds delay, partial progress + anim.update(200); + expect(node.x).toBeGreaterThan(0); + }); + + it('emits "animating" once after delay phase', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation( + node, + { x: 100 }, + { duration: 1000, delay: 100 }, + ); + const cb = vi.fn(); + anim.on('animating', cb); + anim.update(200); // passes delay, starts animating + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('advances node property in proportion to elapsed time', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation(node, { x: 100 }, { duration: 1000 }); + anim.update(500); // 50% progress + expect(node.x).toBeCloseTo(50); + }); + + it('clamps progress to 1 and emits "finished"', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation(node, { x: 100 }, { duration: 1000 }); + const cb = vi.fn(); + anim.once('finished', cb); + anim.update(1200); // beyond duration + expect(node.x).toBe(100); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('loops when loop: true — resets progress to 0 on overflow', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation( + node, + { x: 100 }, + { duration: 1000, loop: true }, + ); + anim.update(1100); // complete one cycle — progress wraps back to 0 + // CoreAnimation resets progress=0 on loop, so updateValues(0) → start value + expect(node.x).toBe(0); + }); + + it('emits "tick" with progress on intermediate frames', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation(node, { x: 100 }, { duration: 1000 }); + const ticks: number[] = []; + anim.on('tick', (_: unknown, data: { progress: number }) => { + ticks.push(data.progress); + }); + anim.update(300); + anim.update(300); + expect(ticks.length).toBe(2); + expect(ticks[0]).toBeCloseTo(0.3); + expect(ticks[1]).toBeCloseTo(0.6); + }); + + it('emits "destroyed" and stops when node.destroyed is true', () => { + const node = makeNode({ destroyed: true }); + const anim = new CoreAnimation(node, { x: 100 }, { duration: 1000 }); + const cb = vi.fn(); + anim.once('destroyed', cb); + anim.update(16); + expect(cb).toHaveBeenCalledTimes(1); + }); + }); + + describe('reset()', () => { + it('resets progress to 0 and re-applies delay', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation( + node, + { x: 100 }, + { duration: 1000, delay: 200 }, + ); + anim.update(500); // 50% through, x ~= 30 (300ms into 1000ms after 200ms delay) + const xBeforeReset = node.x; + expect(xBeforeReset).toBeGreaterThan(0); + anim.reset(); + // After reset, delay is restored — a small tick within the delay window + // should not change x (the node retains its current value; reset only + // resets internal animation progress/delay state, not node values). + anim.update(10); // still within the 200ms delay + expect(node.x).toBe(xBeforeReset); + }); + }); + + describe('restore()', () => { + it('writes original start values back to the node', () => { + const node = makeNode({ x: 50, alpha: 0.5 }); + const anim = new CoreAnimation( + node, + { x: 200, alpha: 1 }, + { duration: 1000 }, + ); + anim.update(600); // half-way through + expect(node.x).toBeGreaterThan(50); + anim.restore(); + expect(node.x).toBe(50); + expect(node.alpha).toBe(0.5); + }); + }); + + describe('reverse()', () => { + it('swaps start and target values', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation(node, { x: 100 }, { duration: 1000 }); + anim.reverse(); + expect(anim.propValuesMap['props']!['x']!.start).toBe(100); + expect(anim.propValuesMap['props']!['x']!.target).toBe(0); + }); + + it('clears stopMethod after reversing (non-loop)', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation( + node, + { x: 100 }, + { duration: 1000, stopMethod: 'reverse' }, + ); + anim.reverse(); + expect(anim.settings.stopMethod).toBe(false); + }); + }); + + describe('stopMethod: "reverse"', () => { + it('emits "finished" when progress completes (triggering reverse path in controller)', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation( + node, + { x: 100 }, + { duration: 1000, stopMethod: 'reverse' }, + ); + const cb = vi.fn(); + anim.once('finished', cb); + anim.update(1100); + expect(cb).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateValue()', () => { + it('returns start value at progress 0', () => { + const node = makeNode({ x: 10 }); + const anim = new CoreAnimation(node, { x: 100 }, { duration: 1000 }); + const result = anim.updateValue('x', 100, 10, 'linear'); + expect(result).toBe(10); + }); + + it('returns target value at progress 1', () => { + const node = makeNode({ x: 0 }); + const anim = new CoreAnimation(node, { x: 100 }, { duration: 1000 }); + anim.update(1000); + const result = anim.updateValue('x', 100, 0, 'linear'); + expect(result).toBe(100); + }); + + it('interpolates color values using mergeColorProgress', () => { + const node = makeNode({ colorTl: 0xff0000ff }); + const anim = new CoreAnimation( + node, + { colorTl: 0x0000ffff }, + { duration: 1000 }, + ); + anim.update(500); // 50% + // Result should be neither start nor end color + expect(node.colorTl).not.toBe(0xff0000ff); + expect(node.colorTl).not.toBe(0x0000ffff); + }); + }); +}); diff --git a/src/core/animations/CoreAnimationController.test.ts b/src/core/animations/CoreAnimationController.test.ts new file mode 100644 index 000000000..03c107de7 --- /dev/null +++ b/src/core/animations/CoreAnimationController.test.ts @@ -0,0 +1,223 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CoreAnimationController } from './CoreAnimationController.js'; +import { CoreAnimation } from './CoreAnimation.js'; +import { AnimationManager } from './AnimationManager.js'; +import type { CoreNode } from '../CoreNode.js'; + +function makeNode(): CoreNode { + return { + destroyed: false, + x: 0, + y: 0, + alpha: 1, + shader: null, + } as unknown as CoreNode; +} + +function makeController(nodeOverrides?: Record): { + controller: CoreAnimationController; + animation: CoreAnimation; + manager: AnimationManager; +} { + const node = makeNode(); + if (nodeOverrides) { + Object.assign(node, nodeOverrides); + } + const animation = new CoreAnimation(node, { x: 100 }, { duration: 500 }); + const manager = new AnimationManager(); + const controller = new CoreAnimationController(manager, animation); + return { controller, animation, manager }; +} + +describe('CoreAnimationController', () => { + describe('initial state', () => { + it('starts in "stopped" state', () => { + const { controller } = makeController(); + expect(controller.state).toBe('stopped'); + }); + }); + + describe('start()', () => { + it('transitions to "scheduled" state', () => { + const { controller } = makeController(); + controller.start(); + expect(controller.state).toBe('scheduled'); + }); + + it('returns the controller (for chaining)', () => { + const { controller } = makeController(); + expect(controller.start()).toBe(controller); + }); + + it('registers the animation with the manager', () => { + const { controller, animation, manager } = makeController(); + controller.start(); + expect(manager.activeAnimations.has(animation)).toBe(true); + }); + + it('does not double-register if already scheduled', () => { + const { controller, manager } = makeController(); + controller.start(); + controller.start(); + // Still only one animation in the manager + expect(manager.activeAnimations.size).toBe(1); + }); + }); + + describe('state transition: scheduled → running', () => { + it('transitions to "running" when the animation emits "animating"', () => { + const { controller, manager } = makeController(); + controller.start(); + // No delay, so the first update drives the animating event + manager.update(16); + expect(controller.state).toBe('running'); + }); + + it('emits "animating" event on the controller', () => { + const { controller, manager } = makeController(); + const cb = vi.fn(); + controller.on('animating', cb); + controller.start(); + manager.update(16); + expect(cb).toHaveBeenCalledTimes(1); + }); + }); + + describe('pause()', () => { + it('transitions to "paused" and unregisters animation', () => { + const { controller, animation, manager } = makeController(); + controller.start(); + controller.pause(); + expect(controller.state).toBe('paused'); + expect(manager.activeAnimations.has(animation)).toBe(false); + }); + + it('returns the controller (for chaining)', () => { + const { controller } = makeController(); + controller.start(); + expect(controller.pause()).toBe(controller); + }); + + it('can be resumed with start()', () => { + const { controller, animation, manager } = makeController(); + controller.start(); + controller.pause(); + controller.start(); + expect(controller.state).toBe('scheduled'); + expect(manager.activeAnimations.has(animation)).toBe(true); + }); + }); + + describe('stop()', () => { + it('transitions to "stopped" state', () => { + const { controller } = makeController(); + controller.start(); + controller.stop(); + expect(controller.state).toBe('stopped'); + }); + + it('returns the controller (for chaining)', () => { + const { controller } = makeController(); + expect(controller.stop()).toBe(controller); + }); + + it('emits "stopped" event', () => { + const { controller } = makeController(); + const cb = vi.fn(); + controller.on('stopped', cb); + controller.start(); + controller.stop(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('unregisters the animation from the manager', () => { + const { controller, animation, manager } = makeController(); + controller.start(); + controller.stop(); + expect(manager.activeAnimations.has(animation)).toBe(false); + }); + }); + + describe('natural finish', () => { + it('transitions to "stopped" when the animation finishes', () => { + const { controller, manager } = makeController(); + controller.start(); + manager.update(1000); // well beyond 500ms duration + expect(controller.state).toBe('stopped'); + }); + + it('emits "stopped" on natural finish', () => { + const { controller, manager } = makeController(); + const cb = vi.fn(); + controller.on('stopped', cb); + controller.start(); + manager.update(1000); + expect(cb).toHaveBeenCalledTimes(1); + }); + }); + + describe('waitUntilStopped()', () => { + it('resolves when animation finishes naturally', async () => { + const { controller, manager } = makeController(); + controller.start(); + const promise = controller.waitUntilStopped(); + manager.update(1000); + await expect(promise).resolves.toBeUndefined(); + }); + + it('resolves when stop() is called', async () => { + const { controller } = makeController(); + controller.start(); + const promise = controller.waitUntilStopped(); + controller.stop(); + await expect(promise).resolves.toBeUndefined(); + }); + }); + + describe('restore()', () => { + it('returns the controller (for chaining)', () => { + const { controller } = makeController(); + expect(controller.restore()).toBe(controller); + }); + }); + + describe('stopMethod: "reverse"', () => { + it('reverses and continues instead of stopping on finish', () => { + const node = makeNode(); + const animation = new CoreAnimation( + node, + { x: 100 }, + { duration: 500, stopMethod: 'reverse' }, + ); + const manager = new AnimationManager(); + const controller = new CoreAnimationController(manager, animation); + const stoppedCb = vi.fn(); + controller.on('stopped', stoppedCb); + controller.start(); + // Complete the forward direction + manager.update(600); + // Controller should NOT have emitted stopped yet (it reversed) + expect(stoppedCb).not.toHaveBeenCalled(); + expect(controller.state).not.toBe('stopped'); + }); + }); +}); diff --git a/src/core/animations/CoreAnimationSequenceController.test.ts b/src/core/animations/CoreAnimationSequenceController.test.ts new file mode 100644 index 000000000..c435269a9 --- /dev/null +++ b/src/core/animations/CoreAnimationSequenceController.test.ts @@ -0,0 +1,492 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * TDD spec tests for the new animationSequence() API. + * + * These tests are written BEFORE the implementation, following TDD practice. + * All tests should be failing (red) until the implementation is complete. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { CoreAnimationSequenceController } from './CoreAnimationSequenceController.js'; +import { AnimationManager } from './AnimationManager.js'; +import type { AnimationSequenceStep } from '../../common/AnimationSequenceTypes.js'; +import type { CoreNode } from '../CoreNode.js'; + +function makeNode(overrides: Record = {}): CoreNode { + return { + destroyed: false, + x: 0, + y: 0, + alpha: 1, + scaleX: 1, + color: 0xffffffff, + shader: null, + ...overrides, + } as unknown as CoreNode; +} + +function makeStep( + overrides: Partial = {}, +): AnimationSequenceStep { + return { + duration: 1000, + actions: [{ p: 'x', v: { '0': 0, '1': 100 } }], + ...overrides, + }; +} + +function makeController( + node: CoreNode, + steps: AnimationSequenceStep | AnimationSequenceStep[], +) { + const manager = new AnimationManager(); + const controller = new CoreAnimationSequenceController(manager, node, steps); + return { controller, manager }; +} + +// --------------------------------------------------------------------------- +// 1. Construction +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — construction', () => { + it('returns an object with IAnimationController shape', () => { + const node = makeNode(); + const { controller } = makeController(node, makeStep()); + expect(typeof controller.start).toBe('function'); + expect(typeof controller.stop).toBe('function'); + expect(typeof controller.pause).toBe('function'); + expect(typeof controller.restore).toBe('function'); + expect(typeof controller.waitUntilStopped).toBe('function'); + expect(typeof controller.state).toBe('string'); + }); + + it('initial state is "stopped"', () => { + const { controller } = makeController(makeNode(), makeStep()); + expect(controller.state).toBe('stopped'); + }); + + it('accepts a single step (not wrapped in array)', () => { + const node = makeNode(); + expect(() => makeController(node, makeStep())).not.toThrow(); + }); + + it('accepts an array of steps', () => { + const node = makeNode(); + expect(() => makeController(node, [makeStep(), makeStep()])).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. start() / state transitions +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — start()', () => { + it('transitions state to "scheduled"', () => { + const { controller } = makeController(makeNode(), makeStep()); + controller.start(); + expect(controller.state).toBe('scheduled'); + }); + + it('returns the controller for chaining', () => { + const { controller } = makeController(makeNode(), makeStep()); + expect(controller.start()).toBe(controller); + }); + + it('transitions to "running" once the first step animates', () => { + const node = makeNode(); + const { controller, manager } = makeController(node, makeStep()); + controller.start(); + manager.update(16); + expect(controller.state).toBe('running'); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Single-step keyframe interpolation +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — single step two keyframes', () => { + it('sets x = 0 at progress 0 (before any update)', () => { + const node = makeNode({ x: 0 }); + const { controller, manager } = makeController( + node, + makeStep({ + duration: 1000, + actions: [{ p: 'x', v: { '0': 0, '1': 100 } }], + }), + ); + controller.start(); + manager.update(0); + expect(node.x).toBe(0); + }); + + it('interpolates x to ~50 at 50% progress', () => { + const node = makeNode({ x: 0 }); + const { controller, manager } = makeController( + node, + makeStep({ + duration: 1000, + actions: [{ p: 'x', v: { '0': 0, '1': 100 } }], + }), + ); + controller.start(); + manager.update(500); + expect(node.x).toBeCloseTo(50, 0); + }); + + it('sets x = 100 when step is complete', () => { + const node = makeNode({ x: 0 }); + const { controller, manager } = makeController( + node, + makeStep({ + duration: 1000, + actions: [{ p: 'x', v: { '0': 0, '1': 100 } }], + }), + ); + controller.start(); + manager.update(1200); + expect(node.x).toBe(100); + }); + + it('animates multiple props in a single step simultaneously', () => { + const node = makeNode({ x: 0, alpha: 1 }); + const { controller, manager } = makeController(node, { + duration: 1000, + actions: [ + { p: 'x', v: { '0': 0, '1': 200 } }, + { p: 'alpha', v: { '0': 1, '1': 0 } }, + ], + }); + controller.start(); + manager.update(500); + expect(node.x).toBeCloseTo(100, 0); + expect(node.alpha).toBeCloseTo(0.5, 1); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Three-keyframe (mid-stop) interpolation +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — multi-keyframe step', () => { + it('interpolates correctly in the first segment (0 → 0.5)', () => { + const node = makeNode({ x: 0 }); + const { controller, manager } = makeController(node, { + duration: 1000, + actions: [{ p: 'x', v: { '0': 0, '0.5': 40, '1': 100 } }], + }); + controller.start(); + manager.update(250); // 25% — half way through first segment + expect(node.x).toBeCloseTo(20, 0); + }); + + it('interpolates correctly in the second segment (0.5 → 1)', () => { + const node = makeNode({ x: 0 }); + const { controller, manager } = makeController(node, { + duration: 1000, + actions: [{ p: 'x', v: { '0': 0, '0.5': 40, '1': 100 } }], + }); + controller.start(); + manager.update(750); // 75% — half way through second segment + expect(node.x).toBeCloseTo(70, 0); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Multi-step sequencing +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — multi-step', () => { + it('step 2 does not start before step 1 completes', () => { + const node = makeNode({ x: 0, y: 0 }); + const { controller, manager } = makeController(node, [ + { duration: 500, actions: [{ p: 'x', v: { '0': 0, '1': 100 } }] }, + { duration: 500, actions: [{ p: 'y', v: { '0': 0, '1': 200 } }] }, + ]); + controller.start(); + manager.update(250); // half way through step 1 + expect(node.y).toBe(0); // step 2 hasn't started + }); + + it('step 2 starts immediately after step 1 ends', () => { + const node = makeNode({ x: 0, y: 0 }); + const { controller, manager } = makeController(node, [ + { duration: 500, actions: [{ p: 'x', v: { '0': 0, '1': 100 } }] }, + { duration: 500, actions: [{ p: 'y', v: { '0': 0, '1': 200 } }] }, + ]); + controller.start(); + manager.update(600); // 100ms into step 2 + expect(node.x).toBe(100); + expect(node.y).toBeGreaterThan(0); + }); + + it('emits "stopped" only after all steps complete', () => { + const node = makeNode({ x: 0, y: 0 }); + const { controller, manager } = makeController(node, [ + { duration: 500, actions: [{ p: 'x', v: { '0': 0, '1': 100 } }] }, + { duration: 500, actions: [{ p: 'y', v: { '0': 0, '1': 200 } }] }, + ]); + const cb = vi.fn(); + controller.on('stopped', cb); + controller.start(); + manager.update(600); // only step 1 done + expect(cb).not.toHaveBeenCalled(); + manager.update(600); // step 2 done + expect(cb).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// 6. waitUntilStopped() +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — waitUntilStopped()', () => { + it('resolves after a single step completes', async () => { + const node = makeNode({ x: 0 }); + const { controller, manager } = makeController( + node, + makeStep({ duration: 100 }), + ); + controller.start(); + const promise = controller.waitUntilStopped(); + manager.update(200); + await expect(promise).resolves.toBeUndefined(); + }); + + it('resolves after all steps in a sequence complete', async () => { + const node = makeNode({ x: 0, y: 0 }); + const { controller, manager } = makeController(node, [ + { duration: 100, actions: [{ p: 'x', v: { '0': 0, '1': 100 } }] }, + { duration: 100, actions: [{ p: 'y', v: { '0': 0, '1': 200 } }] }, + ]); + controller.start(); + const promise = controller.waitUntilStopped(); + manager.update(150); // completes step 1 only + manager.update(150); // completes step 2 + await expect(promise).resolves.toBeUndefined(); + }); + + it('resolves immediately when stop() is called mid-sequence', async () => { + const node = makeNode({ x: 0, y: 0 }); + const { controller, manager } = makeController(node, [ + { duration: 500, actions: [{ p: 'x', v: { '0': 0, '1': 100 } }] }, + { duration: 500, actions: [{ p: 'y', v: { '0': 0, '1': 200 } }] }, + ]); + controller.start(); + const promise = controller.waitUntilStopped(); + manager.update(300); + controller.stop(); + await expect(promise).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 7. stop() +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — stop()', () => { + it('sets state to "stopped"', () => { + const { controller, manager } = makeController(makeNode(), makeStep()); + controller.start(); + manager.update(100); + controller.stop(); + expect(controller.state).toBe('stopped'); + }); + + it('emits "stopped"', () => { + const { controller, manager } = makeController(makeNode(), makeStep()); + const cb = vi.fn(); + controller.on('stopped', cb); + controller.start(); + manager.update(100); + controller.stop(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('returns the controller for chaining', () => { + const { controller } = makeController(makeNode(), makeStep()); + expect(controller.stop()).toBe(controller); + }); + + it('stops further animation updates', () => { + const node = makeNode({ x: 0 }); + const { controller, manager } = makeController( + node, + makeStep({ + duration: 1000, + actions: [{ p: 'x', v: { '0': 0, '1': 100 } }], + }), + ); + controller.start(); + manager.update(300); + const xAfterStop = node.x; + controller.stop(); + manager.update(300); + expect(node.x).toBe(xAfterStop); + }); +}); + +// --------------------------------------------------------------------------- +// 8. pause() / resume +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — pause()', () => { + it('sets state to "paused"', () => { + const { controller, manager } = makeController(makeNode(), makeStep()); + controller.start(); + manager.update(100); + controller.pause(); + expect(controller.state).toBe('paused'); + }); + + it('resumes from the same point after start()', () => { + const node = makeNode({ x: 0 }); + const { controller, manager } = makeController( + node, + makeStep({ + duration: 1000, + actions: [{ p: 'x', v: { '0': 0, '1': 100 } }], + }), + ); + controller.start(); + manager.update(500); // 50% + const xAtPause = node.x; + controller.pause(); + manager.update(500); // while paused — should not advance + expect(node.x).toBe(xAtPause); + controller.start(); // resume + manager.update(100); // should advance a bit + expect(node.x).toBeGreaterThan(xAtPause); + }); +}); + +// --------------------------------------------------------------------------- +// 9. restore() +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — restore()', () => { + it('resets node properties to values before the sequence started', () => { + const node = makeNode({ x: 50 }); + const { controller, manager } = makeController( + node, + makeStep({ + duration: 1000, + actions: [{ p: 'x', v: { '0': 50, '1': 200 } }], + }), + ); + controller.start(); + manager.update(700); + expect(node.x).toBeGreaterThan(50); + controller.restore(); + expect(node.x).toBe(50); + }); + + it('returns the controller for chaining', () => { + const { controller } = makeController(makeNode(), makeStep()); + expect(controller.restore()).toBe(controller); + }); +}); + +// --------------------------------------------------------------------------- +// 10. Step-level repeat +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — step repeat', () => { + it('replays a step N+1 times before moving to the next', () => { + const node = makeNode({ x: 0, y: 0 }); + const { controller, manager } = makeController(node, [ + { + duration: 100, + repeat: 1, // plays twice total + actions: [{ p: 'x', v: { '0': 0, '1': 100 } }], + }, + { duration: 100, actions: [{ p: 'y', v: { '0': 0, '1': 50 } }] }, + ]); + controller.start(); + manager.update(150); // through ~1.5 repeats of step 1 + expect(node.y).toBe(0); // step 2 should not have started + manager.update(100); // past 2nd repeat + expect(node.y).toBeGreaterThan(0); // step 2 has started + }); +}); + +// --------------------------------------------------------------------------- +// 11. step loop: -1 / loop: true (infinite step) +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — infinite step loop', () => { + it('keeps playing the step and never emits "stopped"', () => { + const node = makeNode({ x: 0 }); + const { controller, manager } = makeController(node, [ + { + duration: 100, + repeat: -1, + actions: [{ p: 'x', v: { '0': 0, '1': 100 } }], + }, + { duration: 100, actions: [{ p: 'x', v: { '0': 100, '1': 200 } }] }, + ]); + const cb = vi.fn(); + controller.on('stopped', cb); + controller.start(); + // Run many cycles + for (let i = 0; i < 20; i++) { + manager.update(100); + } + expect(cb).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// 12. Value smoothing { v, s } +// --------------------------------------------------------------------------- + +describe('CoreAnimationSequenceController — value smoothing', () => { + it('produces non-linear interpolation compared to a plain linear value', () => { + const nodeLinear = makeNode({ x: 0 }); + const nodeSmoothed = makeNode({ x: 0 }); + + const { controller: ctrlLinear, manager: mgr1 } = makeController( + nodeLinear, + { duration: 1000, actions: [{ p: 'x', v: { '0': 0, '1': 100 } }] }, + ); + const { controller: ctrlSmoothed, manager: mgr2 } = makeController( + nodeSmoothed, + { + duration: 1000, + actions: [ + { + p: 'x', + v: { '0': { v: 0, s: 0 }, '1': { v: 100, s: 0 } }, + }, + ], + }, + ); + + ctrlLinear.start(); + ctrlSmoothed.start(); + // Use 25% progress: smoothstep(0.25) ≈ 0.156 (x≈15.6) vs linear (x=25) + mgr1.update(250); + mgr2.update(250); + + // The smoothed value should differ significantly from the linear value + expect(Math.abs(nodeSmoothed.x - nodeLinear.x)).toBeGreaterThan(5); + }); +}); diff --git a/src/core/animations/CoreAnimationSequenceController.ts b/src/core/animations/CoreAnimationSequenceController.ts new file mode 100644 index 000000000..38c57b53b --- /dev/null +++ b/src/core/animations/CoreAnimationSequenceController.ts @@ -0,0 +1,409 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from '../../common/EventEmitter.js'; +import type { + AnimationControllerState, + IAnimationController, +} from '../../common/IAnimationController.js'; +import type { + AnimationKeyframes, + AnimationKeyframeValue, + AnimationSequenceSettings, + AnimationSequenceStep, +} from '../../common/AnimationSequenceTypes.js'; +import type { CoreNode } from '../CoreNode.js'; +import { getTimingFunction, type TimingFunction } from '../utils.js'; +import { mergeColorProgress } from '../../utils.js'; +import type { AnimationManager, IAnimatable } from './AnimationManager.js'; + +// --------------------------------------------------------------------------- +// Keyframe interpolation helpers +// --------------------------------------------------------------------------- + +/** Ken Perlin's cubic smoothstep: ease-in-out in [0,1]. */ +function smoothstep(t: number): number { + return t * t * (3 - 2 * t); +} + +function rawValue(v: AnimationKeyframeValue): number { + return typeof v === 'object' ? v.v : v; +} + +function smoothness(v: AnimationKeyframeValue): number | undefined { + return typeof v === 'object' ? v.s : undefined; +} + +/** + * Interpolates a keyframe map at the given overall step progress [0,1]. + * Adjacent keyframe segments are interpolated using the step's easing + * function; value-smoothing `{v, s}` values override easing for that segment. + */ +function interpolateKeyframes( + keyframes: AnimationKeyframes, + progress: number, + easing: TimingFunction, + propName: string, +): number { + // Single pass: walk the keys in insertion order, tracking the last stop + // that is <= progress (lo) and the first stop that is > progress (hi). + // Keys are expected to be ordered 0 → 1; no extra allocation needed. + let loP = -Infinity; + let loRaw: AnimationKeyframeValue = 0; + let hiP = Infinity; + let hiRaw: AnimationKeyframeValue = 0; + let hasAny = false; + + for (const k in keyframes) { + const kp = k as unknown as number; + const kv = keyframes[kp] as AnimationKeyframeValue; + hasAny = true; + + if (kp <= progress && kp > loP) { + loP = kp; + loRaw = kv; + } + if (kp > progress && kp < hiP) { + hiP = kp; + hiRaw = kv; + } + } + + if (hasAny === false) return 0; + + // Clamp to bounds + if (hiP === Infinity) return rawValue(loRaw); // progress >= last stop + if (loP === -Infinity) return rawValue(hiRaw); // progress < first stop + + const lo = { p: loP, raw: loRaw }; + const hi = { p: hiP, raw: hiRaw }; + + const span = hi.p - lo.p; + const segP = span === 0 ? 1 : (progress - lo.p) / span; + + const loVal = rawValue(lo.raw); + const hiVal = rawValue(hi.raw); + const loS = smoothness(lo.raw); + const hiS = smoothness(hi.raw); + + let t: number; + if (loS !== undefined || hiS !== undefined) { + // s=0 → full smoothstep (ease-in-out), s=1 → linear + const sLo = loS ?? 1; + const sHi = hiS ?? 1; + const smooth = smoothstep(segP); + const blendLo = smooth + (segP - smooth) * sLo; + const blendHi = smooth + (segP - smooth) * sHi; + t = (blendLo + blendHi) / 2; + } else { + t = easing(segP); + } + + if (propName.includes('color')) { + if (loVal === hiVal) return loVal; + return mergeColorProgress(loVal, hiVal, t); + } + + return loVal + (hiVal - loVal) * t; +} + +function applyStep( + node: CoreNode, + step: AnimationSequenceStep, + progress: number, + easing: TimingFunction, +): void { + const nodeRecord = node as unknown as Record; + for (const action of step.actions) { + nodeRecord[action.p] = interpolateKeyframes( + action.v, + progress, + easing, + action.p, + ); + } +} + +// --------------------------------------------------------------------------- +// SequenceRunner — single IAnimatable registered with AnimationManager +// --------------------------------------------------------------------------- + +/** + * Internal runner registered once with AnimationManager. It drives all steps + * of the sequence in order, correctly carrying dt overflow from one step to + * the next within a single manager.update() call. + */ +class SequenceRunner extends EventEmitter implements IAnimatable { + private stepIndex = 0; + private progress = 0; + private playsLeft: number; + private delayFor: number; + private readonly timingFunctions: TimingFunction[]; + + constructor( + private readonly node: CoreNode, + private readonly steps: AnimationSequenceStep[], + ) { + super(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.timingFunctions = new Array(steps.length); + for (let i = 0; i < steps.length; i++) { + const easing = steps[i]!.easing ?? 'linear'; + this.timingFunctions[i] = + typeof easing === 'string' ? getTimingFunction(easing) : easing; + } + this.delayFor = steps[0]?.delay ?? 0; + this.playsLeft = this._playsForStep(0); + } + + reset() { + this.stepIndex = 0; + this.progress = 0; + this.delayFor = this.steps[0]?.delay ?? 0; + this.playsLeft = this._playsForStep(0); + } + + update(dt: number) { + if (this.node.destroyed) { + this.emit('destroyed', {}); + return; + } + + while (dt >= 0 && this.stepIndex < this.steps.length) { + const step = this.steps[this.stepIndex]!; + const easing = this.timingFunctions[this.stepIndex]!; + + // Delay phase + if (this.delayFor > 0) { + this.delayFor -= dt; + if (this.delayFor >= 0) return; + dt = -this.delayFor; + this.delayFor = 0; + } + + // Zero-duration step + if (step.duration === 0) { + applyStep(this.node, step, 1, easing); + dt = this._onStepComplete(dt, step); + if (dt < 0) return; + continue; + } + + // First-frame signal (progress just became non-zero) + if (this.progress === 0) { + this.emit('animating', {}); + } + + this.progress += dt / step.duration; + + if (this.progress >= 1) { + applyStep(this.node, step, 1, easing); + // Compute leftover dt: whatever fraction was beyond 1.0 + const overflow = (this.progress - 1) * step.duration; + dt = this._onStepComplete(overflow, step); + if (dt < 0) return; + continue; + } + + applyStep(this.node, step, this.progress, easing); + this.emit('tick', { progress: this.progress }); + return; + } + } + + /** + * Called when the current step finishes one play. + * Returns leftover dt if the sequence should continue, or -1 if done. + */ + private _onStepComplete( + overflow: number, + step: AnimationSequenceStep, + ): number { + if (this.playsLeft === -1) { + // Infinite loop on this step — reset and keep going + this.progress = 0; + this.delayFor = step.delay ?? 0; + return overflow; + } + + this.playsLeft -= 1; + if (this.playsLeft > 0) { + this.progress = 0; + this.delayFor = step.delay ?? 0; + return overflow; + } + + // Advance to next step + this.stepIndex++; + this.progress = 0; + + if (this.stepIndex >= this.steps.length) { + this.emit('sequenceFinished', {}); + return -1; + } + + const nextStep = this.steps[this.stepIndex]!; + this.delayFor = nextStep.delay ?? 0; + this.playsLeft = this._playsForStep(this.stepIndex); + return overflow; + } + + private _playsForStep(index: number): number { + const step = this.steps[index]; + if (!step) return 1; + const repeat = step.repeat ?? (step.loop === true ? -1 : 0); + return repeat === -1 ? -1 : repeat + 1; + } +} + +// --------------------------------------------------------------------------- +// CoreAnimationSequenceController +// --------------------------------------------------------------------------- + +/** + * Implements {@link IAnimationController} for an L2-inspired animation + * sequence — one or more steps, each with property keyframe maps, played + * end-to-end. + * + * @example + * ```ts + * node.animationSequence([ + * { duration: 500, actions: [{ p: 'x', v: { '0': 0, '1': 200 } }] }, + * { duration: 300, actions: [{ p: 'alpha', v: { '0': 1, '1': 0 } }] }, + * ]).start(); + * ``` + */ +export class CoreAnimationSequenceController + extends EventEmitter + implements IAnimationController +{ + state: AnimationControllerState = 'stopped'; + + private readonly steps: AnimationSequenceStep[]; + private readonly runner: SequenceRunner; + + private stoppedPromise: Promise = Promise.resolve(); + private stoppedResolve: (() => void) | null = null; + + private savedProps: Record = {}; + + constructor( + private readonly manager: AnimationManager, + private readonly node: CoreNode, + settings: AnimationSequenceSettings, + ) { + super(); + this.steps = Array.isArray(settings) ? settings : [settings]; + this.runner = new SequenceRunner(node, this.steps); + this._captureStartValues(); + this._hookRunnerEvents(); + } + + start(): IAnimationController { + if (this.state === 'running' || this.state === 'scheduled') { + return this; + } + if (this.state === 'stopped') { + this.runner.reset(); + } + this._makeStoppedPromise(); + this.manager.registerAnimation(this.runner); + this.state = 'scheduled'; + return this; + } + + stop(): IAnimationController { + this.manager.unregisterAnimation(this.runner); + this._resolveStoppedPromise(); + this.emit('stopped', this); + this.state = 'stopped'; + return this; + } + + pause(): IAnimationController { + this.manager.unregisterAnimation(this.runner); + this.state = 'paused'; + return this; + } + + restore(): IAnimationController { + this.manager.unregisterAnimation(this.runner); + this.stoppedResolve = null; + const nodeRecord = this.node as unknown as Record; + for (const [key, val] of Object.entries(this.savedProps)) { + nodeRecord[key] = val; + } + this.runner.reset(); + this.state = 'stopped'; + return this; + } + + waitUntilStopped(): Promise { + return this.stoppedPromise; + } + + private _captureStartValues() { + const nodeRecord = this.node as unknown as Record; + for (const step of this.steps) { + for (const action of step.actions) { + if (!(action.p in this.savedProps)) { + this.savedProps[action.p] = nodeRecord[action.p] ?? 0; + } + } + } + } + + private _hookRunnerEvents() { + this.runner.on('animating', () => { + this.state = 'running'; + this.emit('animating', this); + }); + + this.runner.on('tick', (_: unknown, data: { progress: number }) => { + this.emit('tick', data); + }); + + this.runner.once('sequenceFinished', () => { + this.manager.unregisterAnimation(this.runner); + this._resolveStoppedPromise(); + this.emit('stopped', this); + this.state = 'stopped'; + }); + + this.runner.once('destroyed', () => { + this.manager.unregisterAnimation(this.runner); + this.state = 'stopped'; + }); + } + + private _makeStoppedPromise() { + if (this.stoppedResolve === null) { + this.stoppedPromise = new Promise((resolve) => { + this.stoppedResolve = resolve; + }); + } + } + + private _resolveStoppedPromise() { + if (this.stoppedResolve !== null) { + this.stoppedResolve(); + this.stoppedResolve = null; + } + } +} diff --git a/src/main-api/INode.ts b/src/main-api/INode.ts index f97bb82bc..a84dc5b98 100644 --- a/src/main-api/INode.ts +++ b/src/main-api/INode.ts @@ -24,6 +24,7 @@ import { } from '../core/CoreNode.js'; import type { CoreTextNode, CoreTextNodeProps } from '../core/CoreTextNode.js'; import type { AnimationSettings } from '../core/animations/CoreAnimation.js'; +import type { AnimationSequenceSettings } from '../common/AnimationSequenceTypes.js'; import type { CoreShaderNode } from '../core/renderers/CoreShaderNode.js'; /** @@ -51,6 +52,7 @@ export interface INode props: Partial>, settings: Partial, ): IAnimationController; + animationSequence(settings: AnimationSequenceSettings): IAnimationController; parent: INode | null; } @@ -89,6 +91,7 @@ export interface ITextNode extends Omit { props: Partial>, settings: Partial, ): IAnimationController; + animationSequence(settings: AnimationSequenceSettings): IAnimationController; parent: INode | null; } diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s1-end-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s1-end-1.png new file mode 100644 index 000000000..c72a10e57 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s1-end-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s1-initial-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s1-initial-1.png new file mode 100644 index 000000000..1432d3ca7 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s1-initial-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s2-end-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s2-end-1.png new file mode 100644 index 000000000..809f25f57 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s2-end-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s2-initial-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s2-initial-1.png new file mode 100644 index 000000000..8678739cd Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s2-initial-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s3-end-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s3-end-1.png new file mode 100644 index 000000000..4501f70a9 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s3-end-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s3-initial-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s3-initial-1.png new file mode 100644 index 000000000..201897e79 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s3-initial-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s4-end-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s4-end-1.png new file mode 100644 index 000000000..d842effba Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s4-end-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s4-initial-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s4-initial-1.png new file mode 100644 index 000000000..a3a56fcbd Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s4-initial-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-initial-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-initial-1.png new file mode 100644 index 000000000..116a94605 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-initial-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-paused-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-paused-1.png new file mode 100644 index 000000000..3d29ddfa8 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-paused-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-restored-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-restored-1.png new file mode 100644 index 000000000..1c3a2afb5 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-restored-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s6-initial-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s6-initial-1.png new file mode 100644 index 000000000..f09552cc5 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s6-initial-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s6-stopped-1.png b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s6-stopped-1.png new file mode 100644 index 000000000..c381e3a07 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/animation-sequence_s6-stopped-1.png differ