Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions examples/tests/animation-sequence.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return new Promise<void>((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' });
}
18 changes: 16 additions & 2 deletions exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
100 changes: 100 additions & 0 deletions src/common/AnimationSequenceTypes.ts
Original file line number Diff line number Diff line change
@@ -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<number, AnimationKeyframeValue>;

/**
* 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;
Comment on lines +82 to +88
/** 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[];
10 changes: 10 additions & 0 deletions src/core/CoreNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading