diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 0537f1705d..1e64ee27d3 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 42d408659f..3b1d298940 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -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; diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index de27921705..804d6fd639 100644 --- a/packages/cli/src/server/studioServer.test.ts +++ b/packages/cli/src/server/studioServer.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index d6d839a06d..6764ef8377 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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; - }; - 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 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(() => { diff --git a/packages/cli/src/server/thumbnailTimeline.ts b/packages/cli/src/server/thumbnailTimeline.ts new file mode 100644 index 0000000000..1e2d51adda --- /dev/null +++ b/packages/cli/src/server/thumbnailTimeline.ts @@ -0,0 +1,96 @@ +export function isThumbnailTimelineReady(): boolean { + function isRecord(value: unknown): value is Record { + 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 { + 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); + } +} diff --git a/packages/player/src/composition-probe.test.ts b/packages/player/src/composition-probe.test.ts index fee97a7bfe..2c0296be36 100644 --- a/packages/player/src/composition-probe.test.ts +++ b/packages/player/src/composition-probe.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { readCompositionSizeFromDocument } from "./composition-probe.js"; +import { describe, expect, it, vi } from "vitest"; +import { CompositionProbe, readCompositionSizeFromDocument } from "./composition-probe.js"; describe("readCompositionSizeFromDocument", () => { it("reads dimensions from the composition root", () => { @@ -24,3 +24,49 @@ describe("readCompositionSizeFromDocument", () => { expect(readCompositionSizeFromDocument(doc)).toBeNull(); }); }); + +describe("CompositionProbe direct timeline resolution", () => { + it("resolves a same-origin anime registration without injecting the runtime bridge", () => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + try { + const doc = document.implementation.createHTMLDocument(); + Object.defineProperty(iframe, "contentDocument", { + configurable: true, + get() { + return doc; + }, + }); + doc.body.innerHTML = + '
'; + const seek = vi.fn(); + const win = Object.create(window); + Reflect.set(win, "__hfAnime", { + main: { + id: "main", + instance: { + seek, + duration: 2500, + play: vi.fn(), + pause: vi.fn(), + }, + labels: {}, + }, + }); + + const probe = new CompositionProbe(iframe, { + onReady: () => undefined, + onError: () => undefined, + }); + const adapter = probe.resolveDirectTimelineAdapterFromWindow(win); + if (!adapter) throw new Error("expected direct anime adapter"); + + expect(adapter.duration()).toBe(2.5); + adapter.seek(1.2, false); + expect(seek).toHaveBeenCalledWith(1200); + expect(adapter.time()).toBe(1.2); + } finally { + iframe.remove(); + } + }); +}); diff --git a/packages/player/src/composition-probe.ts b/packages/player/src/composition-probe.ts index 5ac4c18ddf..b5885f7dea 100644 --- a/packages/player/src/composition-probe.ts +++ b/packages/player/src/composition-probe.ts @@ -14,6 +14,7 @@ import { shouldInjectRuntime } from "./shouldInjectRuntime.js"; import { type DirectTimelineAdapter, type PlaybackDurationAdapter, + buildAnimeDirectTimelineAdapter, isDirectTimelineAdapter, isObjectRecord, isRuntimeDurationAdapter, @@ -156,7 +157,6 @@ export class CompositionProbe { } } - // fallow-ignore-next-line unused-class-member resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null { return this._resolveDirectTimelineAdapterFromWindow(win); } @@ -184,18 +184,34 @@ export class CompositionProbe { private _resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null { if (this.hasRuntimeBridge(win)) return null; + const rootId = this._readRootCompositionId(); + const timelines = Reflect.get(win, "__timelines"); - if (!isObjectRecord(timelines)) return null; + if (isObjectRecord(timelines)) { + const keys = Object.keys(timelines); + if (keys.length > 0) { + const key = rootId && rootId in timelines ? rootId : keys[keys.length - 1]; + const timeline = timelines[key]; + if (isDirectTimelineAdapter(timeline)) return timeline; + } + } - const keys = Object.keys(timelines); - if (keys.length === 0) return null; + return ( + buildAnimeDirectTimelineAdapter(Reflect.get(win, "hyperframesAnime"), rootId) ?? + buildAnimeDirectTimelineAdapter(Reflect.get(win, "__hfAnime"), rootId) + ); + } - const rootId = this._iframe.contentDocument - ?.querySelector("[data-composition-id]") - ?.getAttribute("data-composition-id"); - const key = rootId && rootId in timelines ? rootId : keys[keys.length - 1]; - const timeline = timelines[key]; - return isDirectTimelineAdapter(timeline) ? timeline : null; + private _readRootCompositionId(): string | null { + try { + return ( + this._iframe.contentDocument + ?.querySelector("[data-composition-id]") + ?.getAttribute("data-composition-id") ?? null + ); + } catch { + return null; + } } private _resolvePlaybackDurationAdapter(win: Window): PlaybackDurationAdapter | null { diff --git a/packages/player/src/timeline-adapters.test.ts b/packages/player/src/timeline-adapters.test.ts new file mode 100644 index 0000000000..92166ae8e5 --- /dev/null +++ b/packages/player/src/timeline-adapters.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildAnimeDirectTimelineAdapter, isAnimeRegistryLike } from "./timeline-adapters.js"; + +describe("anime direct timeline adapters", () => { + it("adapts hyperframesAnime entries with seconds-to-milliseconds seeking", () => { + const seek = vi.fn(); + const play = vi.fn(); + const pause = vi.fn(); + const registry = { + entries: () => [ + { + id: "main", + instance: { + seek, + play, + pause, + totalDuration: () => 2500, + duration: 1000, + }, + labels: {}, + }, + ], + }; + + expect(isAnimeRegistryLike(registry)).toBe(true); + const adapter = buildAnimeDirectTimelineAdapter(registry, "main"); + if (!adapter) throw new Error("expected anime adapter"); + + expect(adapter.duration()).toBe(2.5); + expect(adapter.time()).toBe(0); + + adapter.seek(1.25, false); + adapter.play(); + adapter.pause(); + + expect(seek).toHaveBeenCalledWith(1250); + expect(adapter.time()).toBe(1.25); + expect(play).toHaveBeenCalledTimes(1); + expect(pause).toHaveBeenCalledTimes(1); + }); + + it("selects the root id from __hfAnime and falls back to the last registration", () => { + const introSeek = vi.fn(); + const outroSeek = vi.fn(); + const registry = { + intro: { seek: introSeek, duration: 1000 }, + outro: { id: "outro", instance: { seek: outroSeek, duration: 3000 }, labels: {} }, + }; + + const rootAdapter = buildAnimeDirectTimelineAdapter(registry, "intro"); + if (!rootAdapter) throw new Error("expected root anime adapter"); + rootAdapter.seek(0.5); + + const fallbackAdapter = buildAnimeDirectTimelineAdapter(registry, "missing"); + if (!fallbackAdapter) throw new Error("expected fallback anime adapter"); + fallbackAdapter.seek(2); + + expect(introSeek).toHaveBeenCalledWith(500); + expect(outroSeek).toHaveBeenCalledWith(2000); + expect(rootAdapter.duration()).toBe(1); + expect(fallbackAdapter.duration()).toBe(3); + }); +}); diff --git a/packages/player/src/timeline-adapters.ts b/packages/player/src/timeline-adapters.ts index e38a98a94a..91b748400b 100644 --- a/packages/player/src/timeline-adapters.ts +++ b/packages/player/src/timeline-adapters.ts @@ -52,3 +52,152 @@ export function isDirectTimelineAdapter(value: unknown): value is DirectTimeline typeof value.pause === "function" ); } + +interface AnimeInstanceLike { + seek?: (timeMs: number) => unknown; + pause?: () => unknown; + play?: () => unknown; + duration?: number | (() => number); + totalDuration?: number | (() => number); +} + +interface AnimeRegistryEntryLike { + id: string; + instance: AnimeInstanceLike; +} + +export type AnimeRegistryLike = Record | unknown[]; + +function isAnimeInstanceLike(value: unknown): value is AnimeInstanceLike { + if (!isObjectRecord(value)) return false; + return ( + typeof value.seek === "function" || + typeof value.pause === "function" || + typeof value.play === "function" || + "duration" in value || + "totalDuration" in value + ); +} + +function isAnimeRegistrationLike(value: unknown): value is AnimeRegistryEntryLike { + return ( + isObjectRecord(value) && typeof value.id === "string" && isAnimeInstanceLike(value.instance) + ); +} + +function normalizeAnimeEntry(fallbackId: string, value: unknown): AnimeRegistryEntryLike | null { + if (isAnimeRegistrationLike(value)) { + return { + id: value.id || fallbackId, + instance: value.instance, + }; + } + if (isAnimeInstanceLike(value)) { + return { + id: fallbackId, + instance: value, + }; + } + return null; +} + +function isArrayIndexKey(value: string): boolean { + const index = Number(value); + return Number.isInteger(index) && index >= 0 && String(index) === value; +} + +function collectAnimeEntriesFromObject(registry: AnimeRegistryLike): AnimeRegistryEntryLike[] { + const entries: AnimeRegistryEntryLike[] = []; + for (const [id, value] of Object.entries(registry)) { + const fallbackId = Array.isArray(registry) && isArrayIndexKey(id) ? `legacy-${id}` : id; + const normalized = normalizeAnimeEntry(fallbackId, value); + if (normalized) entries.push(normalized); + } + return entries; +} + +// fallow-ignore-next-line complexity +function collectAnimeEntries(source: unknown): AnimeRegistryEntryLike[] { + if (!isObjectRecord(source)) return []; + + const entriesFn = Reflect.get(source, "entries"); + if (typeof entriesFn === "function") { + try { + const rawEntries = entriesFn.call(source); + if (Array.isArray(rawEntries)) { + const normalizedEntries: AnimeRegistryEntryLike[] = []; + for (const [index, value] of rawEntries.entries()) { + const normalized = normalizeAnimeEntry(`entry-${index}`, value); + if (normalized) normalizedEntries.push(normalized); + } + if (normalizedEntries.length > 0) return normalizedEntries; + } + } catch { + return []; + } + } + + return collectAnimeEntriesFromObject(source); +} + +export function isAnimeRegistryLike(value: unknown): value is AnimeRegistryLike { + return collectAnimeEntries(value).length > 0; +} + +function readAnimeDurationMs(instance: AnimeInstanceLike): number | null { + const totalDuration = readAnimeNumericProperty(instance, "totalDuration"); + const duration = totalDuration ?? readAnimeNumericProperty(instance, "duration"); + return duration != null && duration > 0 ? duration : null; +} + +function readAnimeNumericProperty( + instance: AnimeInstanceLike, + key: "duration" | "totalDuration", +): number | null { + // fallow-ignore-next-line code-duplication + const value = instance[key]; + try { + const raw = typeof value === "function" ? value.call(instance) : value; + const numberValue = Number(raw); + return Number.isFinite(numberValue) ? numberValue : null; + } catch { + return null; + } +} + +export function buildAnimeDirectTimelineAdapter( + registry: unknown, + rootId?: string | null, +): DirectTimelineAdapter | null { + const entries = collectAnimeEntries(registry); + if (entries.length === 0) return null; + + const selected = + rootId != null + ? (entries.find((entry) => entry.id === rootId) ?? entries[entries.length - 1]) + : entries[entries.length - 1]; + if (!selected) return null; + + const instance = selected.instance; + let lastSeconds = 0; + return { + duration: () => { + const durationMs = readAnimeDurationMs(instance); + return durationMs == null ? 0 : durationMs / 1000; + }, + time: () => lastSeconds, + seek: (timeInSeconds: number) => { + lastSeconds = Math.max(0, Number(timeInSeconds) || 0); + const seek = instance.seek; + return typeof seek === "function" ? seek.call(instance, lastSeconds * 1000) : undefined; + }, + play: () => { + const play = instance.play; + return typeof play === "function" ? play.call(instance) : undefined; + }, + pause: () => { + const pause = instance.pause; + return typeof pause === "function" ? pause.call(instance) : undefined; + }, + }; +} diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index a545eb4e63..c9126be248 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, mock, beforeAll } from "bun:test"; import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { ANIME_CDN } from "@hyperframes/core"; import { parseHTML } from "linkedom"; import { collectExternalAssets, @@ -495,6 +496,113 @@ describe("detectRenderModeHints", () => { } }); + it("rewrites a missing local anime script to the pinned CDN and inlines it", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-anime-rewrite-missing-")); + writeFileSync( + join(projectDir, "index.html"), + ` + + + +
+ + +`, + ); + const originalFetch = globalThis.fetch; + const fetchMock = mock(async () => { + return new Response("window.anime = { createTimeline: function(){ return {}; } };", { + status: 200, + }); + }); + globalThis.fetch = fetchMock; + + try { + const compiled = await compileForRender( + projectDir, + join(projectDir, "index.html"), + projectDir, + ); + + expect(compiled.html).toContain(`/* inlined: ${ANIME_CDN} */`); + expect(compiled.html).toContain("window.anime ="); + expect(compiled.html).not.toContain("./anime.min.js"); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("leaves an existing local anime script untouched", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-anime-rewrite-present-")); + writeFileSync(join(projectDir, "anime.min.js"), "window.anime = {};"); + writeFileSync( + join(projectDir, "index.html"), + ` + + + +
+ +`, + ); + const originalFetch = globalThis.fetch; + const fetchMock = mock(async () => { + throw new Error("local anime script should not be fetched"); + }); + globalThis.fetch = fetchMock; + + try { + const compiled = await compileForRender( + projectDir, + join(projectDir, "index.html"), + projectDir, + ); + + expect(compiled.html).toContain('src="./anime.min.js"'); + expect(compiled.html).not.toContain(ANIME_CDN); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("keeps the existing missing GSAP rewrite path working", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-gsap-rewrite-missing-")); + writeFileSync( + join(projectDir, "index.html"), + ` + + + +
+ +`, + ); + const originalFetch = globalThis.fetch; + const fetchMock = mock(async () => new Response("window.gsap = {};", { status: 200 })); + globalThis.fetch = fetchMock; + + try { + const compiled = await compileForRender( + projectDir, + join(projectDir, "index.html"), + projectDir, + ); + + expect(compiled.html).toContain( + "/* inlined: https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js */", + ); + expect(compiled.html).toContain("window.gsap = {};"); + expect(compiled.html).not.toContain("./node_modules/gsap/dist/gsap.min.js"); + } finally { + globalThis.fetch = originalFetch; + } + }); + // Shared fixture builder for the assertSubCompositionsUsable / EmptyCompositionError // pre-flight tests below. `subCompFiles` is a map of compositions/-relative // filename to raw file content (empty string / malformed text / valid HTML). diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index e3882384d8..02af880b20 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -21,6 +21,7 @@ import { shouldClampMediaDuration, CSS_URL_RE, isNonRelativeUrl, + ANIME_CDN, type ResolvedDuration, type UnresolvedElement, } from "@hyperframes/core"; @@ -998,7 +999,7 @@ export async function inlineExternalScripts(html: string): Promise { } else { defaultLogger.warn( `[Compiler] WARNING: Failed to download CDN script: ${src} — ${download.reason}. ` + - `The render may fail if this script is required (e.g. GSAP). ` + + `The render may fail if this script is a required animation runtime library. ` + `Consider bundling it locally in your project.`, ); } @@ -1599,6 +1600,27 @@ function rewriteUnresolvableGsapToCdn(html: string, projectDir: string): string ); } +function isLocalAnimeScriptSrc(src: string): boolean { + const pathWithoutQuery = src.split(/[?#]/, 1)[0] ?? src; + return basename(pathWithoutQuery).toLowerCase().includes("anime"); +} + +function rewriteUnresolvableAnimeToCdn(html: string, projectDir: string): string { + return html.replace( + /(]*\bsrc=["'])([^"']+)(["'][^>]*>)/gi, + (full, prefix, src, suffix) => { + if (/^https?:\/\//i.test(src)) return full; + if (!isLocalAnimeScriptSrc(src)) return full; + const absPath = resolve(projectDir, src); + if (existsSync(absPath)) return full; + defaultLogger.info( + `[Compiler] Rewriting missing anime.js script to CDN: ${src} → ${ANIME_CDN}`, + ); + return `${prefix}${ANIME_CDN}${suffix}`; + }, + ); +} + /** * Compile an HTML composition project into a single self-contained HTML string * with all media metadata resolved. @@ -1610,7 +1632,10 @@ export async function compileForRender( downloadDir: string, options: CompileForRenderOptions = {}, ): Promise { - const rawHtml = rewriteUnresolvableGsapToCdn(readFileSync(htmlPath, "utf-8"), projectDir); + const rawHtml = rewriteUnresolvableAnimeToCdn( + rewriteUnresolvableGsapToCdn(readFileSync(htmlPath, "utf-8"), projectDir), + projectDir, + ); // Pre-flight: every data-composition-src reference must resolve to a // usable file before we spend any time compiling, launching a browser, or diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index 6eb83390ee..0b3e498d04 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -4,6 +4,7 @@ import { Hono } from "hono"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { ANIME_CDN } from "../../../core/src/templates/constants"; import { registerPreviewRoutes } from "./preview"; import type { StudioApiAdapter } from "../types"; @@ -165,6 +166,76 @@ describe("registerPreviewRoutes", () => { expect(html).not.toContain("MotionPathPlugin.min.js"); }); + it("injects the pinned anime CDN into when anime APIs are used without a script", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + `
+ + `, + ); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html.split(ANIME_CDN).length - 1).toBe(1); + expect(html.indexOf("")).toBeLessThan(html.indexOf(ANIME_CDN)); + expect(html.indexOf(ANIME_CDN)).toBeLessThan(html.indexOf("")); + }); + + it("does not inject a second anime script when a local or CDN script is already present", async () => { + for (const src of ["./vendor/anime.min.js", ANIME_CDN]) { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + ` + + `, + ); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html.split(src).length - 1).toBe(1); + expect(html.split(ANIME_CDN).length - 1).toBe(src === ANIME_CDN ? 1 : 0); + } + }); + + it("does not inject anime for GSAP-only compositions", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + ` + +
+ + `, + ); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).not.toContain(ANIME_CDN); + }); + it("injects Studio GSAP motion runtime into sub-composition previews with the active source path", async () => { const projectDir = createProjectDir(); mkdirSync(join(projectDir, "compositions"), { recursive: true }); diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index 4a4a5f488c..fbdb6e6acb 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -20,6 +20,15 @@ const GSAP_CDN_SCRIPT = ``; const GSAP_MOTION_PATH_CDN_SCRIPT = ``; +let animeCdnScriptPromise: Promise | null = null; + +function loadAnimeCdnScript(): Promise { + animeCdnScriptPromise ??= import("@hyperframes/core").then( + ({ ANIME_CDN }) => ``, + ); + return animeCdnScriptPromise; +} + function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string { return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir); } @@ -79,6 +88,33 @@ function htmlHasGsap(html: string): boolean { ); } +// fallow-ignore-next-line code-duplication +function htmlHasAnime(html: string): boolean { + // Only match anime references outside