mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core): add anime.js runtime adapter (#569)
## Summary - Adds a `RuntimeDeterministicAdapter` for anime.js v4+ alongside existing Lottie, Three.js, WAAPI, and CSS adapters - Enables frame-accurate rendering of anime.js animations — the adapter converts seek time (seconds) to milliseconds and calls `.seek(timeMs)` on registered instances - Auto-discovers running instances via `anime.running`; compositions can also register manually via `window.__hfAnime` ## Usage in compositions ```html <script src="https://cdn.jsdelivr.net/npm/animejs@4.0.2/lib/anime.iife.min.js"></script> <script> const anim = anime({ targets: '.box', translateX: 250, rotate: '1turn', duration: 2000, autoplay: false, }); window.__hfAnime = window.__hfAnime || []; window.__hfAnime.push(anim); </script> ``` ## Files changed - `packages/core/src/runtime/adapters/animejs.ts` — adapter implementation - `packages/core/src/runtime/adapters/animejs.test.ts` — 15 unit tests - `packages/core/src/runtime/init.ts` — register adapter in runtime init - `packages/core/src/runtime/window.d.ts` — add `anime` and `__hfAnime` globals ## Test plan - [x] All 15 unit tests pass (`bun run --cwd packages/core test -- --run adapters/animejs`) - [x] Build passes (`bun run build`) - [x] Pre-commit hooks pass (lint, format, typecheck, commitlint) - [x] Manual test: render a composition using anime.js animations
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createAnimeJsAdapter } from "./animejs";
|
||||
|
||||
const animeWindow = window as Window & {
|
||||
anime?: {
|
||||
running: unknown[];
|
||||
};
|
||||
__hfAnime?: unknown[];
|
||||
};
|
||||
|
||||
function createAnimeInstance(opts?: { duration?: number }) {
|
||||
return {
|
||||
seek: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
play: vi.fn(),
|
||||
duration: opts?.duration ?? 2000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("animejs adapter", () => {
|
||||
beforeEach(() => {
|
||||
delete animeWindow.anime;
|
||||
delete animeWindow.__hfAnime;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete animeWindow.anime;
|
||||
delete animeWindow.__hfAnime;
|
||||
});
|
||||
|
||||
it("has correct name", () => {
|
||||
expect(createAnimeJsAdapter().name).toBe("animejs");
|
||||
});
|
||||
|
||||
describe("discover", () => {
|
||||
it("auto-discovers from anime.running", () => {
|
||||
const instance = createAnimeInstance();
|
||||
animeWindow.anime = { running: [instance] };
|
||||
animeWindow.__hfAnime = [];
|
||||
const adapter = createAnimeJsAdapter();
|
||||
adapter.discover();
|
||||
expect(animeWindow.__hfAnime).toContain(instance);
|
||||
});
|
||||
|
||||
it("does not duplicate existing instances", () => {
|
||||
const instance = createAnimeInstance();
|
||||
animeWindow.anime = { running: [instance] };
|
||||
animeWindow.__hfAnime = [instance];
|
||||
const adapter = createAnimeJsAdapter();
|
||||
adapter.discover();
|
||||
expect(animeWindow.__hfAnime).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles no global anime", () => {
|
||||
const adapter = createAnimeJsAdapter();
|
||||
expect(() => adapter.discover()).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles empty running array", () => {
|
||||
animeWindow.anime = { running: [] };
|
||||
const adapter = createAnimeJsAdapter();
|
||||
expect(() => adapter.discover()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("seek", () => {
|
||||
it("seeks with time in milliseconds", () => {
|
||||
const instance = createAnimeInstance();
|
||||
animeWindow.__hfAnime = [instance];
|
||||
const adapter = createAnimeJsAdapter();
|
||||
adapter.seek({ time: 2 });
|
||||
expect(instance.seek).toHaveBeenCalledWith(2000);
|
||||
});
|
||||
|
||||
it("seeks fractional seconds accurately", () => {
|
||||
const instance = createAnimeInstance();
|
||||
animeWindow.__hfAnime = [instance];
|
||||
const adapter = createAnimeJsAdapter();
|
||||
adapter.seek({ time: 0.5 });
|
||||
expect(instance.seek).toHaveBeenCalledWith(500);
|
||||
});
|
||||
|
||||
it("clamps negative time to 0", () => {
|
||||
const instance = createAnimeInstance();
|
||||
animeWindow.__hfAnime = [instance];
|
||||
const adapter = createAnimeJsAdapter();
|
||||
adapter.seek({ time: -3 });
|
||||
expect(instance.seek).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("does nothing with no instances", () => {
|
||||
const adapter = createAnimeJsAdapter();
|
||||
expect(() => adapter.seek({ time: 1 })).not.toThrow();
|
||||
});
|
||||
|
||||
it("seeks multiple instances", () => {
|
||||
const a = createAnimeInstance();
|
||||
const b = createAnimeInstance();
|
||||
animeWindow.__hfAnime = [a, b];
|
||||
const adapter = createAnimeJsAdapter();
|
||||
adapter.seek({ time: 1.5 });
|
||||
expect(a.seek).toHaveBeenCalledWith(1500);
|
||||
expect(b.seek).toHaveBeenCalledWith(1500);
|
||||
});
|
||||
|
||||
it("continues seeking remaining instances if one throws", () => {
|
||||
const bad = {
|
||||
seek: vi.fn(() => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
pause: vi.fn(),
|
||||
play: vi.fn(),
|
||||
};
|
||||
const good = createAnimeInstance();
|
||||
animeWindow.__hfAnime = [bad, good];
|
||||
const adapter = createAnimeJsAdapter();
|
||||
adapter.seek({ time: 1 });
|
||||
expect(good.seek).toHaveBeenCalledWith(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pause", () => {
|
||||
it("pauses all instances", () => {
|
||||
const a = createAnimeInstance();
|
||||
const b = createAnimeInstance();
|
||||
animeWindow.__hfAnime = [a, b];
|
||||
const adapter = createAnimeJsAdapter();
|
||||
adapter.pause();
|
||||
expect(a.pause).toHaveBeenCalled();
|
||||
expect(b.pause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing with no instances", () => {
|
||||
const adapter = createAnimeJsAdapter();
|
||||
expect(() => adapter.pause()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("play", () => {
|
||||
it("plays all instances", () => {
|
||||
const a = createAnimeInstance();
|
||||
animeWindow.__hfAnime = [a];
|
||||
const adapter = createAnimeJsAdapter();
|
||||
adapter.play!();
|
||||
expect(a.play).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("revert", () => {
|
||||
it("does not throw", () => {
|
||||
const adapter = createAnimeJsAdapter();
|
||||
expect(() => adapter.revert!()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { RuntimeDeterministicAdapter } from "../types";
|
||||
|
||||
/**
|
||||
* anime.js adapter for HyperFrames
|
||||
*
|
||||
* Supports anime.js v4+ (the `.seek(timeMs)` API).
|
||||
*
|
||||
* ## Usage in a composition
|
||||
*
|
||||
* ```html
|
||||
* <script src="https://cdn.jsdelivr.net/npm/animejs@4.0.2/lib/anime.iife.min.js"></script>
|
||||
* <script>
|
||||
* const anim = anime({
|
||||
* targets: '.box',
|
||||
* translateX: 250,
|
||||
* rotate: '1turn',
|
||||
* duration: 2000,
|
||||
* autoplay: false,
|
||||
* });
|
||||
* window.__hfAnime = window.__hfAnime || [];
|
||||
* window.__hfAnime.push(anim);
|
||||
* </script>
|
||||
* ```
|
||||
*
|
||||
* Timelines work the same way:
|
||||
*
|
||||
* ```html
|
||||
* <script>
|
||||
* const tl = anime.timeline({ autoplay: false });
|
||||
* tl.add({ targets: '.a', opacity: [0, 1], duration: 500 })
|
||||
* .add({ targets: '.b', translateY: [-40, 0], duration: 400 });
|
||||
* window.__hfAnime = window.__hfAnime || [];
|
||||
* window.__hfAnime.push(tl);
|
||||
* </script>
|
||||
* ```
|
||||
*
|
||||
* Multiple instances are supported — all are seeked in sync.
|
||||
*
|
||||
* ## Auto-discovery
|
||||
*
|
||||
* The adapter also checks `anime.running` for active instances
|
||||
* (useful for compositions that forget to register manually).
|
||||
*/
|
||||
export function createAnimeJsAdapter(): RuntimeDeterministicAdapter {
|
||||
return {
|
||||
name: "animejs",
|
||||
|
||||
discover: () => {
|
||||
try {
|
||||
const animeGlobal = (window as AnimeWindow).anime;
|
||||
if (!animeGlobal || typeof animeGlobal.running === "undefined") return;
|
||||
|
||||
const running = animeGlobal.running;
|
||||
if (!Array.isArray(running) || running.length === 0) return;
|
||||
|
||||
const existing = (window as AnimeWindow).__hfAnime ?? [];
|
||||
const existingSet = new Set(existing);
|
||||
for (const instance of running) {
|
||||
if (!existingSet.has(instance)) {
|
||||
existing.push(instance);
|
||||
}
|
||||
}
|
||||
(window as AnimeWindow).__hfAnime = existing;
|
||||
} catch {
|
||||
// ignore discovery failures
|
||||
}
|
||||
},
|
||||
|
||||
seek: (ctx) => {
|
||||
const timeMs = Math.max(0, (Number(ctx.time) || 0) * 1000);
|
||||
const instances = (window as AnimeWindow).__hfAnime;
|
||||
if (!instances || instances.length === 0) return;
|
||||
|
||||
for (const instance of instances) {
|
||||
try {
|
||||
if (typeof instance.seek === "function") {
|
||||
instance.seek(timeMs);
|
||||
}
|
||||
} catch {
|
||||
// ignore per-instance failures — keep going for other instances
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
const instances = (window as AnimeWindow).__hfAnime;
|
||||
if (!instances || instances.length === 0) return;
|
||||
|
||||
for (const instance of instances) {
|
||||
try {
|
||||
if (typeof instance.pause === "function") {
|
||||
instance.pause();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
play: () => {
|
||||
const instances = (window as AnimeWindow).__hfAnime;
|
||||
if (!instances || instances.length === 0) return;
|
||||
|
||||
for (const instance of instances) {
|
||||
try {
|
||||
if (typeof instance.play === "function") {
|
||||
instance.play();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
revert: () => {
|
||||
// Don't clear __hfAnime — instances are owned by the composition.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Minimal type shapes (no anime.js package dependency) ──────────────────────
|
||||
|
||||
interface AnimeInstance {
|
||||
seek: (timeMs: number) => void;
|
||||
pause: () => void;
|
||||
play: () => void;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface AnimeGlobal {
|
||||
(params: unknown): AnimeInstance;
|
||||
timeline?: (params?: unknown) => AnimeInstance;
|
||||
running: AnimeInstance[];
|
||||
}
|
||||
|
||||
interface AnimeWindow extends Window {
|
||||
anime?: AnimeGlobal;
|
||||
/** anime.js instances registered by compositions for the adapter to seek. */
|
||||
__hfAnime?: AnimeInstance[];
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { installRuntimeControlBridge, postRuntimeMessage } from "./bridge";
|
||||
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
|
||||
import { createCssAdapter } from "./adapters/css";
|
||||
import { createGsapAdapter } from "./adapters/gsap";
|
||||
import { createAnimeJsAdapter } from "./adapters/animejs";
|
||||
import { createLottieAdapter } from "./adapters/lottie";
|
||||
import { createThreeAdapter } from "./adapters/three";
|
||||
import { createWaapiAdapter } from "./adapters/waapi";
|
||||
@@ -1595,6 +1596,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
createCssAdapter({
|
||||
resolveStartSeconds: (element) => resolveStartForElement(element, 0),
|
||||
}),
|
||||
createAnimeJsAdapter(),
|
||||
createLottieAdapter(),
|
||||
createThreeAdapter(),
|
||||
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
|
||||
|
||||
+18
@@ -43,6 +43,24 @@ declare global {
|
||||
};
|
||||
};
|
||||
THREE?: ThreeLike;
|
||||
/**
|
||||
* Global anime.js instance (set by including the anime.iife.min.js script).
|
||||
* The adapter uses `anime.running` for auto-discovery.
|
||||
*/
|
||||
anime?: {
|
||||
(params: unknown): unknown;
|
||||
timeline?: (params?: unknown) => unknown;
|
||||
running: unknown[];
|
||||
};
|
||||
/**
|
||||
* anime.js instances registered by compositions.
|
||||
* The adapter seeks all instances when the player is seeked.
|
||||
*
|
||||
* Push your animation or timeline instance here:
|
||||
* window.__hfAnime = window.__hfAnime || [];
|
||||
* window.__hfAnime.push(anim);
|
||||
*/
|
||||
__hfAnime?: unknown[];
|
||||
/**
|
||||
* Global lottie-web instance (set by including the lottie.min.js script).
|
||||
* The adapter uses `lottie.getRegisteredAnimations()` for auto-discovery.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "animejs-adapter",
|
||||
"description": "Regression guard for the anime.js v4 runtime adapter. Verifies that anime.createTimeline(), stagger(), and seek() render deterministically via the __hfAnime adapter bridge.",
|
||||
"tags": ["regression", "adapter"],
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
"minAudioCorrelation": 0,
|
||||
"maxAudioLagWindows": 1,
|
||||
"renderConfig": {
|
||||
"fps": 30,
|
||||
"workers": 1
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:68dbadce0d0178668532a07cf8b68a81956039ccc3c1628d958e10152d8c142f
|
||||
size 272293
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; background: #0B132B; }
|
||||
[data-composition-id="animejs-test"] { position: relative; overflow: hidden; font-family: monospace; }
|
||||
.box { width: 120px; height: 120px; position: absolute; border-radius: 12px; }
|
||||
.dot { width: 16px; height: 16px; border-radius: 50%; background: #1C2541; position: absolute; }
|
||||
#label { position: absolute; top: 40px; left: 60px; color: #BBFBFF; font-size: 28px; font-weight: bold; opacity: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="animejs-test" data-width="1920" data-height="1080" data-start="0" data-duration="6">
|
||||
|
||||
<div id="label">anime.js adapter test</div>
|
||||
|
||||
<!-- Boxes for transform tests -->
|
||||
<div class="box" id="box-a" style="left:200px;top:300px;background:#5409DA;opacity:0;"></div>
|
||||
<div class="box" id="box-b" style="left:400px;top:300px;background:#FF6D00;opacity:0;"></div>
|
||||
<div class="box" id="box-c" style="left:600px;top:300px;background:#4E71FF;opacity:0;"></div>
|
||||
|
||||
<!-- Dot grid for stagger test -->
|
||||
<div id="dot-container" style="position:absolute;right:100px;top:200px;"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/animejs@4.0.2/lib/anime.iife.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__hfAnime = window.__hfAnime || [];
|
||||
|
||||
// Generate dot grid
|
||||
const container = document.getElementById('dot-container');
|
||||
for (let row = 0; row < 6; row++) {
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'dot';
|
||||
dot.style.left = (col * 40) + 'px';
|
||||
dot.style.top = (row * 40) + 'px';
|
||||
container.appendChild(dot);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline 1: label + box entrances (0–3s)
|
||||
const tl1 = anime.createTimeline({ autoplay: false });
|
||||
tl1
|
||||
.add('#label', { opacity: [0, 1], translateY: [-30, 0], duration: 400, ease: 'out(4)' }, 200)
|
||||
.add('#box-a', { opacity: [0, 1], scale: [0, 1], rotate: [-90, 0], duration: 500, ease: 'out(5)' }, 500)
|
||||
.add('#box-b', { opacity: [0, 1], scale: [0, 1], rotate: [90, 0], duration: 500, ease: 'out(5)' }, 700)
|
||||
.add('#box-c', { opacity: [0, 1], scale: [0, 1], translateY: [80, 0], duration: 500, ease: 'out(3)' }, 900);
|
||||
|
||||
// Timeline 2: CSS transforms on boxes (2–5s)
|
||||
const tl2 = anime.createTimeline({ autoplay: false });
|
||||
tl2
|
||||
.add('#box-a', { rotate: 360, duration: 2000, ease: 'inOut(2)' }, 0)
|
||||
.add('#box-b', { skewX: 25, scale: 1.3, duration: 1500, ease: 'inOut(3)' }, 200)
|
||||
.add('#box-c', { rotate: -180, translateX: 100, duration: 1800, ease: 'inOut(2)' }, 400);
|
||||
|
||||
// Timeline 3: stagger wave on dots (3–6s)
|
||||
const tl3 = anime.createTimeline({ autoplay: false });
|
||||
tl3.add('.dot', {
|
||||
backgroundColor: '#5409DA',
|
||||
scale: [1, 2.5, 1],
|
||||
duration: 600,
|
||||
ease: 'out(2)',
|
||||
delay: anime.stagger(30, { grid: [8, 6], from: 'center' }),
|
||||
}, 0);
|
||||
tl3.add('.dot', {
|
||||
backgroundColor: '#FF6D00',
|
||||
scale: [1, 2, 1],
|
||||
duration: 500,
|
||||
ease: 'in(2)',
|
||||
delay: anime.stagger(25, { grid: [8, 6], from: 'edges' }),
|
||||
}, 1500);
|
||||
|
||||
// Scene mapping for the adapter
|
||||
const scenes = [
|
||||
{ tl: tl1, start: 0, end: 3 },
|
||||
{ tl: tl2, start: 2, end: 5 },
|
||||
{ tl: tl3, start: 3, end: 6 },
|
||||
];
|
||||
|
||||
window.__hfAnime = [{
|
||||
seek: function(globalTimeMs) {
|
||||
const t = globalTimeMs / 1000;
|
||||
for (const s of scenes) {
|
||||
if (t >= s.start && t < s.end) {
|
||||
s.tl.seek((t - s.start) * 1000);
|
||||
} else if (t >= s.end) {
|
||||
s.tl.seek((s.end - s.start) * 1000);
|
||||
} else {
|
||||
s.tl.seek(0);
|
||||
}
|
||||
}
|
||||
},
|
||||
pause: function() { for (const s of scenes) s.tl.pause(); },
|
||||
play: function() { for (const s of scenes) s.tl.play(); },
|
||||
}];
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines['animejs-test'] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user