From de3411a873362db3d488e70d7e9fc225b37680bb Mon Sep 17 00:00:00 2001 From: Hashim-Bhagad Date: Tue, 15 Sep 2026 21:58:31 +0530 Subject: [PATCH] fix(linux): prefer WebGL renderer for Lightning exports with WebGPU fallback retry --- src/lib/exporter/backendPolicy.test.ts | 69 ++++++++++++++ src/lib/exporter/backendPolicy.ts | 63 +++++++++++++ src/lib/exporter/modernFrameRenderer.ts | 25 ++++-- .../modernVideoExporter.fallback.test.ts | 90 +++++++++++++++++++ src/lib/exporter/modernVideoExporter.ts | 24 ++++- 5 files changed, 262 insertions(+), 9 deletions(-) diff --git a/src/lib/exporter/backendPolicy.test.ts b/src/lib/exporter/backendPolicy.test.ts index 69ae5b8f5..6822fe2be 100644 --- a/src/lib/exporter/backendPolicy.test.ts +++ b/src/lib/exporter/backendPolicy.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest"; import { getDefaultLightningRenderBackend, + isWebGPURendererFailure, normalizeLightningRuntimePlatform, planLightningExportRoutes, + resolveLightningPreferredRenderBackend, + resolveLightningRenderBackendOrder, shouldPreferNativeAutoBackend, shouldPreferNativeStaticLayoutBeforeBreeze, } from "./backendPolicy"; @@ -27,6 +30,72 @@ describe("backendPolicy", () => { expect(getDefaultLightningRenderBackend()).toBe("webgl"); }); + it("prefers WebGL for Lightning on Linux while preserving other platforms", () => { + expect(resolveLightningPreferredRenderBackend("linux", undefined)).toBe("webgl"); + expect(resolveLightningPreferredRenderBackend("linux", null)).toBe("webgl"); + expect(resolveLightningPreferredRenderBackend("darwin", undefined)).toBeUndefined(); + expect(resolveLightningPreferredRenderBackend("win32", undefined)).toBeUndefined(); + expect(resolveLightningPreferredRenderBackend("unknown", undefined)).toBeUndefined(); + }); + + it("respects an explicit render backend override on every platform", () => { + expect(resolveLightningPreferredRenderBackend("linux", "webgpu")).toBe("webgpu"); + expect(resolveLightningPreferredRenderBackend("linux", "webgl")).toBe("webgl"); + expect(resolveLightningPreferredRenderBackend("darwin", "webgl")).toBe("webgl"); + expect(resolveLightningPreferredRenderBackend("win32", "webgpu")).toBe("webgpu"); + }); + + it("orders WebGL before WebGPU for Linux Lightning exports", () => { + expect( + resolveLightningRenderBackendOrder({ + preferredRenderBackend: undefined, + platform: "linux", + webgpuAvailable: true, + }), + ).toEqual(["webgl", "webgpu"]); + expect( + resolveLightningRenderBackendOrder({ + preferredRenderBackend: undefined, + platform: "linux", + webgpuAvailable: false, + }), + ).toEqual(["webgl", "webgpu"]); + }); + + it("keeps WebGPU first on macOS/Windows when no backend is requested", () => { + expect( + resolveLightningRenderBackendOrder({ + preferredRenderBackend: undefined, + platform: "darwin", + webgpuAvailable: true, + }), + ).toEqual(["webgpu", "webgl"]); + expect( + resolveLightningRenderBackendOrder({ + preferredRenderBackend: undefined, + platform: "win32", + webgpuAvailable: true, + }), + ).toEqual(["webgpu", "webgl"]); + expect( + resolveLightningRenderBackendOrder({ + preferredRenderBackend: undefined, + platform: "darwin", + webgpuAvailable: false, + }), + ).toEqual(["webgl"]); + }); + + it("detects Pixi WebGPU _resourceType render failures", () => { + expect( + isWebGPURendererFailure( + new Error("Cannot read properties of undefined (reading '_resourceType')"), + ), + ).toBe(true); + expect(isWebGPURendererFailure(new Error("WebCodecs unavailable"))).toBe(false); + expect(isWebGPURendererFailure(new Error("readAVPacket pipeline failed"))).toBe(false); + }); + it("keeps Windows auto exports on the streaming route by default", () => { expect(shouldPreferNativeStaticLayoutBeforeBreeze("win32", "auto")).toBe(false); expect(shouldPreferNativeStaticLayoutBeforeBreeze("darwin", "auto")).toBe(false); diff --git a/src/lib/exporter/backendPolicy.ts b/src/lib/exporter/backendPolicy.ts index 3c7f98479..af8184fcb 100644 --- a/src/lib/exporter/backendPolicy.ts +++ b/src/lib/exporter/backendPolicy.ts @@ -149,3 +149,66 @@ export function planLightningExportRoutes(options: { export function getDefaultLightningRenderBackend(): ExportRenderBackend { return "webgl"; } + +/** + * Resolve the preferred Pixi render backend for a Lightning export. + * + * Linux Mesa/Intel WebGPU adapters can initialize successfully in Electron + * but produce invalid GPU resources on the first rendered frame, surfacing as + * `Cannot read properties of undefined (reading '_resourceType')` (issue #644). + * Legacy exports already render WebGL-first and are unaffected, so Lightning + * on Linux must also start with WebGL while macOS/Windows keep the existing + * WebGPU-first order. + * + * An explicit backend (smoke-test override) is always respected so WebGPU can + * still be exercised on Linux when requested. + */ +export function resolveLightningPreferredRenderBackend( + platform: LightningRuntimePlatform, + explicitPreference?: ExportRenderBackend | null, +): ExportRenderBackend | undefined { + if (explicitPreference === "webgl" || explicitPreference === "webgpu") { + return explicitPreference; + } + + if (platform === "linux") { + return "webgl"; + } + + return undefined; +} + +/** + * Resolve the ordered Pixi backend attempts for a Lightning export. + * WebGL-first on Linux; otherwise preserve the historical behavior of trying + * WebGPU first when the runtime exposes `navigator.gpu`. + */ +export function resolveLightningRenderBackendOrder(options: { + preferredRenderBackend?: ExportRenderBackend | null; + platform: LightningRuntimePlatform; + webgpuAvailable: boolean; +}): ExportRenderBackend[] { + const resolved = resolveLightningPreferredRenderBackend( + options.platform, + options.preferredRenderBackend ?? undefined, + ); + if (resolved === "webgl") { + return ["webgl", "webgpu"]; + } + if (resolved === "webgpu") { + return ["webgpu", "webgl"]; + } + return options.webgpuAvailable ? ["webgpu", "webgl"] : ["webgl"]; +} + +/** + * Detect the Pixi WebGPU render failure seen on Linux (`_resourceType`). + * WebGPU initialization can succeed while the first frame render throws + * `Cannot read properties of undefined (reading '_resourceType')`, which is + * only recoverable by retrying the export with the WebGL renderer. + */ +export function isWebGPURendererFailure(error: unknown): boolean { + const message = + error instanceof Error ? (error.message ?? String(error)) : String(error ?? ""); + return message.toLowerCase().includes("_resourcetype"); +} diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 629f25210..c52e27a7c 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -94,6 +94,10 @@ import { VIDEO_SHADOW_LAYER_PROFILES, WEBCAM_SHADOW_LAYER_PROFILES, } from "./shadowProfile"; +import { + normalizeLightningRuntimePlatform, + resolveLightningRenderBackendOrder, +} from "./backendPolicy"; import type { ExportRenderBackend } from "./types"; interface FrameRenderConfig { @@ -623,14 +627,19 @@ export class FrameRenderer { }; const preferredRenderBackend = this.config.preferredRenderBackend; - const backendOrder: ExportRenderBackend[] = - preferredRenderBackend === "webgl" - ? ["webgl", "webgpu"] - : preferredRenderBackend === "webgpu" - ? ["webgpu", "webgl"] - : typeof navigator !== "undefined" && "gpu" in navigator - ? ["webgpu", "webgl"] - : ["webgl"]; + const runtimePlatform = + typeof navigator !== "undefined" + ? normalizeLightningRuntimePlatform( + (navigator as Navigator & { platform?: string }).platform || + navigator.userAgent || + "", + ) + : "unknown"; + const backendOrder: ExportRenderBackend[] = resolveLightningRenderBackendOrder({ + preferredRenderBackend, + platform: runtimePlatform, + webgpuAvailable: typeof navigator !== "undefined" && "gpu" in navigator, + }); const failures: PixiRendererAttempt[] = []; for (const backend of backendOrder) { diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts index f4d291ab7..59b4464ec 100644 --- a/src/lib/exporter/modernVideoExporter.fallback.test.ts +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -364,6 +364,96 @@ describe("ModernVideoExporter native fallback routing", () => { expect(mocks.muxerFinalize).toHaveBeenCalledTimes(1); }); + it("prefers the WebGL renderer for Lightning exports on Linux", async () => { + vi.stubGlobal("navigator", { + platform: "Linux x86_64", + userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + }); + mocks.streamingDecoderGetEffectiveDuration.mockReturnValue(1); + mocks.streamingDecoderDecodeAll.mockResolvedValue(undefined); + + const { FrameRenderer } = await import("./modernFrameRenderer"); + const exporter = new ModernVideoExporter({ + videoUrl: "file:///recording.mp4", + width: 1440, + height: 810, + frameRate: 30, + bitrate: 8_000_000, + wallpaper: "#101010", + padding: 0, + borderRadius: 0, + backgroundBlur: 0, + shadowIntensity: 0, + showShadow: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + backendPreference: "webcodecs", + } as never) as unknown as { + export: () => Promise<{ success: boolean; blob?: Blob; error?: string }>; + initializeEncoder: () => Promise; + }; + + vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({ + codec: "avc1.640033", + hardwareAcceleration: "prefer-software", + }); + + const result = await exporter.export(); + + expect(result.success).toBe(true); + expect(FrameRenderer).toHaveBeenCalledWith( + expect.objectContaining({ preferredRenderBackend: "webgl" }), + ); + }); + + it("retries a WebGPU _resourceType failure once with the WebGL renderer", async () => { + vi.stubGlobal("navigator", { + platform: "Win32", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }); + mocks.streamingDecoderGetEffectiveDuration.mockReturnValue(1); + mocks.frameRendererGetBackend.mockReturnValueOnce("webgpu"); + mocks.streamingDecoderDecodeAll + .mockRejectedValueOnce( + new Error("Cannot read properties of undefined (reading '_resourceType')"), + ) + .mockResolvedValueOnce(undefined); + + const { FrameRenderer } = await import("./modernFrameRenderer"); + const exporter = new ModernVideoExporter({ + videoUrl: "file:///recording.mp4", + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 8_000_000, + wallpaper: "#101010", + padding: 0, + borderRadius: 0, + backgroundBlur: 0, + shadowIntensity: 0, + showShadow: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + backendPreference: "webcodecs", + } as never) as unknown as { + export: () => Promise<{ success: boolean; blob?: Blob; error?: string }>; + initializeEncoder: () => Promise; + }; + + vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({ + codec: "avc1.640034", + hardwareAcceleration: "prefer-hardware", + }); + + const result = await exporter.export(); + + expect(result.success).toBe(true); + expect(mocks.streamingDecoderDecodeAll).toHaveBeenCalledTimes(2); + const frameRendererMock = vi.mocked(FrameRenderer); + expect(frameRendererMock).toHaveBeenCalledTimes(2); + expect(frameRendererMock.mock.calls[1]?.[0]).toMatchObject({ + preferredRenderBackend: "webgl", + }); + }); + it("builds actionable diagnostics for input decoder failures", () => { vi.stubGlobal("navigator", { platform: "Win32", diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 81137b267..246bd53a6 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -49,7 +49,9 @@ import { } from "@/lib/wallpapers"; import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder"; import { + isWebGPURendererFailure, normalizeLightningRuntimePlatform, + resolveLightningPreferredRenderBackend, shouldPreferNativeAutoBackend, shouldPreferNativeStaticLayoutBeforeBreeze, } from "./backendPolicy"; @@ -380,6 +382,8 @@ export class ModernVideoExporter { async export(): Promise { let useFallbackMediaSource = false; let retriedWithFallbackMediaSource = false; + let forcedRenderBackend: ExportRenderBackend | null = null; + let retriedWithWebGLFallback = false; let nativeFailure: string | null = null; this.mediaSourceRetryAttempted = false; this.runtimeDiagnostics = await this.collectRuntimeDiagnostics(); @@ -604,7 +608,11 @@ export class ModernVideoExporter { timelineEffects: this.config.clipRegions !== undefined, width: this.config.width, height: this.config.height, - preferredRenderBackend: undefined, + preferredRenderBackend: + resolveLightningPreferredRenderBackend( + runtimePlatform, + forcedRenderBackend ?? this.config.preferredRenderBackend, + ) ?? this.config.preferredRenderBackend, wallpaper: this.config.wallpaper, zoomRegions: this.config.zoomRegions, showShadow: this.config.showShadow, @@ -915,6 +923,20 @@ export class ModernVideoExporter { nativeFailure, ); retryExport = true; + } else if ( + !this.cancelled && + !retriedWithWebGLFallback && + forcedRenderBackend !== "webgl" && + this.renderBackend !== "webgl" && + isWebGPURendererFailure(this.encoderError ?? error) + ) { + retriedWithWebGLFallback = true; + forcedRenderBackend = "webgl"; + retryExport = true; + console.warn( + "[VideoExporter] WebGPU render failed (Pixi _resourceType); retrying Lightning export once with WebGL.", + error, + ); } else if ( !this.cancelled && !useFallbackMediaSource &&