mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-13 20:58:33 +00:00
fix(core): preview recovers its WebGL scenes and effects after a lost context (#7028)
* fix(core): preview repaints and rebuilds effects after the GPU restores a lost WebGL context * fix(core): a paused scene redraws and a playing one repaints effects after a context restore * test(core): a paused scene is redrawn after a WebGL context restore * test(core): give the restore redraw test a clip * fix(core): shorten the context recovery header comment * fix(core): rebuild effects only after every lost context has restored * test(core): the restore rebuild waits while another context is still lost * test(core): count context requests to show the rebuild waits * test(core): the mock reports the lost flag the test sets
This commit is contained in:
@@ -389,6 +389,32 @@ describe("runtime entry", () => {
|
||||
expect(visibility(current, later)).toEqual(["hidden", "visible"]);
|
||||
});
|
||||
|
||||
it("redraws a paused scene by stepping its timeline off the current time and back after a WebGL restore", async () => {
|
||||
const root = mountRoot();
|
||||
timed(root, "div", "0");
|
||||
const timeline = window.__timelines.main!;
|
||||
await evaluateRuntime();
|
||||
window.__player!.seek(2);
|
||||
const seen: number[] = [];
|
||||
const totalTime = timeline.totalTime!;
|
||||
timeline.totalTime = (t?: number) => {
|
||||
if (t !== undefined) seen.push(t);
|
||||
return totalTime(t as number);
|
||||
};
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => frames.push(cb));
|
||||
|
||||
const scene = root.appendChild(document.createElement("canvas"));
|
||||
scene.dispatchEvent(new Event("webglcontextlost", { cancelable: true }));
|
||||
scene.dispatchEvent(new Event("webglcontextrestored"));
|
||||
seen.length = 0;
|
||||
frames.splice(0).forEach((cb) => cb(0));
|
||||
|
||||
expect(seen[0]).toBeCloseTo(2 - 1e-4, 6);
|
||||
expect(seen.at(-1)).toBeCloseTo(2, 3);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("leaves nothing hidden when the runtime is evaluated a second time", async () => {
|
||||
const root = mountRoot();
|
||||
const current = timed(root, "div", "0");
|
||||
|
||||
@@ -165,7 +165,8 @@ import { stillStandsIn, unheldPoster, unproxiedSrc } from "./proxySrc";
|
||||
import { applyVariableBindings, unproxiedMediaSrc } from "./applyVariableBindings";
|
||||
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
|
||||
import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "../colorGrading";
|
||||
import { initVfx, paintVfx } from "./vfx";
|
||||
import { initVfx, paintVfx, rebuildLostVfx } from "./vfx";
|
||||
import { watchSceneWebglContexts } from "./webglContextRecovery";
|
||||
import {
|
||||
captureMotionBlurBusy,
|
||||
initCaptureMotionBlur,
|
||||
@@ -4778,6 +4779,22 @@ export function initSandboxRuntimeModular(): void {
|
||||
// Per-pixel effect chains: compile once here, repaint on every seek below.
|
||||
initVfx(document.body, state.canonicalFps);
|
||||
initMotionBlur();
|
||||
registerRuntimeCleanup(
|
||||
watchSceneWebglContexts(() => {
|
||||
rebuildLostVfx(document.body);
|
||||
// Effects paint on seeks only. A playing transport redraws the scene itself on its next tick.
|
||||
if (clock.isPlaying()) {
|
||||
paintVfx(state.currentTime);
|
||||
return;
|
||||
}
|
||||
// Seeking to the time a timeline is already at calls no onUpdate, so step off it first.
|
||||
const tl = state.capturedTimeline;
|
||||
const at = state.currentTime;
|
||||
if (tl && typeof tl.totalTime === "function")
|
||||
tl.totalTime(at > 1e-3 ? at - 1e-4 : at + 1e-4, false);
|
||||
applySeek(at);
|
||||
}),
|
||||
);
|
||||
|
||||
const applyPlaybackRate = (nextRate: number) => {
|
||||
const parsed = Number(nextRate);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HfVfxCapture } from "../vfx";
|
||||
import { resetSeekDispatchState, waitForSeekCompletion } from "./adapters/seek-dispatch";
|
||||
import { initVfx, paintVfx } from "./vfx";
|
||||
import { initVfx, paintVfx, rebuildLostVfx } from "./vfx";
|
||||
|
||||
/**
|
||||
* No def has `capture !== "none"` until Task 2.2 registers `wave-warp`, so the
|
||||
@@ -52,6 +52,7 @@ interface MockGl {
|
||||
usedPrograms: unknown[];
|
||||
shaderSources: string[];
|
||||
viewports: number[][];
|
||||
lost: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,6 +68,7 @@ function createMockGl(): WebGL2RenderingContext & MockGl {
|
||||
usedPrograms: [],
|
||||
shaderSources: [],
|
||||
viewports: [],
|
||||
lost: false,
|
||||
};
|
||||
let currentProgram: unknown = null;
|
||||
const gl = {
|
||||
@@ -97,6 +99,7 @@ function createMockGl(): WebGL2RenderingContext & MockGl {
|
||||
getShaderParameter: () => true,
|
||||
getShaderInfoLog: () => "",
|
||||
deleteShader: () => {},
|
||||
isContextLost: () => (gl as unknown as MockGl).lost,
|
||||
createProgram: () => ({ id: ++state.programCount }),
|
||||
attachShader: () => {},
|
||||
linkProgram: () => {},
|
||||
@@ -407,6 +410,30 @@ describe("vfx runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("paints a host again after its lost context is restored and the chains are rebuilt", () => {
|
||||
const host = makeHost(ONE_NODE);
|
||||
initVfx(document.body, 30);
|
||||
const out = host.querySelector("canvas.hf-vfx-out") as HTMLCanvasElement;
|
||||
out.dispatchEvent(new Event("webglcontextlost", { cancelable: true }));
|
||||
out.dispatchEvent(new Event("webglcontextrestored"));
|
||||
paintVfx(0);
|
||||
expect(gl!.calls).toEqual([]);
|
||||
|
||||
// Another context has not come back yet: rebuilding now would drop its host.
|
||||
const contexts = vi.mocked(HTMLCanvasElement.prototype.getContext);
|
||||
const asked = contexts.mock.calls.length;
|
||||
gl!.lost = true;
|
||||
rebuildLostVfx(document.body);
|
||||
expect(contexts.mock.calls.length).toBe(asked);
|
||||
|
||||
gl!.lost = false;
|
||||
rebuildLostVfx(document.body);
|
||||
expect(contexts.mock.calls.length).toBeGreaterThan(asked);
|
||||
gl!.calls.length = 0;
|
||||
paintVfx(0);
|
||||
expect(gl!.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("forgets the previous composition's hosts when re-initialised", () => {
|
||||
makeHost(ONE_NODE);
|
||||
initVfx(document.body, 30);
|
||||
|
||||
@@ -552,13 +552,8 @@ function resolveCaptureSource(
|
||||
* Only the OUTPUT canvas is watched: `.hf-vfx-src` is a 2-D context and is
|
||||
* unaffected by GL context loss.
|
||||
*
|
||||
* Restore is deliberately unhandled — the entry stays lost for as long as this
|
||||
* registry does. `initVfx` already re-scans and re-registers every host when
|
||||
* the composition mounts and again when a sub-composition arrives, rebuilding
|
||||
* every program, texture and framebuffer from scratch; a second, narrower
|
||||
* rebuild path for `webglcontextrestored` alone would duplicate that one and
|
||||
* be free to drift from it. `preventDefault()` still runs, so the context is
|
||||
* restorable if a follow-up wants to take it.
|
||||
* A lost entry stays lost for as long as its registry does; `rebuildLostVfx`
|
||||
* re-runs `initVfx` once the context is restored, the one rebuild path.
|
||||
*/
|
||||
function watchContextLoss(entry: VfxEntry): void {
|
||||
entry.out.addEventListener("webglcontextlost", (event) => {
|
||||
@@ -572,6 +567,12 @@ function watchContextLoss(entry: VfxEntry): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Rebuilds every chain once none is still waiting on its context: a rebuild on a lost one drops the host for good. */
|
||||
export function rebuildLostVfx(root: HTMLElement): void {
|
||||
if (!registry.some((entry) => entry.contextLost)) return;
|
||||
if (registry.every((entry) => !entry.gl.isContextLost())) initVfx(root, registryFps);
|
||||
}
|
||||
|
||||
/** The host's chain, or `null` once the reason it is unusable has been said. */
|
||||
function readVfxChain(host: HTMLElement): HfVfxChain | null {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { watchSceneWebglContexts } from "./webglContextRecovery";
|
||||
|
||||
function canvas() {
|
||||
const el = document.createElement("canvas");
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("watchSceneWebglContexts", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("lets a scene canvas restore, then repaints once after its own restore handler", () => {
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => frames.push(cb));
|
||||
const order: string[] = [];
|
||||
const repaint = vi.fn(() => order.push("repaint"));
|
||||
const stop = watchSceneWebglContexts(repaint);
|
||||
const scene = canvas();
|
||||
scene.addEventListener("webglcontextrestored", () => order.push("scene"));
|
||||
|
||||
const lost = new Event("webglcontextlost", { cancelable: true });
|
||||
scene.dispatchEvent(lost);
|
||||
expect(lost.defaultPrevented).toBe(true);
|
||||
|
||||
scene.dispatchEvent(new Event("webglcontextrestored"));
|
||||
canvas().dispatchEvent(new Event("webglcontextrestored"));
|
||||
expect(repaint).not.toHaveBeenCalled();
|
||||
frames.splice(0).forEach((cb) => cb(0));
|
||||
expect(order).toEqual(["scene", "repaint"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("does nothing after it is stopped", () => {
|
||||
const raf = vi.spyOn(window, "requestAnimationFrame");
|
||||
const repaint = vi.fn();
|
||||
watchSceneWebglContexts(repaint)();
|
||||
const lost = new Event("webglcontextlost", { cancelable: true });
|
||||
canvas().dispatchEvent(lost);
|
||||
canvas().dispatchEvent(new Event("webglcontextrestored"));
|
||||
expect(lost.defaultPrevented).toBe(false);
|
||||
expect(raf).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export function watchSceneWebglContexts(onRestored: () => void): () => void {
|
||||
let frame: number | null = null;
|
||||
// Without preventDefault the browser never restores the context.
|
||||
const onLost = (event: Event) => event.preventDefault();
|
||||
const onRestore = () => {
|
||||
if (frame !== null) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
onRestored();
|
||||
});
|
||||
};
|
||||
// These events do not bubble, so only a capture listener sees every canvas.
|
||||
document.addEventListener("webglcontextlost", onLost, true);
|
||||
document.addEventListener("webglcontextrestored", onRestore, true);
|
||||
return () => {
|
||||
document.removeEventListener("webglcontextlost", onLost, true);
|
||||
document.removeEventListener("webglcontextrestored", onRestore, true);
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user