diff --git a/packages/core/src/runtime/adapters/seek-dispatch.ts b/packages/core/src/runtime/adapters/seek-dispatch.ts new file mode 100644 index 000000000..6261cc5a2 --- /dev/null +++ b/packages/core/src/runtime/adapters/seek-dispatch.ts @@ -0,0 +1,36 @@ +import { swallow } from "../diagnostics"; + +/** + * Shared, deduplicated `"hf-seek"` CustomEvent dispatcher for GPU adapters. + * + * Both the Three.js and TypeGPU adapters dispatch the same `"hf-seek"` event + * so that compositions need not know which GPU library they're paired with. + * Without deduplication, a seek to time T would fire two events (one from each + * adapter), doubling per-scrub work in any composition that has both present. + * + * This module deduplicates by tracking the last dispatched time. If the same + * time value is dispatched twice in the same synchronous call stack (e.g. two + * adapters both calling `dispatchSeekEvent(5.0)` without yielding), only the + * first call fires the event. + * + * The deduplication is intentionally coarse (exact float equality). Adapter + * seek paths clamp and normalise time before calling this function, so the + * values that arrive here are already stable. + */ + +let _lastDispatchedTime = -1; + +export function dispatchSeekEvent(time: number): void { + if (time === _lastDispatchedTime) return; + _lastDispatchedTime = time; + try { + window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time } })); + } catch (err) { + swallow("runtime.adapters.seek-dispatch.site1", err); + } +} + +/** Reset internal state — used in tests to prevent cross-test contamination. */ +export function resetSeekDispatchState(): void { + _lastDispatchedTime = -1; +} diff --git a/packages/core/src/runtime/adapters/three.test.ts b/packages/core/src/runtime/adapters/three.test.ts index ac886f889..eb26beb7a 100644 --- a/packages/core/src/runtime/adapters/three.test.ts +++ b/packages/core/src/runtime/adapters/three.test.ts @@ -1,11 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createThreeAdapter } from "./three"; +import { resetSeekDispatchState } from "./seek-dispatch"; const threeWindow = window as Window & { __hfThreeTime?: number }; describe("three adapter", () => { beforeEach(() => { delete threeWindow.__hfThreeTime; + resetSeekDispatchState(); }); it("has correct name", () => { diff --git a/packages/core/src/runtime/adapters/three.ts b/packages/core/src/runtime/adapters/three.ts index 600749a71..c7ddfc9f7 100644 --- a/packages/core/src/runtime/adapters/three.ts +++ b/packages/core/src/runtime/adapters/three.ts @@ -1,5 +1,5 @@ import type { RuntimeDeterministicAdapter } from "../types"; -import { swallow } from "../diagnostics"; +import { dispatchSeekEvent } from "./seek-dispatch"; export function createThreeAdapter(): RuntimeDeterministicAdapter { let forcedTime: number | null = null; @@ -12,12 +12,7 @@ export function createThreeAdapter(): RuntimeDeterministicAdapter { forcedTime = Math.max(0, Number(ctx.time) || 0); lastForcedTime = forcedTime; window.__hfThreeTime = forcedTime; - try { - window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: forcedTime } })); - } catch (err) { - // ignore custom event failures - swallow("runtime.adapters.three.site1", err); - } + dispatchSeekEvent(forcedTime); }, pause: () => { if (forcedTime == null) { diff --git a/packages/core/src/runtime/adapters/typegpu.test.ts b/packages/core/src/runtime/adapters/typegpu.test.ts new file mode 100644 index 000000000..f38e1e5d4 --- /dev/null +++ b/packages/core/src/runtime/adapters/typegpu.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createTypegpuAdapter } from "./typegpu"; +import { resetSeekDispatchState } from "./seek-dispatch"; + +const gpuWindow = window as Window & { __hfTypegpuTime?: number }; + +describe("typegpu adapter", () => { + beforeEach(() => { + delete gpuWindow.__hfTypegpuTime; + // Reset shared dedup state so each test starts with a clean dispatch history + resetSeekDispatchState(); + }); + + it("has correct name", () => { + expect(createTypegpuAdapter().name).toBe("typegpu"); + }); + + it("seek sets __hfTypegpuTime", () => { + const adapter = createTypegpuAdapter(); + adapter.seek({ time: 5 }); + expect(gpuWindow.__hfTypegpuTime).toBe(5); + }); + + it("seek dispatches hf-seek custom event with time", () => { + const adapter = createTypegpuAdapter(); + const handler = vi.fn(); + window.addEventListener("hf-seek", handler); + adapter.seek({ time: 3.5 }); + window.removeEventListener("hf-seek", handler); + expect(handler).toHaveBeenCalledOnce(); + const detail = (handler.mock.calls[0][0] as CustomEvent).detail; + expect(detail.time).toBe(3.5); + }); + + it("seek clamps negative time to 0", () => { + const adapter = createTypegpuAdapter(); + adapter.seek({ time: -5 }); + expect(gpuWindow.__hfTypegpuTime).toBe(0); + }); + + it("seek handles NaN gracefully", () => { + const adapter = createTypegpuAdapter(); + adapter.seek({ time: NaN }); + expect(gpuWindow.__hfTypegpuTime).toBe(0); + }); + + it("multiple seeks to different times dispatch separate events", () => { + const adapter = createTypegpuAdapter(); + const handler = vi.fn(); + window.addEventListener("hf-seek", handler); + adapter.seek({ time: 1 }); + adapter.seek({ time: 2 }); + adapter.seek({ time: 3 }); + window.removeEventListener("hf-seek", handler); + expect(handler).toHaveBeenCalledTimes(3); + }); + + it("duplicate seek to same time fires event only once (dedup)", () => { + const adapter = createTypegpuAdapter(); + const handler = vi.fn(); + window.addEventListener("hf-seek", handler); + adapter.seek({ time: 5 }); + adapter.seek({ time: 5 }); // same time — deduplicated + window.removeEventListener("hf-seek", handler); + expect(handler).toHaveBeenCalledOnce(); + // __hfTypegpuTime is still updated on every seek regardless of dedup + expect(gpuWindow.__hfTypegpuTime).toBe(5); + }); + + it("pause after seek preserves last time", () => { + const adapter = createTypegpuAdapter(); + adapter.seek({ time: 8 }); + adapter.pause(); + expect(gpuWindow.__hfTypegpuTime).toBe(8); + }); + + it("revert resets state", () => { + const adapter = createTypegpuAdapter(); + adapter.seek({ time: 5 }); + adapter.revert!(); + adapter.pause(); + expect(gpuWindow.__hfTypegpuTime).toBe(5); + }); + + it("discover is a no-op and does not throw", () => { + const adapter = createTypegpuAdapter(); + expect(() => adapter.discover()).not.toThrow(); + }); +}); diff --git a/packages/core/src/runtime/adapters/typegpu.ts b/packages/core/src/runtime/adapters/typegpu.ts new file mode 100644 index 000000000..60a5a3ae0 --- /dev/null +++ b/packages/core/src/runtime/adapters/typegpu.ts @@ -0,0 +1,97 @@ +import type { RuntimeDeterministicAdapter } from "../types"; +import { dispatchSeekEvent } from "./seek-dispatch"; + +/** + * TypeGPU / WebGPU adapter for HyperFrames + * + * Enables seekable GPU-rendered compositions built with TypeGPU or raw WebGPU. + * Since WebGPU pipelines are not introspectable from outside (unlike GSAP + * timelines or Lottie instances), this adapter uses the same push+poll pattern + * as the Three.js adapter: + * + * - `window.__hfTypegpuTime` — poll this from your rAF/render loop instead + * of `performance.now()` to get the current seek position in seconds. + * + * - `"hf-seek"` CustomEvent on `window` — listen for this to imperatively + * re-render a single frame at the new seek position. + * + * ## Usage in a composition + * + * ```html + * + * + * ``` + * + * Works with TypeGPU (https://docs.swmansion.com/TypeGPU) and raw WebGPU alike. + * The adapter makes no assumptions about how the pipeline is constructed. + * Multiple canvases / renderers are supported — each listens for the same event. + * + * ## Render-mode determinism + * + * For frame-perfect video renders, call `await device.queue.onSubmittedWorkDone()` + * after each `render(time)` invocation before the frame is captured. This ensures + * the GPU has finished writing to the canvas before the engine screenshots it. + * + * ## Browser feature detection + * + * Always guard against environments where WebGPU is unavailable: + * + * ```js + * if (!navigator.gpu) { /* fallback or early return *\/ } + * const adapter = await navigator.gpu.requestAdapter(); + * if (!adapter) { /* GPU unavailable — software fallback *\/ } + * ``` + * + * The adapter itself does not check for WebGPU support — that is the + * composition author's responsibility. + */ +export function createTypegpuAdapter(): RuntimeDeterministicAdapter { + let forcedTime: number | null = null; + let lastForcedTime = 0; + + return { + name: "typegpu", + + discover: () => { + // WebGPU pipelines have no global registry — nothing to auto-discover. + }, + + seek: (ctx) => { + forcedTime = Math.max(0, Number(ctx.time) || 0); + lastForcedTime = forcedTime; + window.__hfTypegpuTime = forcedTime; + dispatchSeekEvent(forcedTime); + }, + + pause: () => { + if (forcedTime == null) { + forcedTime = Math.max(0, lastForcedTime); + } + }, + + play: () => { + forcedTime = null; + }, + + revert: () => { + forcedTime = null; + lastForcedTime = 0; + }, + }; +} diff --git a/packages/core/src/runtime/adapters/video-texture-compat.ts b/packages/core/src/runtime/adapters/video-texture-compat.ts new file mode 100644 index 000000000..17bc0b716 --- /dev/null +++ b/packages/core/src/runtime/adapters/video-texture-compat.ts @@ -0,0 +1,47 @@ +/** + * Patches `GPUQueue.copyExternalImageToTexture` so that video-backed WebGPU + * effects work in both preview and render mode. + * + * During render, the engine's video-frame injector replaces each `