From 7e73f52a14a3fab5306928ca180d3c9c6ca9650a Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 22 Jun 2026 09:46:51 +0000 Subject: [PATCH] Recover preview from WebGL context loss on Linux/Wayland MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Manjaro / CachyOS (Linux/Wayland with Mesa/EGL) the preview Pixi app can lose its WebGL context during heavy GPU usage from the exporter — most likely the `VideoEncoder`'s `prefer-hardware` path or the Linux-only CPU readback in `frameRenderer.ts.readbackVideoCanvas`. The wallpaper is rendered to a 2D canvas (`frameRenderer.ts:212`, "Background renders separately, not in PixiJS") and survives the context loss, but the video sprite lives inside the lost Pixi GL context and never reappears. That is the "only background remains" symptom in issue #8. Fix: attach a `webglcontextlost` listener to the preview Pixi canvas. `preventDefault()` opts in to Chromium's restoration attempt, and bumping a `pixiGeneration` state tears the broken app down via the existing `useEffect` cleanup so the next mount rebuilds it from scratch with a fresh WebGL context. The recovery is logged for diagnostics ("[VideoPlayback] WebGL context lost, recreating Pixi app"), which will now reach the terminal thanks to the console-message forwarding added to `electron/main.ts` in PR #11. Extracted the handler into `createWebGLContextLostHandler` for unit testability and added a small focused test that covers the two invariants the bug hinges on: the event's `preventDefault` is called and the regenerate callback fires. Tested: - npx tsc --noEmit - npm run lint - npm test (244 passed, +3 new) - npm run build-vite --- .../video-editor/VideoPlayback.test.ts | 38 +++++++++ src/components/video-editor/VideoPlayback.tsx | 82 +++++++++++++++++-- 2 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 src/components/video-editor/VideoPlayback.test.ts diff --git a/src/components/video-editor/VideoPlayback.test.ts b/src/components/video-editor/VideoPlayback.test.ts new file mode 100644 index 000000000..165512851 --- /dev/null +++ b/src/components/video-editor/VideoPlayback.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import { createWebGLContextLostHandler } from "@/components/video-editor/VideoPlayback"; + +describe("createWebGLContextLostHandler", () => { + it("calls preventDefault on the lost event so Chromium can attempt restoration", () => { + const regenerate = vi.fn(); + const handler = createWebGLContextLostHandler({ generation: 0, regenerate }); + + const event = new Event("webglcontextlost"); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + handler(event); + + expect(preventDefaultSpy).toHaveBeenCalledOnce(); + }); + + it("triggers the regenerate callback so React can rebuild the Pixi app", () => { + const regenerate = vi.fn(); + const handler = createWebGLContextLostHandler({ generation: 0, regenerate }); + + handler(new Event("webglcontextlost")); + + expect(regenerate).toHaveBeenCalledOnce(); + }); + + it("logs the generation captured at handler construction time", () => { + const regenerate = vi.fn(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const handler = createWebGLContextLostHandler({ generation: 7, regenerate }); + + handler(new Event("webglcontextlost")); + + expect(warnSpy).toHaveBeenCalledWith( + "[VideoPlayback] WebGL context lost, recreating Pixi app", + { generation: 7 }, + ); + warnSpy.mockRestore(); + }); +}); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 1b4ad5263..27d5dfdf5 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -215,6 +215,33 @@ function enableAllPreviewAudioTracks(video: HTMLVideoElement) { } } +/** + * Builds a `webglcontextlost` handler for the preview Pixi canvas. + * + * On Linux/Wayland the preview can lose its WebGL context during heavy GPU + * usage from the exporter (e.g. the VideoEncoder's hardware path on + * Mesa/EGL, or the Linux-specific CPU readback in `frameRenderer.ts`). + * Without recovery the video sprite never reappears — only the wallpaper + * (rendered to a 2D canvas) stays visible. See issue #8. + * + * `preventDefault()` opts in to the restoration attempt so Chromium can + * fire `webglcontextrestored`. The caller is expected to bump `regenerate` + * so React tears the broken app down and rebuilds from scratch on the + * next mount. + */ +export function createWebGLContextLostHandler(params: { + generation: number; + regenerate: () => void; +}): (event: Event) => void { + return (event: Event) => { + event.preventDefault(); + console.warn("[VideoPlayback] WebGL context lost, recreating Pixi app", { + generation: params.generation, + }); + params.regenerate(); + }; +} + const VideoPlayback = forwardRef( ( { @@ -290,6 +317,7 @@ const VideoPlayback = forwardRef( const cameraContainerRef = useRef(null); const timeUpdateAnimationRef = useRef(null); const [pixiReady, setPixiReady] = useState(false); + const [pixiGeneration, setPixiGeneration] = useState(0); const [videoReady, setVideoReady] = useState(false); const [supplementalAudioPath, setSupplementalAudioPath] = useState(null); const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 }); @@ -992,6 +1020,10 @@ const VideoPlayback = forwardRef( let mounted = true; let app: Application | null = null; + // Declared outside the async IIFE so the cleanup function can + // detach them after Pixi tears the canvas down. + let handleContextLost: ((event: Event) => void) | null = null; + let handleContextRestored: (() => void) | null = null; (async () => { let cursorOverlayEnabled = true; @@ -1003,14 +1035,19 @@ const VideoPlayback = forwardRef( app = new Application(); - await app.init({ - width: container.clientWidth, - height: container.clientHeight, - backgroundAlpha: 0, - antialias: true, - resolution: window.devicePixelRatio || 1, - autoDensity: true, - }); + try { + await app.init({ + width: container.clientWidth, + height: container.clientHeight, + backgroundAlpha: 0, + antialias: true, + resolution: window.devicePixelRatio || 1, + autoDensity: true, + }); + } catch (error) { + console.error("[VideoPlayback] Pixi init failed:", error); + return; + } app.ticker.maxFPS = 60; @@ -1026,6 +1063,24 @@ const VideoPlayback = forwardRef( appRef.current = app; container.appendChild(app.canvas); + // Recover from WebGL context loss. On Linux/Wayland the preview + // Pixi app can lose its context during heavy GPU usage from the + // exporter (e.g. the VideoEncoder's hardware path on Mesa/EGL). + // Without recovery the video sprite never reappears — only the + // wallpaper (rendered to a 2D canvas) stays visible. See issue + // #8. preventDefault() opts in to the restoration attempt so + // Chromium can fire webglcontextrestored, then we tear the + // broken app down and rebuild from scratch on the next mount. + handleContextLost = createWebGLContextLostHandler({ + generation: pixiGeneration, + regenerate: () => setPixiGeneration((g) => g + 1), + }); + handleContextRestored = () => { + console.info("[VideoPlayback] WebGL context restored", { generation: pixiGeneration }); + }; + app.canvas.addEventListener("webglcontextlost", handleContextLost); + app.canvas.addEventListener("webglcontextrestored", handleContextRestored); + // Camera container - this will be scaled/positioned for zoom const cameraContainer = new Container(); cameraContainerRef.current = cameraContainer; @@ -1061,6 +1116,15 @@ const VideoPlayback = forwardRef( nativeCursorTextureIdRef.current = null; nativeCursorImageIdRef.current = null; if (app && app.renderer) { + // Detach the recovery listeners from the canvas before the + // app tears it down so we don't leak handlers on a detached + // element. + if (handleContextLost) { + app.canvas.removeEventListener("webglcontextlost", handleContextLost); + } + if (handleContextRestored) { + app.canvas.removeEventListener("webglcontextrestored", handleContextRestored); + } app.destroy(true, { children: true, texture: true, @@ -1072,7 +1136,7 @@ const VideoPlayback = forwardRef( videoContainerRef.current = null; videoSpriteRef.current = null; }; - }, []); + }, [pixiGeneration]); useEffect(() => { if (!videoPath) {