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:
Miguel Ángel
2026-11-02 17:47:44 +00:00
committed by GitHub
parent 4c1c1782af
commit e8ffaa410f
6 changed files with 148 additions and 9 deletions
+26
View File
@@ -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");
+18 -1
View File
@@ -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);
+28 -1
View File
@@ -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);
+8 -7
View File
@@ -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;
};
}