initial code (#2)

* feat: initial code port from hyperframes-internal

Port all OSS-ready packages from the internal monorepo:
- @hyperframes/core — shared types, HTML generation, GSAP utilities, runtime
- @hyperframes/cli — CLI for creating, previewing, and rendering compositions
- @hyperframes/engine — framework-agnostic rendering engine (BeginFrame + FFmpeg)
- @hyperframes/producer — video rendering pipeline (Puppeteer + FFmpeg)
- @hyperframes/ui-player — browser-based video player component
- @hyperframes/studio — composition editor (React frontend + Hono backend)

Includes regression test suite with Docker-based test harness.

All HeyGen-internal references, deployment infrastructure, and
proprietary assets have been removed. Package names migrated
from @app/* to @hyperframes/*.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: scrub internal codenames and stale references from OSS port

- Replace static.heygen.ai runtime URLs in test fixtures
- Remove internal CDN publish script (publish-hyperframe-runtime.ts)
- Replace sandbox-studio, sandbox-interceptor, __magicEditRuntime
  with neutral names (studio, hyperframe-runtime, __hyperframeRuntime)
- Fix stale Vault API / localhost references in docs
- Remove broken deprecated_studio link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove remaining internal codenames and stale references

- Delete stale producer README.md and PIPELINE.md (referenced nonexistent files)
- Replace "Cerberus" codename with "HyperFrames" in test design reviews
- Replace magic-edit postMessage identifiers with hf-preview/hf-parent
- Rename debug-magic-edit-timeline.ts to debug-timeline.ts
- Replace "Motion Cut" with "HyperFrames" in Timeline comments
- Fix studio/CLI references to nonexistent archive package
  (use local data/projects/ dir, stub render proxy)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-03-21 22:43:56 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 10621e7903
commit 9f8e5ba5a1
401 changed files with 54545 additions and 2 deletions
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createCssAdapter } from "./css";
describe("css adapter", () => {
it("has correct name", () => {
expect(createCssAdapter().name).toBe("css");
});
it("discover finds elements with CSS animations", () => {
const el = document.createElement("div");
el.style.animationName = "fadeIn";
el.style.animationDuration = "1s";
document.body.appendChild(el);
const adapter = createCssAdapter();
adapter.discover();
// discover doesn't crash — that's the main assertion
document.body.removeChild(el);
});
it("seek sets animationDelay and pauses", () => {
const el = document.createElement("div");
el.setAttribute("data-start", "1");
el.style.animationName = "slide";
el.style.animationDuration = "2s";
document.body.appendChild(el);
// We need to mock getComputedStyle since jsdom doesn't compute animations
const origGetComputedStyle = window.getComputedStyle;
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
const real = origGetComputedStyle(target);
return {
...real,
animationName: target === el ? "slide" : "none",
} as CSSStyleDeclaration;
});
const adapter = createCssAdapter();
adapter.discover();
adapter.seek({ time: 3 });
expect(el.style.animationPlayState).toBe("paused");
// localTime = max(0, 3 - 1) = 2
expect(el.style.animationDelay).toBe("-2s");
document.body.removeChild(el);
vi.restoreAllMocks();
});
it("seek uses resolveStartSeconds when provided", () => {
const el = document.createElement("div");
el.style.animationName = "bounce";
document.body.appendChild(el);
vi.spyOn(window, "getComputedStyle").mockImplementation(() => {
return { animationName: "bounce" } as CSSStyleDeclaration;
});
const adapter = createCssAdapter({ resolveStartSeconds: () => 2 });
adapter.discover();
adapter.seek({ time: 5 });
expect(el.style.animationPlayState).toBe("paused");
// localTime = max(0, 5 - 2) = 3
expect(el.style.animationDelay).toBe("-3s");
document.body.removeChild(el);
vi.restoreAllMocks();
});
it("pause restores base play state", () => {
const el = document.createElement("div");
el.style.animationName = "spin";
el.style.animationPlayState = "running";
document.body.appendChild(el);
vi.spyOn(window, "getComputedStyle").mockImplementation(() => {
return { animationName: "spin" } as CSSStyleDeclaration;
});
const adapter = createCssAdapter();
adapter.discover();
adapter.seek({ time: 1 });
expect(el.style.animationPlayState).toBe("paused");
adapter.pause();
expect(el.style.animationPlayState).toBe("running");
document.body.removeChild(el);
vi.restoreAllMocks();
});
it("revert clears entries", () => {
const adapter = createCssAdapter();
adapter.revert!();
// Should not crash when seeking after revert
expect(() => adapter.seek({ time: 1 })).not.toThrow();
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { RuntimeDeterministicAdapter } from "../types";
export function createCssAdapter(params?: {
resolveStartSeconds?: (element: Element) => number;
}): RuntimeDeterministicAdapter {
let entries: Array<{
el: HTMLElement;
baseDelay: string;
basePlayState: string;
}> = [];
return {
name: "css",
discover: () => {
entries = [];
const all = document.querySelectorAll("*");
for (const rawEl of all) {
if (!(rawEl instanceof HTMLElement)) continue;
const style = window.getComputedStyle(rawEl);
if (!style.animationName || style.animationName === "none") continue;
entries.push({
el: rawEl,
baseDelay: rawEl.style.animationDelay || "",
basePlayState: rawEl.style.animationPlayState || "",
});
}
},
seek: (ctx) => {
const time = Number(ctx.time) || 0;
for (const entry of entries) {
if (!entry.el.isConnected) continue;
const start = params?.resolveStartSeconds
? params.resolveStartSeconds(entry.el)
: Number.parseFloat(entry.el.getAttribute("data-start") ?? "0") || 0;
const localTime = Math.max(0, time - start);
entry.el.style.animationPlayState = "paused";
entry.el.style.animationDelay = `-${localTime.toFixed(3)}s`;
}
},
pause: () => {
for (const entry of entries) {
if (!entry.el.isConnected) continue;
entry.el.style.animationPlayState = entry.basePlayState || "paused";
if (entry.baseDelay) entry.el.style.animationDelay = entry.baseDelay;
}
},
revert: () => {
entries = [];
},
};
}
@@ -0,0 +1,78 @@
import { describe, it, expect, vi } from "vitest";
import { createGsapAdapter } from "./gsap";
import type { RuntimeTimelineLike } from "../types";
function createMockTimeline(): RuntimeTimelineLike {
return {
play: vi.fn(),
pause: vi.fn(),
seek: vi.fn(),
totalTime: vi.fn(),
time: vi.fn(() => 0),
duration: vi.fn(() => 10),
add: vi.fn(),
paused: vi.fn(),
set: vi.fn(),
};
}
describe("gsap adapter", () => {
it("has correct name", () => {
const adapter = createGsapAdapter({ getTimeline: () => null });
expect(adapter.name).toBe("gsap");
});
it("seek uses totalTime when available", () => {
const timeline = createMockTimeline();
const adapter = createGsapAdapter({ getTimeline: () => timeline });
adapter.seek({ time: 5 });
expect(timeline.pause).toHaveBeenCalled();
expect(timeline.totalTime).toHaveBeenCalledWith(5, false);
expect(timeline.seek).not.toHaveBeenCalled();
});
it("seek falls back to .seek() when totalTime is missing", () => {
const timeline = createMockTimeline();
(timeline as Record<string, unknown>).totalTime = undefined;
const adapter = createGsapAdapter({ getTimeline: () => timeline });
adapter.seek({ time: 3 });
expect(timeline.pause).toHaveBeenCalled();
expect(timeline.seek).toHaveBeenCalledWith(3, false);
});
it("seek clamps negative time to 0", () => {
const timeline = createMockTimeline();
const adapter = createGsapAdapter({ getTimeline: () => timeline });
adapter.seek({ time: -5 });
expect(timeline.totalTime).toHaveBeenCalledWith(0, false);
});
it("seek handles NaN time", () => {
const timeline = createMockTimeline();
const adapter = createGsapAdapter({ getTimeline: () => timeline });
adapter.seek({ time: NaN });
expect(timeline.totalTime).toHaveBeenCalledWith(0, false);
});
it("seek does nothing without timeline", () => {
const adapter = createGsapAdapter({ getTimeline: () => null });
expect(() => adapter.seek({ time: 5 })).not.toThrow();
});
it("pause pauses the timeline", () => {
const timeline = createMockTimeline();
const adapter = createGsapAdapter({ getTimeline: () => timeline });
adapter.pause();
expect(timeline.pause).toHaveBeenCalled();
});
it("pause does nothing without timeline", () => {
const adapter = createGsapAdapter({ getTimeline: () => null });
expect(() => adapter.pause()).not.toThrow();
});
it("discover is a no-op", () => {
const adapter = createGsapAdapter({ getTimeline: () => null });
expect(() => adapter.discover()).not.toThrow();
});
});
@@ -0,0 +1,28 @@
import type { RuntimeDeterministicAdapter, RuntimeTimelineLike } from "../types";
type GsapAdapterDeps = {
getTimeline: () => RuntimeTimelineLike | null;
};
export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAdapter {
return {
name: "gsap",
discover: () => {},
seek: (ctx) => {
const timeline = deps.getTimeline();
if (!timeline) return;
timeline.pause();
const safeTime = Math.max(0, Number(ctx.time) || 0);
if (typeof timeline.totalTime === "function") {
timeline.totalTime(safeTime, false);
} else {
timeline.seek(safeTime, false);
}
},
pause: () => {
const timeline = deps.getTimeline();
if (!timeline) return;
timeline.pause();
},
};
}
@@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createLottieAdapter } from "./lottie";
const lottieWindow = window as Window & {
lottie?: {
loadAnimation: (params: unknown) => unknown;
getRegisteredAnimations: () => unknown[];
};
__hfLottie?: unknown[];
};
function createLottieWebAnim(opts?: { totalFrames?: number; frameRate?: number }) {
return {
play: vi.fn(),
pause: vi.fn(),
stop: vi.fn(),
goToAndStop: vi.fn(),
goToAndPlay: vi.fn(),
totalFrames: opts?.totalFrames ?? 120,
frameRate: opts?.frameRate ?? 30,
};
}
function createDotLottiePlayer(opts?: { totalFrames?: number; frameRate?: number; duration?: number }) {
return {
play: vi.fn(),
pause: vi.fn(),
totalFrames: opts?.totalFrames ?? 60,
frameRate: opts?.frameRate ?? 30,
duration: opts?.duration ?? 2,
setCurrentRawFrameValue: vi.fn(),
seek: vi.fn(),
};
}
describe("lottie adapter", () => {
beforeEach(() => {
delete lottieWindow.lottie;
delete lottieWindow.__hfLottie;
});
afterEach(() => {
delete lottieWindow.lottie;
delete lottieWindow.__hfLottie;
});
it("has correct name", () => {
expect(createLottieAdapter().name).toBe("lottie");
});
describe("discover", () => {
it("auto-discovers lottie-web animations", () => {
const anim = createLottieWebAnim();
lottieWindow.lottie = {
loadAnimation: vi.fn(),
getRegisteredAnimations: () => [anim],
};
lottieWindow.__hfLottie = [];
const adapter = createLottieAdapter();
adapter.discover();
expect(lottieWindow.__hfLottie).toContain(anim);
});
it("does not duplicate existing animations", () => {
const anim = createLottieWebAnim();
lottieWindow.lottie = {
loadAnimation: vi.fn(),
getRegisteredAnimations: () => [anim],
};
lottieWindow.__hfLottie = [anim];
const adapter = createLottieAdapter();
adapter.discover();
expect(lottieWindow.__hfLottie).toHaveLength(1);
});
it("handles no global lottie", () => {
const adapter = createLottieAdapter();
expect(() => adapter.discover()).not.toThrow();
});
});
describe("seek", () => {
it("seeks lottie-web with goToAndStop in ms", () => {
const anim = createLottieWebAnim();
lottieWindow.__hfLottie = [anim];
const adapter = createLottieAdapter();
adapter.seek({ time: 2 });
expect(anim.goToAndStop).toHaveBeenCalledWith(2000, false);
});
it("seeks dotlottie-web v2 with setCurrentRawFrameValue", () => {
const player = createDotLottiePlayer({ totalFrames: 60, frameRate: 30 });
lottieWindow.__hfLottie = [player];
const adapter = createLottieAdapter();
adapter.seek({ time: 1 });
// frame = time * fps = 1 * 30 = 30
expect(player.setCurrentRawFrameValue).toHaveBeenCalledWith(30);
});
it("clamps frame to totalFrames - 1", () => {
const player = createDotLottiePlayer({ totalFrames: 60, frameRate: 30 });
lottieWindow.__hfLottie = [player];
const adapter = createLottieAdapter();
adapter.seek({ time: 10 }); // frame = 300, but totalFrames = 60
expect(player.setCurrentRawFrameValue).toHaveBeenCalledWith(59);
});
it("does nothing with no instances", () => {
const adapter = createLottieAdapter();
expect(() => adapter.seek({ time: 1 })).not.toThrow();
});
it("clamps negative time to 0", () => {
const anim = createLottieWebAnim();
lottieWindow.__hfLottie = [anim];
const adapter = createLottieAdapter();
adapter.seek({ time: -5 });
expect(anim.goToAndStop).toHaveBeenCalledWith(0, false);
});
});
describe("pause", () => {
it("pauses lottie-web animation", () => {
const anim = createLottieWebAnim();
lottieWindow.__hfLottie = [anim];
const adapter = createLottieAdapter();
adapter.pause();
expect(anim.pause).toHaveBeenCalled();
});
it("pauses dotlottie player", () => {
const player = createDotLottiePlayer();
lottieWindow.__hfLottie = [player];
const adapter = createLottieAdapter();
adapter.pause();
expect(player.pause).toHaveBeenCalled();
});
});
describe("play", () => {
it("plays lottie-web animation", () => {
const anim = createLottieWebAnim();
lottieWindow.__hfLottie = [anim];
const adapter = createLottieAdapter();
adapter.play!();
expect(anim.play).toHaveBeenCalled();
});
});
describe("revert", () => {
it("does not throw", () => {
const adapter = createLottieAdapter();
expect(() => adapter.revert!()).not.toThrow();
});
});
});
@@ -0,0 +1,202 @@
import type { RuntimeDeterministicAdapter } from "../types";
/**
* Lottie adapter for HyperFrames
*
* Supports lottie-web and @lottiefiles/dotlottie-web.
*
* ## Usage in a composition
*
* ### lottie-web (classic):
* ```html
* <script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js"></script>
* <div id="anim"></div>
* <script>
* const anim = lottie.loadAnimation({
* container: document.getElementById('anim'),
* renderer: 'svg',
* loop: false,
* autoplay: false,
* path: 'animation.json',
* });
* // Register so the adapter can seek it:
* window.__hfLottie = window.__hfLottie || [];
* window.__hfLottie.push(anim);
* </script>
* ```
*
* ### @lottiefiles/dotlottie-web:
* ```html
* <script src="https://unpkg.com/@lottiefiles/dotlottie-web"></script>
* <canvas id="anim"></canvas>
* <script>
* const player = new DotLottie({
* canvas: document.getElementById('anim'),
* src: 'animation.lottie',
* autoplay: false,
* });
* window.__hfLottie = window.__hfLottie || [];
* window.__hfLottie.push(player);
* </script>
* ```
*
* Multiple animations are supported — all are seeked in sync.
*
* ## Auto-discovery
*
* The adapter also attempts to auto-discover Lottie animations registered
* via the global `lottie` object, so compositions that call
* `lottie.loadAnimation(...)` without manually registering still work.
*/
export function createLottieAdapter(): RuntimeDeterministicAdapter {
return {
name: "lottie",
discover: () => {
// Auto-discover animations registered via the global lottie API.
// lottie-web exposes registered animations at lottie.getRegisteredAnimations().
try {
const lottieGlobal = (window as LottieWindow).lottie;
if (lottieGlobal && typeof lottieGlobal.getRegisteredAnimations === "function") {
const registered = lottieGlobal.getRegisteredAnimations();
if (Array.isArray(registered) && registered.length > 0) {
const existing = (window as LottieWindow).__hfLottie ?? [];
const existingSet = new Set(existing);
for (const anim of registered) {
if (!existingSet.has(anim)) {
existing.push(anim);
}
}
(window as LottieWindow).__hfLottie = existing;
}
}
} catch {
// ignore discovery failures
}
},
seek: (ctx) => {
const time = Math.max(0, Number(ctx.time) || 0);
const instances = (window as LottieWindow).__hfLottie;
if (!instances || instances.length === 0) return;
for (const anim of instances) {
try {
if (isLottieWebAnimation(anim)) {
// lottie-web: AnimationItem
// goToAndStop(value, isFrame) — isFrame=true means frame number, false means time in ms
// We use isFrame=false and pass time in ms for precision.
anim.goToAndStop(time * 1000, false);
} else if (isDotLottiePlayer(anim)) {
// @lottiefiles/dotlottie-web: DotLottie
// .seek(frame) — frame is 0-100 percentage OR frame number depending on version
// Newer versions use setFrame(frame) or seek(percentage)
if (typeof anim.setCurrentRawFrameValue === "function") {
// dotlottie-web v2+: direct frame setter
const totalFrames = anim.totalFrames ?? 0;
const fps = anim.frameRate ?? 30;
const frame = time * fps;
if (totalFrames > 0) {
anim.setCurrentRawFrameValue(Math.min(frame, totalFrames - 1));
}
} else if (typeof anim.seek === "function") {
// dotlottie-web v1: seek(percentage 0-100)
const duration = anim.duration ?? 1;
const percentage = Math.min(100, (time / duration) * 100);
anim.seek(percentage);
}
}
} catch {
// ignore per-animation failures — keep going for other instances
}
}
},
pause: () => {
const instances = (window as LottieWindow).__hfLottie;
if (!instances || instances.length === 0) return;
for (const anim of instances) {
try {
if (isLottieWebAnimation(anim)) {
anim.pause();
} else if (isDotLottiePlayer(anim)) {
anim.pause();
}
} catch {
// ignore
}
}
},
play: () => {
const instances = (window as LottieWindow).__hfLottie;
if (!instances || instances.length === 0) return;
for (const anim of instances) {
try {
if (isLottieWebAnimation(anim)) {
anim.play();
} else if (isDotLottiePlayer(anim)) {
anim.play();
}
} catch {
// ignore
}
}
},
revert: () => {
// Don't clear __hfLottie — the animation objects are owned by the composition.
// Just let them be garbage collected naturally.
},
};
}
// ── Type guards ────────────────────────────────────────────────────────────────
function isLottieWebAnimation(anim: unknown): anim is LottieWebAnimation {
return typeof anim === "object" && anim !== null && typeof (anim as LottieWebAnimation).goToAndStop === "function";
}
function isDotLottiePlayer(anim: unknown): anim is DotLottiePlayer {
return (
typeof anim === "object" &&
anim !== null &&
typeof (anim as DotLottiePlayer).pause === "function" &&
("totalFrames" in (anim as object) || "duration" in (anim as object))
);
}
// ── Minimal type shapes (no lottie package dependency) ─────────────────────────
interface LottieWebAnimation {
play: () => void;
pause: () => void;
stop: () => void;
goToAndStop: (value: number, isFrame: boolean) => void;
goToAndPlay: (value: number, isFrame: boolean) => void;
totalFrames: number;
frameRate: number;
}
interface LottieWebGlobal {
loadAnimation: (params: unknown) => LottieWebAnimation;
getRegisteredAnimations: () => LottieWebAnimation[];
}
interface DotLottiePlayer {
play: () => void;
pause: () => void;
seek?: (percentage: number) => void;
setCurrentRawFrameValue?: (frame: number) => void;
totalFrames?: number;
frameRate?: number;
duration?: number;
}
interface LottieWindow extends Window {
lottie?: LottieWebGlobal;
/** Compositions register their Lottie animation instances here for the adapter to seek. */
__hfLottie?: Array<LottieWebAnimation | DotLottiePlayer>;
}
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createThreeAdapter } from "./three";
const threeWindow = window as Window & { __hfThreeTime?: number };
describe("three adapter", () => {
beforeEach(() => {
delete threeWindow.__hfThreeTime;
});
it("has correct name", () => {
expect(createThreeAdapter().name).toBe("three");
});
it("seek sets __hfThreeTime", () => {
const adapter = createThreeAdapter();
adapter.seek({ time: 5 });
expect(threeWindow.__hfThreeTime).toBe(5);
});
it("seek dispatches hf-seek custom event", () => {
const adapter = createThreeAdapter();
const handler = vi.fn();
window.addEventListener("hf-seek", handler);
adapter.seek({ time: 3 });
window.removeEventListener("hf-seek", handler);
expect(handler).toHaveBeenCalled();
const detail = (handler.mock.calls[0][0] as CustomEvent).detail;
expect(detail.time).toBe(3);
});
it("seek clamps negative time to 0", () => {
const adapter = createThreeAdapter();
adapter.seek({ time: -10 });
expect(threeWindow.__hfThreeTime).toBe(0);
});
it("pause retains last forced time", () => {
const adapter = createThreeAdapter();
adapter.seek({ time: 7 });
adapter.pause();
// Internal state preserved — no crash
expect(threeWindow.__hfThreeTime).toBe(7);
});
it("play releases forced time", () => {
const adapter = createThreeAdapter();
adapter.seek({ time: 7 });
adapter.play!();
// After play, forced time is released
});
it("revert resets all state", () => {
const adapter = createThreeAdapter();
adapter.seek({ time: 5 });
adapter.revert!();
// After revert, forcedTime and lastForcedTime are reset
});
it("discover is a no-op", () => {
const adapter = createThreeAdapter();
expect(() => adapter.discover()).not.toThrow();
});
});
@@ -0,0 +1,33 @@
import type { RuntimeDeterministicAdapter } from "../types";
export function createThreeAdapter(): RuntimeDeterministicAdapter {
let forcedTime: number | null = null;
let lastForcedTime = 0;
return {
name: "three",
discover: () => {},
seek: (ctx) => {
forcedTime = Math.max(0, Number(ctx.time) || 0);
lastForcedTime = forcedTime;
window.__hfThreeTime = forcedTime;
try {
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: forcedTime } }));
} catch {
// ignore custom event failures
}
},
pause: () => {
if (forcedTime == null) {
forcedTime = Math.max(0, lastForcedTime);
}
},
play: () => {
forcedTime = null;
},
revert: () => {
forcedTime = null;
lastForcedTime = 0;
},
};
}
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createWaapiAdapter } from "./waapi";
describe("waapi adapter", () => {
it("has correct name", () => {
expect(createWaapiAdapter().name).toBe("waapi");
});
it("seek pauses and sets currentTime on all animations", () => {
const mockAnim = { pause: vi.fn(), currentTime: 0 };
(document as any).getAnimations = vi.fn(() => [mockAnim]);
const adapter = createWaapiAdapter();
adapter.seek({ time: 2.5 });
expect(mockAnim.pause).toHaveBeenCalled();
expect(mockAnim.currentTime).toBe(2500); // seconds → ms
delete (document as any).getAnimations;
});
it("seek clamps negative time to 0", () => {
const mockAnim = { pause: vi.fn(), currentTime: 0 };
(document as any).getAnimations = vi.fn(() => [mockAnim]);
const adapter = createWaapiAdapter();
adapter.seek({ time: -3 });
expect(mockAnim.currentTime).toBe(0);
delete (document as any).getAnimations;
});
it("pause pauses all animations", () => {
const mockAnim = { pause: vi.fn(), currentTime: 0 };
(document as any).getAnimations = vi.fn(() => [mockAnim]);
const adapter = createWaapiAdapter();
adapter.pause();
expect(mockAnim.pause).toHaveBeenCalled();
delete (document as any).getAnimations;
});
it("handles missing getAnimations API", () => {
const original = document.getAnimations;
(document as Record<string, unknown>).getAnimations = undefined;
const adapter = createWaapiAdapter();
expect(() => adapter.seek({ time: 1 })).not.toThrow();
expect(() => adapter.pause()).not.toThrow();
document.getAnimations = original;
});
it("handles animation that throws on pause", () => {
const mockAnim = {
pause: vi.fn(() => { throw new Error("invalid state"); }),
currentTime: 0,
};
(document as any).getAnimations = vi.fn(() => [mockAnim]);
const adapter = createWaapiAdapter();
expect(() => adapter.seek({ time: 1 })).not.toThrow();
delete (document as any).getAnimations;
});
it("discover is a no-op", () => {
const adapter = createWaapiAdapter();
expect(() => adapter.discover()).not.toThrow();
});
});
@@ -0,0 +1,30 @@
import type { RuntimeDeterministicAdapter } from "../types";
export function createWaapiAdapter(): RuntimeDeterministicAdapter {
return {
name: "waapi",
discover: () => {},
seek: (ctx) => {
if (!document.getAnimations) return;
const timeMs = Math.max(0, (Number(ctx.time) || 0) * 1000);
for (const animation of document.getAnimations()) {
try {
animation.pause();
animation.currentTime = timeMs;
} catch {
// ignore animation edge-cases
}
}
},
pause: () => {
if (!document.getAnimations) return;
for (const animation of document.getAnimations()) {
try {
animation.pause();
} catch {
// ignore animation edge-cases
}
}
},
};
}