mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
co-authored by
Claude Opus 4.6
parent
10621e7903
commit
9f8e5ba5a1
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user