mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
feat(producer,studio-server,player): anime.js dependency plumbing (U7)
This commit is contained in:
@@ -1,5 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeSnapshotTimes, tailFrameTime } from "./snapshot.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { computeSnapshotTimes, seekSnapshotPage, tailFrameTime } from "./snapshot.js";
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
function withWindowValue(value: unknown, run: () => void): void {
|
||||
const hadWindow = Reflect.has(globalThis, "window");
|
||||
const previousWindow = Reflect.get(globalThis, "window");
|
||||
Reflect.set(globalThis, "window", value);
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (hadWindow) {
|
||||
Reflect.set(globalThis, "window", previousWindow);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("tailFrameTime", () => {
|
||||
it("backs off ~3% of duration so the final frame isn't the blank exact-end", () => {
|
||||
@@ -59,3 +75,42 @@ describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
|
||||
expect(appendedTail).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seekSnapshotPage", () => {
|
||||
it("flushes GSAP ticker after seeking when GSAP is present", () => {
|
||||
const renderSeek = vi.fn();
|
||||
const tick = vi.fn();
|
||||
const animeUpdate = vi.fn();
|
||||
withWindowValue(
|
||||
{
|
||||
__player: { renderSeek },
|
||||
gsap: { ticker: { tick } },
|
||||
anime: { engine: { update: animeUpdate } },
|
||||
},
|
||||
() => {
|
||||
seekSnapshotPage(1.5);
|
||||
},
|
||||
);
|
||||
|
||||
expect(renderSeek).toHaveBeenCalledWith(1.5);
|
||||
expect(tick).toHaveBeenCalledTimes(1);
|
||||
expect(animeUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flushes anime engine when GSAP ticker is unavailable", () => {
|
||||
const seek = vi.fn();
|
||||
const update = vi.fn();
|
||||
withWindowValue(
|
||||
{
|
||||
__player: { seek },
|
||||
anime: { engine: { update } },
|
||||
},
|
||||
() => {
|
||||
seekSnapshotPage(2);
|
||||
},
|
||||
);
|
||||
|
||||
expect(seek).toHaveBeenCalledWith(2);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,6 +177,22 @@ export function computeSnapshotTimes(
|
||||
return { times: times.map(round), appendedTail: false };
|
||||
}
|
||||
|
||||
export function seekSnapshotPage(t: number): void {
|
||||
const player = (window as any).__player;
|
||||
if (!player) return;
|
||||
const safe = Math.max(0, Number(t) || 0);
|
||||
if (typeof player.renderSeek === "function") {
|
||||
player.renderSeek(safe);
|
||||
} else if (typeof player.seek === "function") {
|
||||
player.seek(safe);
|
||||
}
|
||||
if ((window as any).gsap?.ticker?.tick) {
|
||||
(window as any).gsap.ticker.tick();
|
||||
} else if (typeof (window as any).anime?.engine?.update === "function") {
|
||||
(window as any).anime.engine.update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render key frames from a composition as PNG screenshots.
|
||||
* The agent can Read these to verify its output visually.
|
||||
@@ -406,19 +422,7 @@ async function captureSnapshots(
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const time = positions[i]!;
|
||||
|
||||
await page.evaluate((t: number) => {
|
||||
const player = (window as any).__player;
|
||||
if (!player) return;
|
||||
const safe = Math.max(0, Number(t) || 0);
|
||||
if (typeof player.renderSeek === "function") {
|
||||
player.renderSeek(safe);
|
||||
} else if (typeof player.seek === "function") {
|
||||
player.seek(safe);
|
||||
}
|
||||
if ((window as any).gsap?.ticker?.tick) {
|
||||
(window as any).gsap.ticker.tick();
|
||||
}
|
||||
}, time);
|
||||
await page.evaluate(seekSnapshotPage, time);
|
||||
|
||||
await page.evaluate(`new Promise(function(r) {
|
||||
var settled = false;
|
||||
|
||||
@@ -1,9 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
|
||||
import { loadRuntimeSource } from "./runtimeSource.js";
|
||||
import { isThumbnailTimelineReady, seekThumbnailPage } from "./thumbnailTimeline.js";
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
function withWindowValue(value: unknown, run: () => void): void {
|
||||
const hadWindow = Reflect.has(globalThis, "window");
|
||||
const previousWindow = Reflect.get(globalThis, "window");
|
||||
Reflect.set(globalThis, "window", value);
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (hadWindow) {
|
||||
Reflect.set(globalThis, "window", previousWindow);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("loadRuntimeSource", () => {
|
||||
it("loads runtime source from the published core entrypoint", async () => {
|
||||
await expect(loadRuntimeSource()).resolves.toBe(loadHyperframeRuntimeSource());
|
||||
});
|
||||
});
|
||||
|
||||
describe("thumbnail timeline helpers", () => {
|
||||
it("treats hyperframesAnime registrations as thumbnail-ready", () => {
|
||||
withWindowValue(
|
||||
{
|
||||
hyperframesAnime: {
|
||||
entries: () => [{ id: "main", instance: { seek: vi.fn(), duration: 1000 }, labels: {} }],
|
||||
},
|
||||
},
|
||||
() => {
|
||||
expect(isThumbnailTimelineReady()).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("seeks anime registrations in milliseconds when no GSAP timelines exist", () => {
|
||||
const seek = vi.fn();
|
||||
withWindowValue(
|
||||
{
|
||||
hyperframesAnime: {
|
||||
entries: () => [{ id: "main", instance: { seek, duration: 1000 }, labels: {} }],
|
||||
},
|
||||
},
|
||||
() => {
|
||||
seekThumbnailPage(1.25);
|
||||
},
|
||||
);
|
||||
|
||||
expect(seek).toHaveBeenCalledWith(1250);
|
||||
});
|
||||
|
||||
it("preserves the GSAP fallback path when GSAP timelines are present", () => {
|
||||
const pause = vi.fn();
|
||||
const tick = vi.fn();
|
||||
const animeSeek = vi.fn();
|
||||
withWindowValue(
|
||||
{
|
||||
__timelines: { main: { pause } },
|
||||
gsap: { ticker: { tick } },
|
||||
hyperframesAnime: {
|
||||
entries: () => [{ id: "main", instance: { seek: animeSeek }, labels: {} }],
|
||||
},
|
||||
},
|
||||
() => {
|
||||
seekThumbnailPage(2);
|
||||
},
|
||||
);
|
||||
|
||||
expect(pause).toHaveBeenCalledWith(2);
|
||||
expect(tick).toHaveBeenCalledTimes(1);
|
||||
expect(animeSeek).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { VERSION as version } from "../version.js";
|
||||
import { buildStudioHeadScripts, resolveCliTelemetryDistinctId } from "./telemetryIdentity.js";
|
||||
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
|
||||
import { isThumbnailTimelineReady, seekThumbnailPage } from "./thumbnailTimeline.js";
|
||||
import { isDevMode } from "../utils/env.js";
|
||||
import {
|
||||
createStudioManualEditsRenderBodyScript,
|
||||
@@ -482,33 +483,9 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
page = await browser.newPage();
|
||||
await page.setViewport({ width: opts.width || 1920, height: opts.height || 1080 });
|
||||
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const w = window as Window & {
|
||||
__timelines?: Record<string, unknown>;
|
||||
};
|
||||
return !!(w.__timelines && Object.keys(w.__timelines).length > 0);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
await page.waitForFunction(isThumbnailTimelineReady, { timeout: 5000 }).catch(() => {});
|
||||
// fallow-ignore-next-line code-duplication
|
||||
await page.evaluate((t: number) => {
|
||||
const w = window as Window & {
|
||||
__player?: { seek?: (time: number) => void };
|
||||
__timelines?: Record<string, { pause?: (time?: number) => void }>;
|
||||
gsap?: { ticker?: { tick?: () => void } };
|
||||
};
|
||||
if (typeof w.__player?.seek === "function") {
|
||||
w.__player.seek(t);
|
||||
} else if (w.__timelines) {
|
||||
for (const tl of Object.values(w.__timelines)) {
|
||||
tl?.pause?.(t);
|
||||
}
|
||||
w.gsap?.ticker?.tick?.();
|
||||
}
|
||||
}, opts.seekTime);
|
||||
await page.evaluate(seekThumbnailPage, opts.seekTime);
|
||||
const manifestContent = readStudioManualEditManifestContent(opts.project.dir);
|
||||
await applyStudioManualEditsToThumbnailPage(page, manifestContent, opts.compPath);
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
export function isThumbnailTimelineReady(): boolean {
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
const timelines = Reflect.get(window, "__timelines");
|
||||
if (isRecord(timelines) && Object.keys(timelines).length > 0) return true;
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const api = Reflect.get(window, "hyperframesAnime");
|
||||
if (isRecord(api)) {
|
||||
const entries = Reflect.get(api, "entries");
|
||||
if (typeof entries === "function") {
|
||||
try {
|
||||
const result = entries.call(api);
|
||||
if (Array.isArray(result) && result.length > 0) return true;
|
||||
} catch {
|
||||
/* fall back to __hfAnime below */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const registry = Reflect.get(window, "__hfAnime");
|
||||
return isRecord(registry) && Object.keys(registry).length > 0;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function seekThumbnailPage(t: number): void {
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function animeInstances(source: unknown): unknown[] {
|
||||
if (!isRecord(source)) return [];
|
||||
const values = Object.values(source);
|
||||
return values.map((value) => {
|
||||
if (isRecord(value)) {
|
||||
const instance = Reflect.get(value, "instance");
|
||||
if (isRecord(instance)) return instance;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const player = Reflect.get(window, "__player");
|
||||
if (isRecord(player)) {
|
||||
const seek = Reflect.get(player, "seek");
|
||||
if (typeof seek === "function") {
|
||||
seek.call(player, t);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const timelines = Reflect.get(window, "__timelines");
|
||||
if (isRecord(timelines) && Object.keys(timelines).length > 0) {
|
||||
for (const tl of Object.values(timelines)) {
|
||||
if (!isRecord(tl)) continue;
|
||||
const pause = Reflect.get(tl, "pause");
|
||||
if (typeof pause === "function") pause.call(tl, t);
|
||||
}
|
||||
const gsap = Reflect.get(window, "gsap");
|
||||
const ticker = isRecord(gsap) ? Reflect.get(gsap, "ticker") : undefined;
|
||||
const tick = isRecord(ticker) ? Reflect.get(ticker, "tick") : undefined;
|
||||
if (typeof tick === "function") tick.call(ticker);
|
||||
return;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const api = Reflect.get(window, "hyperframesAnime");
|
||||
let instances: unknown[] = [];
|
||||
if (isRecord(api)) {
|
||||
const entries = Reflect.get(api, "entries");
|
||||
if (typeof entries === "function") {
|
||||
try {
|
||||
const result = entries.call(api);
|
||||
if (Array.isArray(result)) instances = animeInstances(result);
|
||||
} catch {
|
||||
instances = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (instances.length === 0) {
|
||||
instances = animeInstances(Reflect.get(window, "__hfAnime"));
|
||||
}
|
||||
|
||||
const timeMs = Math.max(0, (Number(t) || 0) * 1000);
|
||||
for (const instance of instances) {
|
||||
if (!isRecord(instance)) continue;
|
||||
const seek = Reflect.get(instance, "seek");
|
||||
if (typeof seek === "function") seek.call(instance, timeMs);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user