feat(core): add TypeGPU/WebGPU runtime adapter (#755)

* feat(core): add TypeGPU/WebGPU runtime adapter

Adds a deterministic seek adapter for compositions that render with
TypeGPU or raw WebGPU. Follows the same push+poll pattern as the
Three.js adapter:

- Sets `window.__hfTypegpuTime` on every seek so render loops can
  poll it instead of `performance.now()`.
- Dispatches a `"hf-seek"` CustomEvent on `window` so compositions
  can imperatively re-render a single frame at the new seek position.

Compositions listen for the event and update their time uniform:

```js
window.addEventListener("hf-seek", (e) => render(e.detail.time));
```

Works with TypeGPU (docs.swmansion.com/TypeGPU) and raw WebGPU alike.
No assumptions are made about pipeline construction — multiple canvases
or renderers are supported by sharing the same event.

- 9 unit tests, all pass
- wired in init.ts adapter array
- `__hfTypegpuTime` declared in window.d.ts

* fix(core): deduplicate hf-seek dispatch across GPU adapters

Both three and typegpu adapters previously dispatched the same
"hf-seek" CustomEvent independently, causing any composition that
registered a listener to receive two events per seek tick — doubling
per-scrub GPU work even though the renders are idempotent.

Fix: extract a shared `dispatchSeekEvent` helper (seek-dispatch.ts)
that deduplicates by exact float equality within the same synchronous
call stack. Both adapters now call this helper instead of dispatching
directly.

Also adds:
- `resetSeekDispatchState()` export for test isolation
- `beforeEach` reset in three.test.ts and typegpu.test.ts
- New typegpu test: "duplicate seek to same time fires event only once"
- Docstring additions to typegpu.ts: render-mode determinism contract
  (await device.queue.onSubmittedWorkDone()) and navigator.gpu feature
  detection guidance for composition authors

* feat(core): video-texture render compat + TypeGPU skill

Adds the missing pieces for video-backed WebGPU effects in render mode:

- `video-texture-compat.ts`: monkey-patches `GPUQueue.copyExternalImageToTexture`
  to detect the engine's injected `<img class="__render_frame__">` siblings and
  transparently substitute them for `<video>` sources. Headless Chrome can't
  supply decoded video frames to WebGPU, but the engine's pre-extracted frame
  images work. Falls through to the original path in preview mode.

- `patchVideoTextureCompat()` wired in init.ts after adapter array creation.

- `skills/typegpu/SKILL.md`: full authoring guide for TypeGPU/WebGPU compositions
  covering contract, timeline registration, video-backed effects, frosted blur
  via downsample pass, WGSL patterns, and deterministic rendering.

* test(producer): add typegpu-adapter regression test

Self-contained WebGPU composition with:
- Procedural gradient background (no video dependency)
- Animated ring driven by hf-seek time uniform
- Pulsing center glow
- Two GSAP-driven captions testing adapter sync

Verifies the TypeGPU adapter's hf-seek → WebGPU render pipeline
produces deterministic frames. workers: 1 for consistency.

Note: output.mp4 baseline needs to be generated in CI — the local
Docker image can't launch Chrome (ARM/x86 mismatch on Mac).
This commit is contained in:
Miguel Ángel
2026-05-13 03:31:56 +02:00
committed by GitHub
parent 82fd2967c4
commit ac671bdf5c
12 changed files with 750 additions and 7 deletions
@@ -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;
}
@@ -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", () => {
+2 -7
View File
@@ -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) {
@@ -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();
});
});
@@ -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
* <canvas id="gpu-canvas" width="1920" height="1080"></canvas>
* <script type="module">
* const adapter = await navigator.gpu.requestAdapter();
* const device = await adapter.requestDevice();
* // ... build your pipeline ...
*
* function render(timeSeconds) {
* // update your time uniform and submit a draw call
* device.queue.writeBuffer(uniformBuf, 0, new Float32Array([timeSeconds]));
* // ... submit command encoder ...
* }
*
* // Seek: fired by HyperFrames whenever the player scrubs or plays
* window.addEventListener("hf-seek", (e) => render(e.detail.time));
*
* // Initial frame at t=0
* render(window.__hfTypegpuTime ?? 0);
* </script>
* ```
*
* 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;
},
};
}
@@ -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 `<video>`
* with a pre-decoded `<img class="__render_frame__">` sibling. Chrome's
* headless compositor can't supply decoded frames from the native `<video>`
* element to WebGPU, so `copyExternalImageToTexture({ source: video })`
* fails with "Browser fails extracting valid resource from external image."
*
* This patch checks whether a render-frame `<img>` exists next to the
* source `<video>`. If it does and has decoded pixels, the patch
* transparently substitutes it as the copy source. In preview mode (no
* render-frame sibling), the original video path is used unchanged.
*/
export function patchVideoTextureCompat(): void {
const GPUQueueCtor = (globalThis as Record<string, unknown>).GPUQueue as
| { prototype: Record<string, unknown> }
| undefined;
if (!GPUQueueCtor?.prototype?.copyExternalImageToTexture) return;
const orig = GPUQueueCtor.prototype.copyExternalImageToTexture as (
source: unknown,
destination: unknown,
copySize: unknown,
) => void;
GPUQueueCtor.prototype.copyExternalImageToTexture = function (
source: Record<string, unknown>,
destination: unknown,
copySize: unknown,
) {
if (source?.source instanceof HTMLVideoElement) {
const sibling = source.source.nextElementSibling;
if (
sibling instanceof HTMLImageElement &&
sibling.classList.contains("__render_frame__") &&
sibling.complete &&
sibling.naturalWidth > 0
) {
return orig.call(this, { ...source, source: sibling }, destination, copySize);
}
}
return orig.call(this, source, destination, copySize);
};
}
+4
View File
@@ -5,6 +5,8 @@ import { createGsapAdapter } from "./adapters/gsap";
import { createAnimeJsAdapter } from "./adapters/animejs";
import { createLottieAdapter } from "./adapters/lottie";
import { createThreeAdapter } from "./adapters/three";
import { createTypegpuAdapter } from "./adapters/typegpu";
import { patchVideoTextureCompat } from "./adapters/video-texture-compat";
import { createWaapiAdapter } from "./adapters/waapi";
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
import { createPickerModule } from "./picker";
@@ -1634,8 +1636,10 @@ export function initSandboxRuntimeModular(): void {
createAnimeJsAdapter(),
createLottieAdapter(),
createThreeAdapter(),
createTypegpuAdapter(),
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
] as RuntimeDeterministicAdapter[];
patchVideoTextureCompat();
installRuntimeErrorDiagnostics();
runAdapters("discover");
bindMediaMetadataListeners();
+9
View File
@@ -35,6 +35,15 @@ declare global {
__HF_FPS?: number;
__HF_MAX_DURATION_SEC?: number;
__hfThreeTime?: number;
/**
* Current seek position in seconds, set by the TypeGPU/WebGPU adapter.
* Poll this from your WebGPU render loop instead of `performance.now()`
* to get the deterministic seek position.
*
* Also listen for the `"hf-seek"` CustomEvent on `window` for an
* imperative push signal: `window.addEventListener("hf-seek", e => render(e.detail.time))`.
*/
__hfTypegpuTime?: number;
__HF_PICKER_API?: HyperframePickerApi;
gsap?: {
timeline: (params?: { paused?: boolean }) => RuntimeTimelineLike;