Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/core/src/runtime/adapters/seek-dispatch.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions packages/core/src/runtime/adapters/three.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
9 changes: 2 additions & 7 deletions packages/core/src/runtime/adapters/three.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) {
Expand Down
89 changes: 89 additions & 0 deletions packages/core/src/runtime/adapters/typegpu.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
97 changes: 97 additions & 0 deletions packages/core/src/runtime/adapters/typegpu.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
}
47 changes: 47 additions & 0 deletions packages/core/src/runtime/adapters/video-texture-compat.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1634,8 +1636,10 @@ export function initSandboxRuntimeModular(): void {
createAnimeJsAdapter(),
createLottieAdapter(),
createThreeAdapter(),
createTypegpuAdapter(),
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
] as RuntimeDeterministicAdapter[];
patchVideoTextureCompat();
installRuntimeErrorDiagnostics();
runAdapters("discover");
bindMediaMetadataListeners();
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/runtime/window.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions packages/producer/tests/typegpu-adapter/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "typegpu-adapter",
"description": "Regression guard for the TypeGPU/WebGPU runtime adapter. Verifies that WebGPU fragment shaders driven by hf-seek render deterministically via window.__hfTypegpuTime + the shared seek-dispatch deduplication.",
"tags": ["regression", "adapter"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 1,
"renderConfig": {
"fps": 30,
"workers": 1
}
}
155 changes: 155 additions & 0 deletions packages/producer/tests/typegpu-adapter/output/compiled.html

Large diffs are not rendered by default.

Loading
Loading