Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/lib/exporter/backendPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest";

import {
getDefaultLightningRenderBackend,
isWebGPURendererFailure,
normalizeLightningRuntimePlatform,
planLightningExportRoutes,
resolveLightningPreferredRenderBackend,
resolveLightningRenderBackendOrder,
shouldPreferNativeAutoBackend,
shouldPreferNativeStaticLayoutBeforeBreeze,
} from "./backendPolicy";
Expand All @@ -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);
Expand Down
63 changes: 63 additions & 0 deletions src/lib/exporter/backendPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
25 changes: 17 additions & 8 deletions src/lib/exporter/modernFrameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
90 changes: 90 additions & 0 deletions src/lib/exporter/modernVideoExporter.fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
};

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<unknown>;
};

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",
Expand Down
24 changes: 23 additions & 1 deletion src/lib/exporter/modernVideoExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ import {
} from "@/lib/wallpapers";
import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder";
import {
isWebGPURendererFailure,
normalizeLightningRuntimePlatform,
resolveLightningPreferredRenderBackend,
shouldPreferNativeAutoBackend,
shouldPreferNativeStaticLayoutBeforeBreeze,
} from "./backendPolicy";
Expand Down Expand Up @@ -380,6 +382,8 @@ export class ModernVideoExporter {
async export(): Promise<ExportResult> {
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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 &&
Expand Down