Skip to content
Merged
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
155 changes: 155 additions & 0 deletions packages/core/src/runtime/adapters/animejs.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
140 changes: 140 additions & 0 deletions packages/core/src/runtime/adapters/animejs.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1595,6 +1596,7 @@ export function initSandboxRuntimeModular(): void {
createCssAdapter({
resolveStartSeconds: (element) => resolveStartForElement(element, 0),
}),
createAnimeJsAdapter(),
createLottieAdapter(),
createThreeAdapter(),
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/runtime/window.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions packages/producer/tests/animejs-adapter/meta.json
Original file line number Diff line number Diff line change
@@ -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
}
}
126 changes: 126 additions & 0 deletions packages/producer/tests/animejs-adapter/output/compiled.html

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/producer/tests/animejs-adapter/output/output.mp4
Git LFS file not shown
Loading
Loading