feat(producer,studio-server,player): anime.js dependency plumbing (U7)

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 17:23:34 -04:00
parent 3e1ecf77bb
commit f35e7d8216
14 changed files with 804 additions and 66 deletions
+57 -2
View File
@@ -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);
});
});
+17 -13
View File
@@ -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;
+71 -1
View File
@@ -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();
});
});
+3 -26
View File
@@ -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);
}
}