From e8ffaa410fa8b3265a178bd7d3384d625ab16324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 2 Nov 2026 17:47:44 +0000 Subject: [PATCH] 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 --- packages/core/src/runtime/entry.test.ts | 26 ++++++++++ packages/core/src/runtime/init.ts | 19 +++++++- packages/core/src/runtime/vfx.test.ts | 29 +++++++++++- packages/core/src/runtime/vfx.ts | 15 +++--- .../src/runtime/webglContextRecovery.test.ts | 47 +++++++++++++++++++ .../core/src/runtime/webglContextRecovery.ts | 21 +++++++++ 6 files changed, 148 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/runtime/webglContextRecovery.test.ts create mode 100644 packages/core/src/runtime/webglContextRecovery.ts diff --git a/packages/core/src/runtime/entry.test.ts b/packages/core/src/runtime/entry.test.ts index 36d4f33417..1cdb2c00da 100644 --- a/packages/core/src/runtime/entry.test.ts +++ b/packages/core/src/runtime/entry.test.ts @@ -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"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 719fda0a60..28a1ea4638 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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); diff --git a/packages/core/src/runtime/vfx.test.ts b/packages/core/src/runtime/vfx.test.ts index 4567df5d15..05118e15d9 100644 --- a/packages/core/src/runtime/vfx.test.ts +++ b/packages/core/src/runtime/vfx.test.ts @@ -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); diff --git a/packages/core/src/runtime/vfx.ts b/packages/core/src/runtime/vfx.ts index e00b97a267..766e024116 100644 --- a/packages/core/src/runtime/vfx.ts +++ b/packages/core/src/runtime/vfx.ts @@ -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 { diff --git a/packages/core/src/runtime/webglContextRecovery.test.ts b/packages/core/src/runtime/webglContextRecovery.test.ts new file mode 100644 index 0000000000..ba5024f8b9 --- /dev/null +++ b/packages/core/src/runtime/webglContextRecovery.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/runtime/webglContextRecovery.ts b/packages/core/src/runtime/webglContextRecovery.ts new file mode 100644 index 0000000000..f4a674aef4 --- /dev/null +++ b/packages/core/src/runtime/webglContextRecovery.ts @@ -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; + }; +}