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 `