diff --git a/packages/core/src/runtime/adapters/seek-dispatch.test.ts b/packages/core/src/runtime/adapters/seek-dispatch.test.ts new file mode 100644 index 000000000..34a70ebf9 --- /dev/null +++ b/packages/core/src/runtime/adapters/seek-dispatch.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/runtime/adapters/seek-dispatch.ts b/packages/core/src/runtime/adapters/seek-dispatch.ts index 6261cc5a2..ad9f0dad8 100644 --- a/packages/core/src/runtime/adapters/seek-dispatch.ts +++ b/packages/core/src/runtime/adapters/seek-dispatch.ts @@ -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; diff --git a/packages/core/src/runtime/adapters/video-texture-compat.test.ts b/packages/core/src/runtime/adapters/video-texture-compat.test.ts new file mode 100644 index 000000000..43dfe3690 --- /dev/null +++ b/packages/core/src/runtime/adapters/video-texture-compat.test.ts @@ -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).WebGL2RenderingContext; + (globalThis as Record).WebGL2RenderingContext = FakeGL2; + document.body.innerHTML = ""; + }); + + afterEach(() => { + (globalThis as Record).WebGL2RenderingContext = originalGL2; + document.body.innerHTML = ""; + }); + + it("substitutes the decoded __render_frame__ image when uploading a