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
45 changes: 45 additions & 0 deletions packages/core/src/runtime/adapters/seek-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { dispatchSeekEvent, forceDispatchSeekEvent, resetSeekDispatchState } from "./seek-dispatch";

describe("seek-dispatch", () => {
beforeEach(() => {
resetSeekDispatchState();
});

it("dispatchSeekEvent fires an hf-seek event with the time", () => {
const handler = vi.fn();
window.addEventListener("hf-seek", handler);
dispatchSeekEvent(2.5);
window.removeEventListener("hf-seek", handler);
expect(handler).toHaveBeenCalledTimes(1);
expect((handler.mock.calls[0][0] as CustomEvent).detail.time).toBe(2.5);
});

it("dispatchSeekEvent dedups consecutive same-time dispatches", () => {
const handler = vi.fn();
window.addEventListener("hf-seek", handler);
dispatchSeekEvent(4);
dispatchSeekEvent(4);
window.removeEventListener("hf-seek", handler);
expect(handler).toHaveBeenCalledTimes(1);
});

it("forceDispatchSeekEvent re-fires even at the same time (post-injection re-render)", () => {
const handler = vi.fn();
window.addEventListener("hf-seek", handler);
dispatchSeekEvent(6); // GPU adapters' first render at t=6
forceDispatchSeekEvent(6); // engine re-render after video injection, same t
window.removeEventListener("hf-seek", handler);
expect(handler).toHaveBeenCalledTimes(2);
expect((handler.mock.calls[1][0] as CustomEvent).detail.time).toBe(6);
});

it("after a force dispatch, the same time still dedups on the normal path", () => {
const handler = vi.fn();
window.addEventListener("hf-seek", handler);
forceDispatchSeekEvent(8);
dispatchSeekEvent(8); // deduped — force already recorded t=8
window.removeEventListener("hf-seek", handler);
expect(handler).toHaveBeenCalledTimes(1);
});
});
19 changes: 19 additions & 0 deletions packages/core/src/runtime/adapters/seek-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ export function dispatchSeekEvent(time: number): void {
}
}

/**
* Force-dispatch a `"hf-seek"` event even if `time` equals the last dispatched
* time, bypassing the dedup guard.
*
* Needed for the post-video-injection GPU re-render: the engine seeks to time
* T (GPU adapters render once, before video frames are injected), then injects
* the decoded `__render_frame__` images, then must re-render GPU compositions
* at the *same* T so they re-upload textures from the now-present frames. The
* normal dedup would swallow that second dispatch.
*/
export function forceDispatchSeekEvent(time: number): void {
_lastDispatchedTime = time;
try {
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time } }));
} catch (err) {
swallow("runtime.adapters.seek-dispatch.force", err);
}
}

