From 1a8a0e83b3b483ffd9480c0947f35777abc3593a Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Sun, 3 May 2026 21:38:43 +0200 Subject: [PATCH] feat: implement Sequential Animation API --- examples/tests/animation-sequence.ts | 206 ++++++++ exports/index.ts | 18 +- src/common/AnimationSequenceTypes.ts | 100 ++++ src/core/CoreNode.ts | 10 + src/core/animations/AnimationManager.test.ts | 88 ++++ src/core/animations/AnimationManager.ts | 19 +- src/core/animations/CoreAnimation.test.ts | 276 ++++++++++ .../CoreAnimationController.test.ts | 223 ++++++++ .../CoreAnimationSequenceController.test.ts | 492 ++++++++++++++++++ .../CoreAnimationSequenceController.ts | 409 +++++++++++++++ src/main-api/INode.ts | 3 + .../animation-sequence_s1-end-1.png | Bin 0 -> 5484 bytes .../animation-sequence_s1-initial-1.png | Bin 0 -> 6880 bytes .../animation-sequence_s2-end-1.png | Bin 0 -> 7123 bytes .../animation-sequence_s2-initial-1.png | Bin 0 -> 7039 bytes .../animation-sequence_s3-end-1.png | Bin 0 -> 6711 bytes .../animation-sequence_s3-initial-1.png | Bin 0 -> 5895 bytes .../animation-sequence_s4-end-1.png | Bin 0 -> 7271 bytes .../animation-sequence_s4-initial-1.png | Bin 0 -> 5575 bytes .../animation-sequence_s5-initial-1.png | Bin 0 -> 5698 bytes .../animation-sequence_s5-paused-1.png | Bin 0 -> 7482 bytes .../animation-sequence_s5-restored-1.png | Bin 0 -> 5282 bytes .../animation-sequence_s6-initial-1.png | Bin 0 -> 6789 bytes .../animation-sequence_s6-stopped-1.png | Bin 0 -> 5441 bytes 24 files changed, 1839 insertions(+), 5 deletions(-) create mode 100644 examples/tests/animation-sequence.ts create mode 100644 src/common/AnimationSequenceTypes.ts create mode 100644 src/core/animations/AnimationManager.test.ts create mode 100644 src/core/animations/CoreAnimation.test.ts create mode 100644 src/core/animations/CoreAnimationController.test.ts create mode 100644 src/core/animations/CoreAnimationSequenceController.test.ts create mode 100644 src/core/animations/CoreAnimationSequenceController.ts create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s1-end-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s1-initial-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s2-end-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s2-initial-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s3-end-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s3-initial-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s4-end-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s4-initial-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-initial-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-paused-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s5-restored-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s6-initial-1.png create mode 100644 visual-regression/certified-snapshots/chromium-ci/animation-sequence_s6-stopped-1.png 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 0000000000000000000000000000000000000000..c72a10e577b6e0e8646866b220bde0cd62ad73ba GIT binary patch literal 5484 zcmeI0>06R%`^T}(7R#xoDfh8VD@_zMHBhI_)LbaFl*}y`vU16N1DkAQQe1MuT!5KU z87EUy({Ljr7er*-G8HvL#T^v`LBZ$tFZjK9p5ME9bsfijU&nc$_xCuj@A+E`z41c7$!1%YJV@7)1Bsc8J24FdfDvbl1}Il6FRG^YQ}6kB$AbjGsN zCR3dTxw7Ae{zG2Ya=?*+%OYf zj9cXK7o4~@GlfFF$fp9mgywBD3&-A!3+pk1V+aP=wbE-B=)igN9gp`tk$HUb1nA8F zetEn_Er)L=_hbgQ=)O)XclqCc;r}D+|0-rx*OT1`u7WBv6n26v^+4Bm{k!}t!M|1T z??L!4ya2EAHX83Ppe}nzTpTWXGIXlD_PQqTO=_)L?bNPl5)8lEdjI{i8?VHuY4%|4 zs%{-SRQpX$ugX#g_HgBo$fY);=9OlU>7v&r;;i>GP5jO}8>LKWk)t50fr!X_uBI>F+h$ zZZiGC-e`0!jI_b#0nvKCtCI$k4@ee+6AJ1rq*)zWuz3r*bcmR)BR@r=VVk=z&MTwW zdgReF7X0om2@B+Und#G(5nH!ZKd&0U+(Hs{p>!LUKk~a_ye~fKqiEqMDi!5qOk94d z)j03Ys|P}xe>YZoSlMz(R%NXefu6JV6!#9HNaznTvN}LR3#Z`~o7Q2dptpb=@wWc}NYYQDDtzA_M&uX9|IUkH#g!~&F)&=%xF{{E(I?c$aRSMT z{ai{W=BF=YJ2}KmLrHn(hcGLx#}mHpREzGgP+eU>naAzYIX`_^PT0sbh3eV$YzgY9 z=#Trf;sw>DCJ{5ZdEk~?Dz;5Qb9_?MC}y~iDA?YRa=zBJ{}F`LVa?AXZTf!X^xA?) zgBljo;Hw?QrHH2a@`mnnWrLSA&onI7js%bik*jpA^m2L8z04Lm=XleMrRWT~c_Qzq z@}p0O<>P^L*8Y%9HS%X2Z~VG@W3g^rO}dQbq?=2@2_lAcgGX&wjgzyA9^V*?YT11p zE~Kzj{EvKa^imza)@l4G=jN@UFqSFsR+wgFHm#qoFrKiSsMZL`A6!M(+>TW@j@htO z(Mo-8pw_kp=is2Kf=X=hA?X^~oX`;V(f^+7X-sdyHK9+Tg+k4A58I~C=a!4cQ1t?C z;+YGpqnjpB*4iq3h}JRk`7C|)0NVUmd9ETsGGAsDR_^Vp!m+|SI5=ciPa>TPOcEy_ zq=od~usW!fA5pl{b_eCrJu~dvmi4DZ0#ujAt!pBA%yME+O>S6+I+iW!j*@mqHD`^i z2;vRmcum4w>gE*FXdKS%Y=0!4C8HP{<6BbF)+TpIog!U|qQ+rWB3=7+cDYt{ojQ?p z_qkc`WE&#>+fmL2IBtj$6j@zREE7uI7!9t8c0B2H$8oeI&8Sao{RKXEBmFk|`vCNg zWO%SSlKrS%GHpb)F>!b|961um!w4J5wI$T&C7(%vD(X;(frD}JzN~WVTR~gv?dF=v zp#+7!OG-)7aMIL&P)_j|u%1D7navhuwaFI74w*30U`x>z zaiiMr%#P#t6OY<2@qsXy_gNt8Fn@!i$D$|<_g=sHKEhHqfn~n6+4VuX$u&oqN{^D( zKQX{mcwf~fpN_zU>gpiOH$VTug&M@jlT*j>^7)N-7UK zuC#r+N@NJI6a~4nrKJn$@sp>HUI=^b_VU|61?D@HxPr^3s!3+7EGP)!S^45Lu41py zns0g*R1VSdS3 zvAL3p`nkVzTs%bEC%zBRd0`nSiG;bFig%xHpMA0~GZ;PZRysIaP@EP_nZi>6n`qxC zYCNQU)KfC&a#Swn?U4Fz11+(5kcxTjHad3}5!2bG6kJH`Vh_<(c+u?*ql-bMtu%A< z^%i+ekt&e2XLye#a@a<yo{ zs~UUHtOeOpDHYFi1qVFWKSnb`%tx?Z9X&_9D2?$~O-A>+eq{Qjs!yHeu-ATo1ToKG zigRw|8+NHVdao2$`Wt@3ns0iSD2Ayqhb^vFC{R#Eq#l6SBzWjk6EFl|`Y%ETmTU^yiepox?e>M#n%Y4+kW_{klEq*neTNvh` z+QdRHWm)(I!m;*;)h=3A`*M`V*V}O7un$+9ljFp^vt?k+N{Uug+&WFI`7;hDB1u5%sRl{4RTj{(lOx$fwgoAh1{X|OAwecnYn^@X{AphhF}oSOviD(sK@eZj2tSfL=#z2okSD_KQd7kb~?Bx=7dq2cYW zJMJ=KhtRw&y+2D8_$32G#atOo=oBCDh%%K6U%V1`-Av-6h%wW8S{=LLTOt;sHKA48 zL!ZK(%=`uNv$p_W*LeEi%L$}H5Xwi>E-W`UIILh~QlMonqDR}7Xg*_U+)9p9yu0nE z5P~A)1lsrOoGE&~6%BO9vS+0xe?%jT%&KI;kj&8D*`*n`)fD0dh#bVkW+g&xou zQcCmNXYc$QUBPA@JIVF0?`G%$TrQc$~W4 z`sf_aPlq&8%`%G%^|^KUh+Ir3+v^0=*CHtRQHmR`PA>56nrqcv{d~>Rtjl>}%!5f0 zgYHIkRPjKCCV4ONtc}~lntORElNb0nI;!}A`FGnUStP~yzu{FQUUdlTOT?@=#edf!hOdW5R+ z;CJRG0ly7(&!y&scjG2TmK35(KB)n%>s8D(CDYdM(8X9sMG1usvH4WN?zt7euFPK(f=FvcTJ~+;hjK3TEa#9*WtE9J$A;;#;!z$Z zYHpB}bc!|CU#y(KFBoUX^O@DF`gsa@5$FFtiAq)Hb?Is6H&&iXheNzGt|Rn(H6JyM zjOs!&M*$0uf61OE<@xyC;0{uy-|uPO!t#Dj{pspLC0g&!oMAkDzw47bLi3loQp822 zcD01~+hc?3Cme`x|FVK+AX>YwM+KEKucHn4u(C2`oY5hwrjlyCM_IFY?o~P3ht{^% z^3(Ufv6tc65lN`x)?zpy*3puRZUr?F?Uhyz@}>r&Fr>pDe0G z_QCiK{%#Ox_p$9+Kx|(>$5)2)Ed{LV?@rUshf6bV#$@$*UX#OdWuE1+S&CgDU+iW> z%XRR$MD+t4nqWl+YeQ$xs#I#^Tj>>%;qfgAXX0tB9aj&ubND&MJyR$+DOReqfFG z!B(WA0l(^4ltML05yJswzk!Q^HQw&(tl=0gAxoc2i1KjAeb6mYMy;kWqMGL2`DS+R z+B@*Z{JlxELol7d8H49lNqKV_Nm63mQp~_j7v_B-xWq!<)Zkr2x~aezXuTy%8Ie}^ zoo+p-&V0@<(Irzz&K=Tyk9{qE^6K+Iz|5Lfo$Jf?pcpgtogV}CM-V8~1p z0YJB~rT6YpqlOQ5K1+2%m>N30H1NGJeD776a#zy}U&QdeyySxn&b}fYFnN9Y<7yIj z0r{|Fg>`Yhj7|M)m{h3yEb|+&lNf)y(yPVRV>R3J)L<5wOEK~B zmxnFZIDPbQTqmV#9$2;cZGIjR1=?~?QIbP0-$4-~R5!k~m+Y6b&zum_&W@{z|H1k% z7T#Dc^NeHWIA-1m`RU>DV{&T1rtJ#=aL;(KDGPP<4U;Kl%%*F(2u7pEC)|D+A`+^( z>lBlX$sNDke^6_OkvW^*UH|dNcRS_bAuo1L6&LR<35H?}rnV8RBiZ`mxd1k~(w`Lw z^ecdKmA1~owv?Z!>sJEItLa}g2HU*%6##GgOY337;AomsC}CrvniX<0<_ru$fEKgx zIU*f&qI~yJr*%j(aiaide_=?Yi9nC|?Vu4` zk5FC~%{}cq<#mfN8p=37cqfc0*9Bd)c6-=p$}dvZ;%6F&`%W{~?#>-xxe*RaM#coS z;TcInsP;gvCq@*FrGV%1-vF@W_VBG`rlw25vmVRi?lpgzV*Biy2VUjv=9bl=JaSl) zr$-%5R5VYHGRlGgqUdhV76$Ip5#cElTYh~Bb&vGqs8<>$tV!nvA~~r>QkUDlTS0ju z0!BQWmr_(=yaT^qYGt~`O%jI^2glMTxm1@SG+ZUa)REm`H%Ptnp8VFI3KV;f!?|U3Nn(%ec2S=O#<9>|y^#3gf un5+G>1Q8jJaqLef=(p|gep`_ZHX(g&iY%vefR^cg!FOHfeCN0G>sr_QuC?yxS@(TE&q};wV=8(0++h(B z5lM5i-|a<2emN*2BHD8B7vV^4`=3-1k>eufzyEeO46`&D-cn7cim$2_p4KS*RR>Zc zYI^cyoP>&2>A(vUN zMHbn3_c7Oc@m#k>M{;WsYj29Y;^tcvzQ!c4hOP(G^@?H8 zQZCI@7~cHnZY4A!Y|z&Y2@Ihx)^_dXIK#(xNAm!PDHB=$w;u1G!^17zo0eM_r?^ec zlOYs;MD(IFQ#-h_9OH_7!gunm&h9W+({a3z6x`rU`6P>o>NT9Ii_P|ePx@-+`MNGT z;WvlTHO*5Iqj*wqSvx9F(yNZOy&R;_lU>z7GMIFkljBhkU53<|qm!I8Sd?Ig7DGZ} zhp*fp|K!O1g=op$YOIZBZ~yrBCCRF^_gXs2Tl0@!H&X^qMW!OyY@i?-PvHU!{h9P=XD*5&R>^m z=A|tYHo-~>@)7g_O1*`AGjaFxl#cZ+>F*`w^uFa2HLwd|f z!4^3t{Kgy84nAFr{E*dIjv|Jyak}Gv==VS-207yB4Sg_C-95$YfgL^HudbJacc){I?R~371#<{$YG;RVrJ++E zv63m*zyArpO<>9F?(x2W9%Yl|Lj}$`R9r};%LWmaM9L)cjwOJ%CbVcV0VQkXzw{{xYQ-sGi1LV zHZ|g;_ugP!9j<0tx^LSXr09%nL)Nvm=e+AIwK{@YKXN)9>!k)&X%P>| ztouV1R@Jzl(dQt#2J)5sorg$7lUB#R6XwC10JkeH!Ym0b%UIj&1*3A_f1W^(mVqYU zET^AoB9O2B4I0zZZvt$KBloQtFQ445CLL)Y&6qTTih^OZCUFW%b~)klh}+Q7$G#43 z-@3d4yBhB|qRT@n*8Snz#lR^2Bh2^e$Cd@j>xeH?&3ZV!>$MBLS6S<7Nw8`Vo5?+c z4~Rdqt~NJ!*t4|2Slh3aRW|upTQ4dgtc%ZQg2DTjnMORk`l;ZC%tdx{*RFrWUxdG+ z81nL*v;-;}9!fNj)^dy5+hP_4bRWVWgpgW=fMYM3w|2qr9)&{+)vf45gBj)6hy}Da z7T7)wiHdnACLb``HlR7z;fr=Rk}7ZAuY$2w=_RyKum z`gpG?%}~3xmCi_$ewHta+fYO|V)(l6ICWt^csq7SH6&R46UF zp^`H4aib=*L*l{&Sz%|aH}JZSOP^>f%NmjYBQPa6en0T$xEQX9o?IV)M5|pp^fh58 z2Zytt5)we4L&1Zph-wB)HygND-MIkO6a zFkhtMw)!%SWfN)%}9}n4a-NJ_A2NM@beWzb#5N+Mb7BtLaz&G1A=G2+C_~UCOyT z#X(xs``GUla$dZULL7bn*<>9j*FnF6Fn8Amez%cSyzhs~gKEzi5H`BjNQP>Dfpy8C zjfv4t{oIHM%DO6R7wO5%MrTzxebgV)@?N+;>3$gEV{bvRjTp+*2{BrDQ#|nt>*$ma zxY8qLG+r-w?`jteJ$GlXu-<|7aEPa}A4zBC)BJ_*EMZ>$sqi1i6q05qv%czOO-w)n1?5$?I4tl>|~*ABJ(I zo`kNq`YQDU+ReELAu|Kd^i0^L+-^J8CzJ8rJOs8q?`QM+(hhj1B)C6(CRRvKem>jl zU)}19IVOjm-pD@a)1U-=umwm^pM+QB0!2^eII@KznguI)I1W%6X0f82|6-0)?@X;> zc>_AEDx}LvCv&PWr+b0Vo#A+BU~}WlPoBJW=|`Fdbqr2mcu!m?ci-8#trcN+;8!32 zvJZjB8>GbLg6^c)_yzzB6xY_Y&tkc6%Ny%#np*c8cf)3r0n4$kZa*49>ngL!&q!}w zxZ&cJPk~VyEHQ=6oAG~Xsho9E2jhtKzJi+)rP;GMZ((JqhxZK&+ zpmANAb$O`T>(Bf>6pl)+>Kj_`~tVO7-e3PbMhRj zQlX(sC=TSBz6qU6ZI8dazLD`ds_ANFzdXsv{Ix>BJJFt|QqVYioWC^Iq}I?(dD7sO zW9!zWf|SlP#@KYp#kDH~M159k+LrUZDd#dIfpLKs=`~k-^10n_mCZBX&BGLBvAz_UUb?5~gDU9Sbl^^Y+=}QU&;`2n8?7?=KcK+Kf zzr*ss$`g{mTtS2rg9@40NH~Qb^jX~tS`<`#>VlYnPZf7~Vu;T)3x3=(VR_)w)YTp) z*Z4`a&w_!H&l8)ctWJxJ-+~Uq(Nv55I8eq9(`BdaqkumO+FtnG)&N%zvXE_G(%NMU z&CJvJ1*aMd&4facOF423IG6UpN|r=6iAll?EA6OJjMhS>;}cN!BcE5EQJv^hzcSs# zE93FI<}V?pI{id#pxm7ZN)|+C2<;gWVt@}NP2N2G%Wf|)-u^eImH^LB5E(z1NM7fK z=>D;n--9?4e2T_xnMWbT@HYj8T-F}-g9PG)eI}X%5fp8HP}5;p@2ZuS2c2P6Z(rqP zWjGM3CDL|Gheo}A%|AJKzmt=#?w(gthtE7=)# z7nM&KO?IPCE##d4tZG;2Sjt6O{+UIJ_b0je``bK*+AB8JQ)A_0_&=%}cjrDVft5k3 z%m*nj)w_Z4AY^W2J$1e$B1hXS1B%qCXwfjzmn6a*_wEhJDB*HokI_54pdUR83zwl9 zTGHPFmLse*87>QG{eeEfM$XG69mlsXtqp0 zuL|&UQjxQcc>9$Gq4v!i0@L#!wBprAG4k4r=GnlHH8Zn`nKGRpCnIX~nZSlBp$BFUet_bWfu3qvi?NR>!9A z7B*`~$#u{)O7Y9Elfmc!lA?{gnshmT=vdWh;-2Yx;7-v59Oe!&bc^@c6r{_X=?lgN z##EfgSDKyRI@Cru)L`%-#xhAzP=SBbxL31L=}2{Xcjj_&L0qP({Q_a1&o(_Mi=COo zVtneH%|B>viuuLP;&{VK_DSXFN?4A*SGQ_It$CbK`vY*qa&K}s`Vxt4(@M%IWGA0Q zu!pZ_>THubi1Lw3>Cho^*_vy-M<2*EehW9ZUIgu@K)Z3ys?~$I#El~H0+;EJTBtu3 z0@a;h=eGP2k+j59qZkz1npaS)SYIi&I(6p3NLZv)a_*KP>mwoOo}_vO-JsKa%yx$9P?sW^FD2c22U=Mw{ERl-5xL;6cGYn| z-PL#9&}aT5CC(gi`S{xYb^B$HAp*=EFf5iyGjgo3)*n|Ea1~hij0|xf&+`(<1zM3Jws{)&;i}?fm7anIe9=F@%@MAKJzUyy+IvZJg z?Q-h6-s0)1p^cx`F1)u&P0qcdmDRYj-~PIVT6Tt6&0LaJ=k-vv@iCV%oCS}TWm8vb z{p=`o`>I3wjRNRI=;L8lb9oAgJ0`(R6;_sadpLeX(&<8-hyF!8cGr3VDx`(9?zqc@ ztSvcu7iQ}?3H+WsgrLe5cU|@I(X@w~VV1PIxMA#NYHq_2mfb|rbl9{CQhTQ3m~q%M z<1#qNvQwiyV5Zv>cpD@&)z+o+j^v1YZ4G|@Ht(3x&0`Ofn!sxrAr}k7z9*Jr0xbZoqJzmF zvx?%+MfTP;FRGGO`BYmAsDkP%UE9plh)Rq4vb~%uY;ak2WbSE?;fW>w4QxDgWrN)G z>YZC8JKL_CvmNW9t>!zh>XT+JlLH&lF`mWr%E*;d`Zpg3)^Y84FE4p-!tQB&m0^}8 z4S@Etj`(%$auXA{uJo+fZE#9f)DxPtf9hLXm9oqQLA`ZkiMxkih$1NL^{>ye>bwNg zHiqo1&fx2ij2W)bp6O&+uGJ?$D(dqKoGeS1NoZO@6bYLOJ>OA`;85xxy^{K47uG7( zFE)}bf{QO2>nJ@UtVf5h4(O8o#KzA5X+~M9vx^yL<@DS85I^k)L&Ex!A;+JxS?Gr%rIjwMlCG3H}@>5E0f@(qC&zgie zq*u<(0Hqx&d0E_+I7_Jof)FvMCzM#>3BW8V)rnyOETR07^{aXiwxh5Y^4yYFD87?g z3Hv+kl-|}Ch5R~yg6NaPDU=-iR;02dN%@}1G4=}u%{19KKUbUf+ev2{UysjOgiHpH zVw8bkfv~HgBCUI3l6+11awx^W8RN9utm$z(-&wmVWvI8*gjWB`wFj4>CTlOjx~CGY zZ8iE&)&^vYa5HeJHls?L<*a+b?65p)a|U17|DxZeD?_sCOwHaY7qkUvd%pN*?}l*? z)VTAqP51SZz~hq!l;Ojtj(Dwh*4rzh^I6FfHIQE0E&6BUW7&rTf`d1X#Zxus;GnqT zyTSJLeSaH%i*@)1LiD(ymq0)q;c+KJX;uePfJ^RZoT#Hm^Asmh+1h=@S2mzY{6e5X3_)0B#dq)U%i3>@{_7LJIR-?sU^ J=9b6ve*yE}WrF|! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..809f25f5745f5adffda883593ccda7fe7f0562b9 GIT binary patch literal 7123 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 JymTw>zW^mY+N=No literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8678739cdb77cb68d0441e4234dc484dbe017d0e GIT binary patch literal 7039 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 P0svojx>WO9K=Qu;Na;0t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..201897e79936ec68145b11e40a4338dfd09f832f GIT binary patch literal 5895 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 P8IbKI$BX5bcOL&2m<;NF literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..116a946053c8859cc43cf4d0efdc25c51e2f4488 GIT binary patch literal 5698 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 TrejS5SOJ+_hZ+?dILG`Kmc0w1 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3d29ddfa8adc3931abb8ec3b200b9642aadd36f6 GIT binary patch literal 7482 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 D6HZ2a literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f09552cc54062ab95ca9cb3ada23fce25d914739 GIT binary patch literal 6789 zcmdUUc{H2b+qPB@r>)@}Eo!Qxl%i-2)tJ%&6@nHu*HBYq2t^T^q^+W=HP(;@Ij5#- z4K>eFvjFHy)7jb-SqCwW;HcK}(1Hn~$UpOAEsU z?-VP7K$1S@BJ=5g*?Ftu?5t}5UWulEy>W6?d5KrH11XEsKn>A{%4 z!h##v^UvZvduEndHV{fH-|rbv(H%(*i>7mo4r#Ch=i`I`XG z>1Rit+_=PZ`&T}m6CFI)6#mR_*7B~wV7=C%_+GQ_^ z%iuNj$fo|b4fuv?W$X64B=r?B^S8@o-bI=AZw+JUcOCBSGG{pKiGWHfRGheOxc)F@ zjk7!M&S{xpt*?Bnbn`DQss?h{d)O8do+D7!ey{@BP;cGrWYEZwTBN-7@Dk1;8K^rQ zwGb|rKX}A7^pBp_mh(=d_co%(`3+`|VE4aj=;gq~$_VK(Mz04$`_&o_xBJO`ZV^PE zu*gT8*FuInLonL%qlV58A1m}C*C(|I2DC4ytPdx~+v_*lnGp?1xo-+mj1GE$>@6~o z!_a_JEMytIw}u4_nMA@gf=LyJ&ZcLy?+ja5w}B@H4Cz1Q2PVbaH{&v_^e81j7DPQA z%Gdv>g*K=UT=Nu0FK>q}O^}RIYxv4^%m%AVmc4mX_TYB>t$^}6dp?&b0s1rX*4cqz1Wm#(w`$-D z3GBy^{`@Lon-9rab@$V*@8FY~Yj$UPPx4>65e-*V-0?u;!j~=_} zoDj}8a>rcbD%B#oc(ts?s`(wi?BftDxciiaI>|>HKD9eO71dSCN8!e4E!{%>5koY4 zCVZiG*oRV-?d*&4scfRZ4J<&H+Pgmd+QUs3ch-u-2spB#;Mnk(OE7#IM&^3s)|DcQ zJlukei=7ia=ar{qNCDaEMjVe17orpWF!oZ6K8N<l3Lux;PpOaSJ7I0&~#lOSz^x~?%)OAfsO`+9xynq-T%p6+DGO_ffDT+on` z(N-dSZwi<E_|}`Ptz5 z1gAgHtC8|Sx7d~&)f!hPFNGxOqk??mPOW<%%Z&BbcNaI%&YXIzG1! zCuBlzl_AZ^NFhGWM7t+SVt2orRzIE?K(qv*)Dw~$yDi_O+yM_(? z#GInSUUlb~KVfn6v~j(8^}tWdzLVt+xFlj8~Xu|aqFlXd~X?YDTM{85n7MRWg>pa z@m~QhdK5opbI~P^6GI)x+>tCWL1C^MQ+n5zOv}R1YfB9GJA?=A)9a`&9^D)V)mJl? z(Iu^naV%RWLs~+xb_^hnOLE(JCry{`PLr{^qs=d;pE}`yuBpP+AB6ZSUvwboKNWoz0673lx%7qv;rW& z277HG#@il}laqEtT$xJ1I^W$S?IaqRU|o>qUfD*Um_#@!!cw>T`cx}(F*U)IE?+-Q z`E+!BxmMhp+S5&BT6*mu4CegfUgVTc(7--if8~8a^=|D4NZyFR)T-N_xkVoFO{|dK z`?jqzm7p0hm%ubb@DaHId*^QLGE_LOrc?(LF#$=;80#U}5~9ayhJQ6WoD&8v=Xh!6 zKj`PA@>n`P8iu+1Ho1=uek+0 ziC?-_#2V-i<8eXKA63Roamj&FyA3-DVQ=^H%I<&G&I80cT?|&U3~{bqGxhyYgXi{J zv_WQ9&SiHrs<&TD9e>C2)TChZQ4J)c|5iPEyjTW5*zme|FEwlDGp{gs1ol*R&3Pj zP(pSsIot}}uXTqQhpHA+a0?Lr8pq;uN-myywj1113XNT=mxifW`bj2bSQ`PT7BLEN z+}tle8NU6^cftDeYM<~lbfew|G#Gr|t+JX{G z0^{KLrEX|s=+TQKy$qc=$`xPpf(&s*MOqZX^1(o!zHWKQvKI51jX#t~NXu*G>Kk;H zY5~R|oo}5hc7os51HzVfuA_O;ry;mc-gMP!?5xs6cZ+jN5*#;|vol`%5J)LY4& zOGOd3ebejh%=M`O(GRt|fB+}YWSGnBH!i(wZa3^l^U9s$;W?rH^DN*HVHl zDxhbFsl*9-uP-_%D~G~&Jx4d)*YI2Z*=SCB?E-bA0s`qAUbE#!StoLh1ynY8U89ZoKttFGZ_#l6PfU9Yr)q*$u2z9IO zbhWd+c;)dg$;d0&#_CgL3LDy5!%gtvwni3q<=2y*9qqFbE1!P~wC~59os{F4+3h<; zO_yz65ROt#mZ*Oh;~vzs4M+B4odOR(*c@8wt~3$gQ0Vd&p|~>J6i~M?NrIdlHUt-3 zGV%Pd>ld~m2+VOG`Q6GTR{*_vmuURqg3})~{wX!kO;mT)?h)Io=1%e^nRCLN!Zr)l zEX2b;$x{__b$@Q@qq;KTy|%1}QfYzx*ir+5U{#+CV`ojT>L9iEcArNKt&s+n{*tT5 ztcCWqv4?fn(ys}_dT)mMYK8V4*h;VR@irb8ht}mqKy=EFWyj92X2`^>{tv@!A#raJ z2K8d$-xzl$sPvai)k~b>;_qr@I}puc{Agy|K+6M9)J5t1;%6v`XFKC@_cgu#Qr^OO zjH1z3mOW4cYQF8^ZwyL19xE4@^!-#kx#Ct~6WF$qMJLHV-s~tPNy8S?B*v}nX}H|$ zY|AN+{f@BYbxKXC0-i7nxor>Uvm9&wE4bmzNDTJJJ~-cf^MsA}eC3RklkD~Gv>L&hG>qZ*IrPPRY-Vl=BWsN>2J z)l|3SQK^~z?)7@@;OSPod-nGpjhP8cHejypNvRt4r7XhfERvvG4mymv;F-mGNZ{x9Fj6wzj_en^M|Kw0rUwG$c z0+G~H+k)1Ah>9_@&6ntJmqL3YOh+hZxYb@WsHeJ=rhL#^zLFb=j+648+1`USYNW`3$9qwD)-;>ep!{s-K7PsUb zGpDN4rh}Rr1zVk;2+Dqr`*e6sJrU79H`e=`3R&I3q*(f(AnoRN+37X^`1{l@yQJF{ zpIwhP9VMBPBL6VWf+VQ=R2-)jm{_>^^gTsEjy#*&>=L$i*NmN?B%$1_Y8ts}tZ=|z zb_jgjC=wDq1fhiLRy5APWKtX4^9|>opHg%s{T#=(O`LJZF_s_OrXom10(n72w^LEn zF|1D|*1Kfk7CU~9c8EGiPtC+D z1$$>m5jbIyZ1?WgOLmuLVXywcc4L_vZXSVB_ZN(` zdbk`svQ}^7vaO$nK^0~?xB=kP(l4Q&=e;!7EH*r81G>Q^f0L%Ttd;w8ty_w=TL34( z?@~dvcQ}bWRKwd8=YM9b7YT1&~h76XN$@ zNl?Cfo-o1x!m}6!RaSoOQL#|F0M{*n>+k&VN}&RuW|uA(@GlcEm@xyxQg57h+_TBg z6k?H}d8`qesBqo?meb93%*CvD<$*}%C*OIwfvrLPjY;8)g9vx6hQXVhp?pD-p1m&cQ=X0|%W}|KIc}w;=$CX)JCVRm56fBnIp4oe7KJ8_$ z9!4Gv>i15f7JRgHGeVdufKnSW48hR4?82*8T7_Yw`#79#8^yA;w{EW<;R}q3cIVu)pErg4R^>HtiDyu*5=Nht@}usb1MpuO_e2&S zV5h6boCB5~kj+;WHCywyTHfw1rm-`6FHGE9@KkRlWRw;q}*^gB@EXVcR0>N zp}LnJl_$+wxs| zr}L3!-M;}3zNIL-+>P!-HCeu_9Uh9JJW)EUxoLVK)nW zy`DE$Ex-!l5fPmZOYopi9eeZIEk30(!v|QI-&FN0Z%Amg?o8&R?f8sI_wk^ z;Y#FUhu!OmFJO(E&9AI9cW_S!0xi`GrJKP9I~uqH(%&J6Z^J&j6d|HqByKE4_pYCJ z7|ydkQeuf;bUaVcye-9OM(FN;4q(t4_?ncZ5OsPF%wMq>%oj{AYoavmxLRtiIwo}c;;C(_RO=hQio@#g_o5Q zZPU0(?l=PdCUfD8Vp%WmrFkJc^P?Vmhd|7c|M6<(t4&RoTLt%=oTb6NL*j7mC?2?T fANYPMM_!&To!!g58fSpq6&{d@m2t%lk7xe{er7dj literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c381e3a0774708bc79d3d52d8fd223f195e20e71 GIT binary patch literal 5441 zcmds5=UbEMmyV(^DvHXepa^0CktPHQk){JyO2%;nkrJc@1QMl$5|W^dAQli%Ae2N! zU>uYd8CocU0)Z4Mfwf|O);0XJUg@bK-D3a%+W{AxPyZfYG&d3V#5cI5d&>Us zz3hAL)d!#bwE6x;Qu5|wFSNFp&0Wgd7;*1K$If5p?gVsh_V~H^&gP5FF;^ESQQp<- zO%gRWwJ`Yhs9>hBuuEMPo@z(eBN}k$h;#>7wumT_;BUW5NlDoXFgaegF4>_2a7tet z@bmw*srlY{Q&At-lMlHRabFkq{$JPGK3_C#&kY+QzGM&jp zizXZ;qjZOYz}|q_{rvcDIcgqg_glHjB&2RN_)!mD29Bb+^l8stu4O^NbfhXrk$L9J)L1|#!n(vN4APN z`~*4*uaI&iTved&5YT~GGy9AWXLVPxGW53F=ruX!JH zr3-{)atSg7Mp*dCx?xrWyVA+WDkPK$WdvzDQ9YEp;cMun*vdvu!E?2d9OG;?y`=AQ z3BQfsDiKh08YY@=7AR#A%+GCPgCL8?y(U&(?= zmSuAby_hDicME5N>5UeM^V|5Xc({b|c|Uf6x-G43ms5~##Go693e8v9W^;h6xwZPw zf>T-^ehKU(s@-pjN6LtAie$OoaB&%^B`j?}@^e^ualsGrO4LN`Nbq}dyA`Waor=Ip z#z(wh#U>$)Bq5bs95>mfknxp?y=jSiRhql{-q4{RqfnOA_!7A?YKQQ-20HZ7iQyg* zjDSY2TEs0d;2$YI3Ku^?HcZ(kF6J$Z`{Ni{qtz|TW3x0{zK%k5bpFeiFKUh}?|kfn z{i$9__yD<7JYm6gj_=$G@`;;>C8`5qv?86(P;==k2zsW%Z|Scqp1w=k^!T%?Om9TX z(nlu^d)~_&KgH?@_Xup0A)?PgG8at)b6M%NviruB`S!5y%A|aoMKBp&=Qlt3X|?@B z4~;HC#yq_v>qz$_2;4EOLe`9G&N}J%fFiVN>0nzPtas#s={OL=uhMgQB97U-&{Q!o z*On;oJeXEc()8ud7Qy8PcCPp8w1u;~kzf)ty^neI`rTc7{dZVDz8+juxKP_?MugGj z%k^!tKB(L>@U*RetYwBz{M6kMC{`8@m5d>MEdt-)gE15$hKh=L{JSmN9HrkWkAtiO zdwgyAq-Z!vD(C|`a}E$I=D=}vp6VlhsK~QZQVXXms@)Mex~xRD7d_FVm0n|7&xiu& zQ?1A6o?Lbh6XAGOtizAUPCEJIt3%~9M}_njyn1;oT%inz8YBI%2U=uGkTAG- zwfpdGB(T6+!41FAhTJX9l1iJBUx>tyB=z5vw_Ehb)-|u30FQgD#Kc~ z6kOoh0rhU^>Ofv96q1g#3MV9m;u<)*V(sk8gJG(%p4X9_tHN#QHgD`_~+^j$Q zr|A3^LXN&w{b9HF+e|8uBTb~+izw@YKDwUK74#aVh+$*=bg?aa5kWERu9Tvrt<+NPfKg1ZYnwFTOfRsI@03JF<_%daS1 zmQf(4ScDK<1Nr0eYH} z69$`Gd3bv!sQsm74Lw^A`T6=9F9#s?&_gI%T7KuJ`XH%ZQ!QY)>HmahyL*`PrsR0TA}D#&NN5!T+X3> z#|KFFy$y`HQ$t-3ennSz4N(*-WD%bhDLv&Iyi7xNb1N#FjzNJbLlfD&+CQS-KN2Pi zhbDAJwv&bZ=$$E~#}qF#*On=ki`od`#XjUmveDt?Jb{N-@Y974oQMP3q0qT|V`nm0 zU?^79%#5W^?%tU1tV_chzmlmDglrS*Aisl7NrI0w6A{+V02GpOGEz=75CDya-lk?EhF7M^0MyqiB|w`2}E9 zT~=upAI(Q~>!RjDQK3+pH**7pmuIPS}08DX5~@OTR(ME@v6uA)n}AZjt;h zQ-7?nq^Ksu+Nh$8ddH}E`L$hV0Lpqo&Uv@Vu;{U-*fl!VWiRf_*v-zZ(SZvy%eWMN z*~{9~Wq(C|2Mc~gJgyQu7}q4+Uv}#mSy&-{eq5Jk5*CDsc(|voS|@>(rM%&ROY~5w zZ*rV&a5HK{synyY&b3MwUWG`SyXRxCq%I+!H%v@jFf4u&=iu@+L9=O(t+cTT7411G zwKC9%NWK7He|=3pjA?Yhnhx~!;SP=6sCZ);-fxBqQFas8pfDo)Igkj_4z0yJMy(0D z9irtyg4}ABtnGG|1UlqC97WVBNQ1S zoZpQgZX+!Xdvh-}qWetiDb`9cc3yIe?h*2xIQ}qQ>GH{S5};LUw?pVYM-%j&zwMn$1>_g&5~u59=!Bq)+FUq@}s4ULb^cSIRl ztgs9naiwLXf!8W%aUYZxOL0_)>8kmxE)=|QBc;ecq$eCZTE{EygH=x|B}tr8Eh7un zw>B>ZeX?@8;?5e(?#KMNF<&=AdlhGyvktw|Vsx65QJd!a6-IpMqg@=$d)%CzW`c-l z;+3iC#`~kRyd!Ep9Le*^-k9z)7_?UgB#2!H3)9!AA-hsO5z=bBrFvc)ts6h=1q+_r zr_@{C5(`EqdM;2LD48A9jt2hC$pquA4k3QoR@G8KR>eWWv3=JY#6qqJa*|D*Q}`;$ zd{=gV8&(2On5F>fl$ejp*EZNK?v zS>A@;d_eh`ZD(Kpr?yp9>*`FAvxeB;mF7((H4a=fWJi+dfHqASGb2ZF$b&wy+xX zY)}qc+WVy@)M9g|6UM*G@J5%prbo9PHc7>9x9Qo-7XS~t17bb^+T;I=mDh)%+egYl rfa-RIa{sAN$SKr|$`{o&)KdX)ZeYL*nOt?H2XNtxyL06){&)TbdJw)c literal 0 HcmV?d00001