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:
Miguel Ángel
2026-04-29 21:30:17 +02:00
committed by GitHub
parent bcd7230557
commit f9d38a1542
8 changed files with 561 additions and 0 deletions
@@ -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
View File
@@ -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
View File
@@ -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.