mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user