/** Reset internal state — used in tests to prevent cross-test contamination. */
export function resetSeekDispatchState(): void {
_lastDispatchedTime = -1;
Expand Down
110 changes: 110 additions & 0 deletions packages/core/src/runtime/adapters/video-texture-compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { patchWebGLVideoTextureCompat } from "./video-texture-compat";

// Minimal fake WebGL2 context that records the source passed to texImage2D /
// texSubImage2D, so we can assert the patch substitutes the injected frame.
class FakeGL2 {
lastImageArgs: unknown[] | null = null;
lastSubArgs: unknown[] | null = null;
texImage2D(...args: unknown[]) {
this.lastImageArgs = args;
}
texSubImage2D(...args: unknown[]) {
this.lastSubArgs = args;
}
}

function makeInjectedImage(): HTMLImageElement {
const img = document.createElement("img");
img.classList.add("__render_frame__");
Object.defineProperty(img, "complete", { value: true, configurable: true });
Object.defineProperty(img, "naturalWidth", { value: 16, configurable: true });
return img;
}

describe("patchWebGLVideoTextureCompat", () => {
let originalGL2: unknown;

beforeEach(() => {
originalGL2 = (globalThis as Record<string, unknown>).WebGL2RenderingContext;
(globalThis as Record<string, unknown>).WebGL2RenderingContext = FakeGL2;
document.body.innerHTML = "";
});

afterEach(() => {
(globalThis as Record<string, unknown>).WebGL2RenderingContext = originalGL2;
document.body.innerHTML = "";
});

it("substitutes the decoded __render_frame__ image when uploading a <video>", () => {
patchWebGLVideoTextureCompat();

const video = document.createElement("video");
const img = makeInjectedImage();
document.body.append(video, img); // img is video.nextElementSibling

const gl = new FakeGL2();
gl.texImage2D(0x0de1, 0, 0x1908, 0x1908, 0x1401, video);

// Last argument (the source) must be swapped to the injected image.
expect(gl.lastImageArgs?.[gl.lastImageArgs.length - 1]).toBe(img);
});

it("leaves the <video> source untouched when no render frame is present (preview)", () => {
patchWebGLVideoTextureCompat();

const video = document.createElement("video");
document.body.append(video);

const gl = new FakeGL2();
gl.texImage2D(0x0de1, 0, 0x1908, 0x1908, 0x1401, video);

expect(gl.lastImageArgs?.[gl.lastImageArgs.length - 1]).toBe(video);
});

it("ignores a render-frame image that is not yet decoded", () => {
patchWebGLVideoTextureCompat();

const video = document.createElement("video");
const img = document.createElement("img");
img.classList.add("__render_frame__");
Object.defineProperty(img, "complete", { value: false, configurable: true });
Object.defineProperty(img, "naturalWidth", { value: 0, configurable: true });
document.body.append(video, img);

const gl = new FakeGL2();
gl.texImage2D(0x0de1, 0, 0x1908, 0x1908, 0x1401, video);

expect(gl.lastImageArgs?.[gl.lastImageArgs.length - 1]).toBe(video);
});

it("also patches texSubImage2D", () => {
patchWebGLVideoTextureCompat();

const video = document.createElement("video");
const img = makeInjectedImage();
document.body.append(video, img);

const gl = new FakeGL2();
gl.texSubImage2D(0x0de1, 0, 0, 0, 0x1908, 0x1401, video);

expect(gl.lastSubArgs?.[gl.lastSubArgs.length - 1]).toBe(img);
});

it("does not touch numeric/pixel-data overloads (no video source)", () => {
patchWebGLVideoTextureCompat();

const pixels = new Uint8Array([1, 2, 3, 4]);
const gl = new FakeGL2();
gl.texImage2D(0x0de1, 0, 0x1908, 1, 1, 0, 0x1908, 0x1401, pixels);

expect(gl.lastImageArgs?.[gl.lastImageArgs.length - 1]).toBe(pixels);
});

it("is idempotent — patching twice does not double-wrap", () => {
patchWebGLVideoTextureCompat();
const once = FakeGL2.prototype.texImage2D;
patchWebGLVideoTextureCompat();
expect(FakeGL2.prototype.texImage2D).toBe(once);
});
});
96 changes: 80 additions & 16 deletions packages/core/src/runtime/adapters/video-texture-compat.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,47 @@
/**
* Patches `GPUQueue.copyExternalImageToTexture` so that video-backed WebGPU
* effects work in both preview and render mode.
* Patches GPU texture-upload paths so that video-backed effects work in both
* preview and render mode — for WebGPU (`GPUQueue.copyExternalImageToTexture`)
* and WebGL (`texImage2D` / `texSubImage2D`).
*
* 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."
* element to the GPU, so uploading a `<video>` directly fails (WebGPU throws
* "Browser fails extracting valid resource from external image"; WebGL uploads
* a black/stale frame). These patches transparently substitute the decoded
* render-frame `<img>` as the upload source. In preview mode (no render-frame
* sibling), the original `<video>` path is used unchanged.
*/

/**
* Resolve the decoded render-frame `<img>` for a source `<video>`, if the
* engine has injected one and it has decoded pixels. Returns null in preview
* mode or before the frame is decoded, so callers fall back to the video.
*
* 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.
* The injector inserts the `<img>` as the video's immediate next sibling and
* also gives it the id `__render_frame_<videoId>__`; we check the sibling
* first (cheap) and fall back to an id lookup in case a node was inserted
* between them.
*/
function resolveRenderFrameImage(video: HTMLVideoElement): HTMLImageElement | null {
const sibling = video.nextElementSibling;
if (
sibling instanceof HTMLImageElement &&
sibling.classList.contains("__render_frame__") &&
sibling.complete &&
sibling.naturalWidth > 0
) {
return sibling;
}
if (video.id) {
const byId = document.getElementById(`__render_frame_${video.id}__`);
if (byId instanceof HTMLImageElement && byId.complete && byId.naturalWidth > 0) {
return byId;
}
}
return null;
}

export function patchVideoTextureCompat(): void {
const GPUQueueCtor = (globalThis as Record<string, unknown>).GPUQueue as
| { prototype: Record<string, unknown> }
Expand All @@ -32,16 +61,51 @@ export function patchVideoTextureCompat(): void {
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);
const img = resolveRenderFrameImage(source.source);
if (img) {
return orig.call(this, { ...source, source: img }, destination, copySize);
}
}
return orig.call(this, source, destination, copySize);
};
}

