From 19580ba21b3e259eb044b90c4f392592ac459377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 29 Apr 2026 14:34:48 -0400 Subject: [PATCH 1/2] feat(core): add anime.js runtime adapter Add a RuntimeDeterministicAdapter for anime.js v4+ that enables seekable frame-accurate rendering of anime.js animations in HyperFrames compositions. The adapter: - Auto-discovers instances from `anime.running` - Accepts manual registration via `window.__hfAnime` - Converts seek time (seconds) to milliseconds for anime.js `.seek()` - Handles multiple instances, gracefully skips failures Follows the same pattern as the existing Lottie, Three.js, WAAPI, and CSS adapters. --- .../core/src/runtime/adapters/animejs.test.ts | 155 ++++++++++++++++++ packages/core/src/runtime/adapters/animejs.ts | 140 ++++++++++++++++ packages/core/src/runtime/init.ts | 2 + packages/core/src/runtime/window.d.ts | 18 ++ 4 files changed, 315 insertions(+) create mode 100644 packages/core/src/runtime/adapters/animejs.test.ts create mode 100644 packages/core/src/runtime/adapters/animejs.ts diff --git a/packages/core/src/runtime/adapters/animejs.test.ts b/packages/core/src/runtime/adapters/animejs.test.ts new file mode 100644 index 000000000..a4ae18ff6 --- /dev/null +++ b/packages/core/src/runtime/adapters/animejs.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/core/src/runtime/adapters/animejs.ts b/packages/core/src/runtime/adapters/animejs.ts new file mode 100644 index 000000000..759f281a9 --- /dev/null +++ b/packages/core/src/runtime/adapters/animejs.ts @@ -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 + * + * + * ``` + * + * Timelines work the same way: + * + * ```html + * + * ``` + * + * 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[]; +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 82c67994d..5c5d8a84d 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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 }), diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index cf9b10555..ceb2ece0e 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -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. From 5780b004bdf37a051170d614b9221e81372f3e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 29 Apr 2026 15:02:19 -0400 Subject: [PATCH 2/2] test(producer): add animejs-adapter regression test Exercises the anime.js v4 runtime adapter with a 6-second composition that covers: - createTimeline() with seek() - stagger() with grid patterns (center + edges) - Multiple overlapping timelines dispatched via __hfAnime - CSS transform animations (rotate, skew, scale, translate) Baseline generated inside Dockerfile.test on the devbox. 100/100 visual checkpoints pass with psnr threshold 30. --- .../producer/tests/animejs-adapter/meta.json | 13 ++ .../animejs-adapter/output/compiled.html | 126 ++++++++++++++++++ .../tests/animejs-adapter/output/output.mp4 | 3 + .../tests/animejs-adapter/src/index.html | 104 +++++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 packages/producer/tests/animejs-adapter/meta.json create mode 100644 packages/producer/tests/animejs-adapter/output/compiled.html create mode 100644 packages/producer/tests/animejs-adapter/output/output.mp4 create mode 100644 packages/producer/tests/animejs-adapter/src/index.html diff --git a/packages/producer/tests/animejs-adapter/meta.json b/packages/producer/tests/animejs-adapter/meta.json new file mode 100644 index 000000000..3c0ae8a1c --- /dev/null +++ b/packages/producer/tests/animejs-adapter/meta.json @@ -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 + } +} diff --git a/packages/producer/tests/animejs-adapter/output/compiled.html b/packages/producer/tests/animejs-adapter/output/compiled.html new file mode 100644 index 000000000..70a86ed2f --- /dev/null +++ b/packages/producer/tests/animejs-adapter/output/compiled.html @@ -0,0 +1,126 @@ + + + + + + +
+ +
anime.js adapter test
+ + +
+
+
+ + +
+ + + + +
+ + diff --git a/packages/producer/tests/animejs-adapter/output/output.mp4 b/packages/producer/tests/animejs-adapter/output/output.mp4 new file mode 100644 index 000000000..9f1fb733d --- /dev/null +++ b/packages/producer/tests/animejs-adapter/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68dbadce0d0178668532a07cf8b68a81956039ccc3c1628d958e10152d8c142f +size 272293 diff --git a/packages/producer/tests/animejs-adapter/src/index.html b/packages/producer/tests/animejs-adapter/src/index.html new file mode 100644 index 000000000..87a57bd81 --- /dev/null +++ b/packages/producer/tests/animejs-adapter/src/index.html @@ -0,0 +1,104 @@ + + + + + + +
+ +
anime.js adapter test
+ + +
+
+
+ + +
+ + + + +
+ +