mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix: align local WebGPU capture behavior (#2907)
* fix: align local WebGPU capture behavior * fix: address WebGPU capture review feedback * fix: retain overlapping GPU seek work * fix: satisfy runtime seek completion types * fix: drain concurrent GPU seek work * fix: prevent WebGPU capture barrier starvation * fix: keep WebGPU presentation active during render seeks
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { dispatchSeekEvent, forceDispatchSeekEvent, resetSeekDispatchState } from "./seek-dispatch";
|
||||
import {
|
||||
dispatchSeekEvent,
|
||||
forceDispatchSeekEvent,
|
||||
resetSeekDispatchState,
|
||||
waitForSeekCompletion,
|
||||
type HfSeekEventDetail,
|
||||
} from "./seek-dispatch";
|
||||
|
||||
describe("seek-dispatch", () => {
|
||||
beforeEach(() => {
|
||||
@@ -42,4 +48,167 @@ describe("seek-dispatch", () => {
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("waits for all GPU work registered synchronously by listeners", async () => {
|
||||
let finish: (() => void) | undefined;
|
||||
const gpuWork = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const handler = (event: Event) => {
|
||||
(event as CustomEvent<HfSeekEventDetail>).detail.waitUntil(gpuWork);
|
||||
};
|
||||
window.addEventListener("hf-seek", handler);
|
||||
dispatchSeekEvent(9);
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
|
||||
let settled = false;
|
||||
const pending = waitForSeekCompletion().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
finish?.();
|
||||
await pending;
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it("retains overlapping seek generations until a capture observes them", async () => {
|
||||
let finishFirst: (() => void) | undefined;
|
||||
let finishSecond: (() => void) | undefined;
|
||||
const firstGpuWork = new Promise<void>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
});
|
||||
const secondGpuWork = new Promise<void>((resolve) => {
|
||||
finishSecond = resolve;
|
||||
});
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<HfSeekEventDetail>).detail;
|
||||
detail.waitUntil(detail.time === 10 ? firstGpuWork : secondGpuWork);
|
||||
};
|
||||
window.addEventListener("hf-seek", handler);
|
||||
dispatchSeekEvent(10);
|
||||
dispatchSeekEvent(11);
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
|
||||
let settled = false;
|
||||
const pending = waitForSeekCompletion().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
finishSecond?.();
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishFirst?.();
|
||||
await pending;
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it("drains success and failure generations registered after the capture wait starts", async () => {
|
||||
let finishFirst: (() => void) | undefined;
|
||||
let finishSecond: (() => void) | undefined;
|
||||
let failThird: ((reason: unknown) => void) | undefined;
|
||||
const firstGpuWork = new Promise<void>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
});
|
||||
const secondGpuWork = new Promise<void>((resolve) => {
|
||||
finishSecond = resolve;
|
||||
});
|
||||
const thirdGpuWork = new Promise<void>((_resolve, reject) => {
|
||||
failThird = reject;
|
||||
});
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<HfSeekEventDetail>).detail;
|
||||
const gpuWork =
|
||||
detail.time === 13 ? firstGpuWork : detail.time === 14 ? secondGpuWork : thirdGpuWork;
|
||||
detail.waitUntil(gpuWork);
|
||||
};
|
||||
window.addEventListener("hf-seek", handler);
|
||||
dispatchSeekEvent(13);
|
||||
|
||||
let settled = false;
|
||||
const failure = new Error("later GPU queue failed");
|
||||
const pending = waitForSeekCompletion()
|
||||
.then(() => {
|
||||
settled = true;
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
settled = true;
|
||||
throw reason;
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
forceDispatchSeekEvent(14);
|
||||
forceDispatchSeekEvent(15);
|
||||
finishFirst?.();
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishSecond?.();
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
failThird?.(failure);
|
||||
await expect(pending).rejects.toBe(failure);
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
await expect(waitForSeekCompletion()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not retain empty or fulfilled heartbeat generations between captures", async () => {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
forceDispatchSeekEvent(20);
|
||||
}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
(event as CustomEvent<HfSeekEventDetail>).detail.waitUntil(Promise.resolve());
|
||||
};
|
||||
window.addEventListener("hf-seek", handler);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
forceDispatchSeekEvent(21);
|
||||
}
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
const promiseAll = vi.spyOn(Promise, "all");
|
||||
await waitForSeekCompletion();
|
||||
expect(promiseAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a rejected generation once, then consumes it", async () => {
|
||||
const failure = new Error("GPU queue failed");
|
||||
const handler = (event: Event) => {
|
||||
(event as CustomEvent<HfSeekEventDetail>).detail.waitUntil(Promise.reject(failure));
|
||||
};
|
||||
window.addEventListener("hf-seek", handler);
|
||||
dispatchSeekEvent(12);
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
|
||||
await expect(waitForSeekCompletion()).rejects.toBe(failure);
|
||||
await expect(waitForSeekCompletion()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports one rejected generation to concurrent capture barriers", async () => {
|
||||
let failGpuWork: ((reason: unknown) => void) | undefined;
|
||||
const gpuWork = new Promise<void>((_resolve, reject) => {
|
||||
failGpuWork = reject;
|
||||
});
|
||||
const failure = new Error("GPU queue failed");
|
||||
const handler = (event: Event) => {
|
||||
(event as CustomEvent<HfSeekEventDetail>).detail.waitUntil(gpuWork);
|
||||
};
|
||||
window.addEventListener("hf-seek", handler);
|
||||
dispatchSeekEvent(22);
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
|
||||
const firstCapture = waitForSeekCompletion();
|
||||
const secondCapture = waitForSeekCompletion();
|
||||
failGpuWork?.(failure);
|
||||
|
||||
await expect(Promise.allSettled([firstCapture, secondCapture])).resolves.toEqual([
|
||||
{ status: "rejected", reason: failure },
|
||||
{ status: "rejected", reason: failure },
|
||||
]);
|
||||
await expect(waitForSeekCompletion()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,15 +19,52 @@ import { swallow } from "../diagnostics";
|
||||
*/
|
||||
|
||||
let _lastDispatchedTime = -1;
|
||||
type SeekCompletionResult = { status: "fulfilled" } | { status: "rejected"; reason: unknown };
|
||||
let _pendingCompletions = new Set<Promise<SeekCompletionResult>>();
|
||||
let _pendingFailure: { reason: unknown } | undefined;
|
||||
let _activeCompletionBarriers = 0;
|
||||
|
||||
export interface HfSeekEventDetail {
|
||||
time: number;
|
||||
waitUntil: (promise: PromiseLike<unknown>) => void;
|
||||
}
|
||||
|
||||
function dispatch(time: number): void {
|
||||
const pending: PromiseLike<unknown>[] = [];
|
||||
let accepting = true;
|
||||
const detail: HfSeekEventDetail = {
|
||||
time,
|
||||
waitUntil: (promise) => {
|
||||
if (!accepting) {
|
||||
throw new Error("hf-seek waitUntil() must be called synchronously from the event listener");
|
||||
}
|
||||
pending.push(promise);
|
||||
},
|
||||
};
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent<HfSeekEventDetail>("hf-seek", { detail }));
|
||||
} catch (err) {
|
||||
swallow("runtime.adapters.seek-dispatch.site1", err);
|
||||
} finally {
|
||||
accepting = false;
|
||||
}
|
||||
if (pending.length === 0) return;
|
||||
const completion = Promise.all(pending)
|
||||
.then<SeekCompletionResult>(() => ({ status: "fulfilled" }))
|
||||
.catch<SeekCompletionResult>((reason: unknown) => ({ status: "rejected", reason }));
|
||||
_pendingCompletions.add(completion);
|
||||
void completion.then((result) => {
|
||||
if (!_pendingCompletions.delete(completion)) return;
|
||||
if (result.status === "rejected" && _pendingFailure === undefined) {
|
||||
_pendingFailure = { reason: result.reason };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
dispatch(time);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,14 +79,44 @@ export function dispatchSeekEvent(time: number): void {
|
||||
*/
|
||||
export function forceDispatchSeekEvent(time: number): void {
|
||||
_lastDispatchedTime = time;
|
||||
dispatch(time);
|
||||
}
|
||||
|
||||
export function isSeekCompletionBarrierActive(): boolean {
|
||||
return _activeCompletionBarriers > 0;
|
||||
}
|
||||
|
||||
export async function waitForSeekCompletion(): Promise<void> {
|
||||
_activeCompletionBarriers += 1;
|
||||
let failed = _pendingFailure;
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time } }));
|
||||
} catch (err) {
|
||||
swallow("runtime.adapters.seek-dispatch.force", err);
|
||||
// Let concurrently started barriers snapshot the same retained failure
|
||||
// before either one consumes it.
|
||||
await Promise.resolve();
|
||||
while (_pendingCompletions.size > 0) {
|
||||
const results = await Promise.all([..._pendingCompletions]);
|
||||
const rejected = results.find((result) => result.status === "rejected");
|
||||
if (failed === undefined && rejected?.status === "rejected") {
|
||||
failed = { reason: rejected.reason };
|
||||
}
|
||||
}
|
||||
const retainedFailure = _pendingFailure;
|
||||
if (failed === undefined) {
|
||||
failed = retainedFailure;
|
||||
}
|
||||
if (_pendingFailure === retainedFailure) {
|
||||
_pendingFailure = undefined;
|
||||
}
|
||||
if (failed) throw failed.reason;
|
||||
} finally {
|
||||
_activeCompletionBarriers -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset internal state — used in tests to prevent cross-test contamination. */
|
||||
export function resetSeekDispatchState(): void {
|
||||
_lastDispatchedTime = -1;
|
||||
_pendingCompletions = new Set();
|
||||
_pendingFailure = undefined;
|
||||
_activeCompletionBarriers = 0;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { createTypegpuAdapter } from "./typegpu";
|
||||
import { resetSeekDispatchState } from "./seek-dispatch";
|
||||
import { createTypegpuAdapter, TYPEGPU_PRESENT_HEARTBEAT_MS } from "./typegpu";
|
||||
import {
|
||||
resetSeekDispatchState,
|
||||
waitForSeekCompletion,
|
||||
type HfSeekEventDetail,
|
||||
} from "./seek-dispatch";
|
||||
|
||||
const gpuWindow = window as Window & { __hfTypegpuTime?: number };
|
||||
|
||||
describe("typegpu adapter", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
delete gpuWindow.__hfTypegpuTime;
|
||||
// Reset shared dedup state so each test starts with a clean dispatch history
|
||||
resetSeekDispatchState();
|
||||
@@ -86,4 +92,77 @@ describe("typegpu adapter", () => {
|
||||
const adapter = createTypegpuAdapter();
|
||||
expect(() => adapter.discover()).not.toThrow();
|
||||
});
|
||||
|
||||
it("re-presents the paused WebGPU frame without advancing seek time", async () => {
|
||||
vi.useFakeTimers();
|
||||
document.body.innerHTML =
|
||||
'<div data-composition-id="gpu" data-requires-webgpu data-duration="2"></div>';
|
||||
const adapter = createTypegpuAdapter();
|
||||
const times: number[] = [];
|
||||
const handler = (event: Event) => {
|
||||
times.push((event as CustomEvent<{ time: number }>).detail.time);
|
||||
};
|
||||
window.addEventListener("hf-seek", handler);
|
||||
|
||||
adapter.seek({ time: 1.25 });
|
||||
adapter.pause();
|
||||
await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS);
|
||||
adapter.play?.();
|
||||
await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS * 2);
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
|
||||
expect(times).toEqual([1.25, 1.25]);
|
||||
expect(gpuWindow.__hfTypegpuTime).toBe(1.25);
|
||||
});
|
||||
|
||||
it("does not start a present heartbeat without the WebGPU capability marker", async () => {
|
||||
vi.useFakeTimers();
|
||||
document.body.innerHTML = '<div data-composition-id="dom" data-duration="2"></div>';
|
||||
const adapter = createTypegpuAdapter();
|
||||
const handler = vi.fn();
|
||||
window.addEventListener("hf-seek", handler);
|
||||
|
||||
adapter.seek({ time: 1.25 });
|
||||
adapter.pause();
|
||||
await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS * 2);
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("pauses presentation heartbeats while a capture barrier drains slow GPU work", async () => {
|
||||
vi.useFakeTimers();
|
||||
document.body.innerHTML =
|
||||
'<div data-composition-id="gpu" data-requires-webgpu data-duration="2"></div>';
|
||||
const adapter = createTypegpuAdapter();
|
||||
const times: number[] = [];
|
||||
const completionLatency = TYPEGPU_PRESENT_HEARTBEAT_MS * 2 + 1;
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<HfSeekEventDetail>).detail;
|
||||
times.push(detail.time);
|
||||
detail.waitUntil(
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, completionLatency);
|
||||
}),
|
||||
);
|
||||
};
|
||||
window.addEventListener("hf-seek", handler);
|
||||
|
||||
adapter.seek({ time: 1.25 });
|
||||
adapter.pause();
|
||||
let settled = false;
|
||||
const capture = waitForSeekCompletion().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS * 2);
|
||||
expect(settled).toBe(false);
|
||||
expect(times).toEqual([1.25]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await capture;
|
||||
adapter.play?.();
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
expect(settled).toBe(true);
|
||||
expect(times).toEqual([1.25]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { RuntimeDeterministicAdapter } from "../types";
|
||||
import { dispatchSeekEvent } from "./seek-dispatch";
|
||||
import {
|
||||
dispatchSeekEvent,
|
||||
forceDispatchSeekEvent,
|
||||
isSeekCompletionBarrierActive,
|
||||
} from "./seek-dispatch";
|
||||
|
||||
export const TYPEGPU_PRESENT_HEARTBEAT_MS = 250;
|
||||
|
||||
/**
|
||||
* TypeGPU / WebGPU adapter for HyperFrames
|
||||
@@ -31,7 +37,10 @@ import { dispatchSeekEvent } from "./seek-dispatch";
|
||||
* }
|
||||
*
|
||||
* // Seek: fired by HyperFrames whenever the player scrubs or plays
|
||||
* window.addEventListener("hf-seek", (e) => render(e.detail.time));
|
||||
* window.addEventListener("hf-seek", (e) => {
|
||||
* render(e.detail.time);
|
||||
* e.detail.waitUntil(device.queue.onSubmittedWorkDone());
|
||||
* });
|
||||
*
|
||||
* // Initial frame at t=0
|
||||
* render(window.__hfTypegpuTime ?? 0);
|
||||
@@ -44,9 +53,9 @@ import { dispatchSeekEvent } from "./seek-dispatch";
|
||||
*
|
||||
* ## 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.
|
||||
* For frame-perfect video renders, register GPU completion synchronously with
|
||||
* `e.detail.waitUntil(device.queue.onSubmittedWorkDone())` after `render(time)`.
|
||||
* HyperFrames awaits the registered work before screenshots and frame capture.
|
||||
*
|
||||
* ## Browser feature detection
|
||||
*
|
||||
@@ -64,6 +73,24 @@ import { dispatchSeekEvent } from "./seek-dispatch";
|
||||
export function createTypegpuAdapter(): RuntimeDeterministicAdapter {
|
||||
let forcedTime: number | null = null;
|
||||
let lastForcedTime = 0;
|
||||
let presentHeartbeat: number | null = null;
|
||||
|
||||
const stopPresentHeartbeat = () => {
|
||||
if (presentHeartbeat === null) return;
|
||||
window.clearInterval(presentHeartbeat);
|
||||
presentHeartbeat = null;
|
||||
};
|
||||
|
||||
const startPresentHeartbeat = () => {
|
||||
if (presentHeartbeat !== null) return;
|
||||
if (!document.querySelector("[data-composition-id][data-requires-webgpu]")) return;
|
||||
presentHeartbeat = window.setInterval(() => {
|
||||
if (forcedTime === null) return;
|
||||
if (isSeekCompletionBarrierActive()) return;
|
||||
window.__hfTypegpuTime = forcedTime;
|
||||
forceDispatchSeekEvent(forcedTime);
|
||||
}, TYPEGPU_PRESENT_HEARTBEAT_MS);
|
||||
};
|
||||
|
||||
return {
|
||||
name: "typegpu",
|
||||
@@ -83,13 +110,16 @@ export function createTypegpuAdapter(): RuntimeDeterministicAdapter {
|
||||
if (forcedTime == null) {
|
||||
forcedTime = Math.max(0, lastForcedTime);
|
||||
}
|
||||
startPresentHeartbeat();
|
||||
},
|
||||
|
||||
play: () => {
|
||||
stopPresentHeartbeat();
|
||||
forcedTime = null;
|
||||
},
|
||||
|
||||
revert: () => {
|
||||
stopPresentHeartbeat();
|
||||
forcedTime = null;
|
||||
lastForcedTime = 0;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user