/**
* WebGL analog of {@link patchVideoTextureCompat}. Patches `texImage2D` and
* `texSubImage2D` on both `WebGL2RenderingContext` and `WebGLRenderingContext`
* so that when a `<video>` is passed as the texture source (the last argument
* in the DOM-source overloads), the decoded render-frame `<img>` is uploaded
* instead during render. Numeric/`ArrayBufferView` overloads are untouched —
* only a trailing `HTMLVideoElement` argument is substituted.
*/
export function patchWebGLVideoTextureCompat(): void {
const ctors = [
(globalThis as Record<string, unknown>).WebGL2RenderingContext,
(globalThis as Record<string, unknown>).WebGLRenderingContext,
] as Array<{ prototype: Record<string, unknown> } | undefined>;

const methods = ["texImage2D", "texSubImage2D"] as const;

for (const ctor of ctors) {
const proto = ctor?.prototype;
if (!proto) continue;
for (const method of methods) {
const orig = proto[method] as ((...args: unknown[]) => unknown) & {
__hfVideoPatched?: boolean;
};
if (typeof orig !== "function" || orig.__hfVideoPatched) continue;

const patched = function (this: unknown, ...args: unknown[]) {
const lastIndex = args.length - 1;
const last = args[lastIndex];
if (last instanceof HTMLVideoElement) {
const img = resolveRenderFrameImage(last);
if (img) args[lastIndex] = img;
}
return orig.apply(this, args);
} as ((...args: unknown[]) => unknown) & { __hfVideoPatched?: boolean };
patched.__hfVideoPatched = true;
proto[method] = patched;
}
}
}
15 changes: 14 additions & 1 deletion packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ 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 {
patchVideoTextureCompat,
patchWebGLVideoTextureCompat,
} from "./adapters/video-texture-compat";
import { forceDispatchSeekEvent } from "./adapters/seek-dispatch";
import { createWaapiAdapter } from "./adapters/waapi";
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
Expand Down Expand Up @@ -1746,6 +1750,15 @@ export function initSandboxRuntimeModular(): void {
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
] as RuntimeDeterministicAdapter[];
patchVideoTextureCompat();
patchWebGLVideoTextureCompat();
// Lets the engine re-render GPU compositions after it injects decoded video
// frames, so video-textured WebGL/WebGPU scenes sample the correct frame.
window.__hfReseekGpu = (time: number) => {
const t = Math.max(0, Number(time) || 0);
window.__hfThreeTime = t;
window.__hfTypegpuTime = t;
forceDispatchSeekEvent(t);
};
installRuntimeErrorDiagnostics();
bindMediaMetadataListeners();
runAdapters("discover");
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/runtime/window.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ declare global {
* imperative push signal: `window.addEventListener("hf-seek", e => render(e.detail.time))`.
*/
__hfTypegpuTime?: number;
/**
* Re-render GPU adapters (Three.js / WebGPU) at the given time, bypassing
* the `"hf-seek"` dedup. Called by the engine after injecting decoded
* video frames so GPU compositions re-upload their video textures from the
* freshly-injected `__render_frame__` images. See `forceDispatchSeekEvent`.
*/
__hfReseekGpu?: (time: number) => void;
__HF_PICKER_API?: HyperframePickerApi;
gsap?: {
timeline: (params?: { paused?: boolean }) => RuntimeTimelineLike;
Expand Down
Loading
Loading