Merge pull request #744 from webadderallorg/fix/lightning-pixi-lifecycle

fix(export): preserve Pixi fallback after renderer init failure
This commit is contained in:
PTMH-NMH
2026-07-11 11:54:20 +07:00
committed by GitHub
6 changed files with 303 additions and 101 deletions
+12 -38
View File
@@ -17,6 +17,10 @@ import {
enablePitchPreservingPlayback,
getMediaSyncPlaybackRate,
} from "@/lib/mediaTiming";
import {
destroyPixiApplication,
initializePixiApplicationWithTimeout,
} from "@/lib/pixiApplicationLifecycle";
import {
DEFAULT_WALLPAPER_PATH,
DEFAULT_WALLPAPER_RELATIVE_PATH,
@@ -259,30 +263,6 @@ function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): st
return `No supported Pixi preview renderer was available. Attempted: ${details}`;
}
type PixiInitOptions = Parameters<Application["init"]>[0];
async function initApplicationWithTimeout(
app: Application,
options: PixiInitOptions,
backend: PixiPreviewBackend,
): Promise<void> {
const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(timeoutErrorMessage));
}, PIXI_RENDERER_INIT_TIMEOUT_MS);
});
try {
await Promise.race([app.init(options), timeoutPromise]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
function getCursorPositionAtTime(
telemetry: CursorTelemetryPoint[],
timeMs: number,
@@ -680,7 +660,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const initStarted =
typeof performance === "undefined" ? Date.now() : performance.now();
try {
await initApplicationWithTimeout(
await initializePixiApplicationWithTimeout(
rendererApp,
{
width: container.clientWidth,
@@ -694,6 +674,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
autoStart: true,
sharedTicker: false,
},
PIXI_RENDERER_INIT_TIMEOUT_MS,
backend,
);
const elapsed = Math.round(
@@ -722,7 +703,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
`[VideoPlayback] Failed to init ${backend} renderer (${statusMessage}) after ${elapsed}ms; trying fallback.`,
error,
);
rendererApp.destroy(true);
destroyPixiApplication(
rendererApp,
`${backend} preview renderer initialization`,
);
}
}
@@ -2126,11 +2110,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
app.ticker.maxFPS = 60;
if (!mounted) {
app.destroy(true, {
children: true,
texture: false,
textureSource: false,
});
destroyPixiApplication(app, "unmounted preview renderer");
return;
}
@@ -2226,13 +2206,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionBlurFilterRef.current?.destroy();
zoomBlurFilterRef.current = null;
motionBlurFilterRef.current = null;
if (app && app.renderer) {
app.destroy(true, {
children: true,
texture: false,
textureSource: false,
});
}
destroyPixiApplication(app, "preview renderer");
appRef.current = null;
cameraContainerRef.current = null;
videoEffectsContainerRef.current = null;
+8 -31
View File
@@ -72,6 +72,10 @@ import {
clampMediaTimeToDuration,
getEffectiveVideoStreamDurationSeconds,
} from "@/lib/mediaTiming";
import {
destroyPixiApplication,
initializePixiApplicationWithTimeout,
} from "@/lib/pixiApplicationLifecycle";
import { isVideoWallpaperSource } from "@/lib/wallpapers";
import { renderAnnotations } from "./annotationRenderer";
import { renderCaptions } from "./captionRenderer";
@@ -173,30 +177,6 @@ function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error ?? "Unknown renderer init error");
}
type PixiInitOptions = Parameters<Application["init"]>[0];
async function initApplicationWithTimeout(
app: Application,
options: PixiInitOptions,
backend: ExportRenderBackend,
): Promise<void> {
const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(timeoutErrorMessage));
}, PIXI_RENDERER_INIT_TIMEOUT_MS);
});
try {
await Promise.race([app.init(options), timeoutPromise]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): string {
const details = attempts.map((attempt) => `${attempt.backend}: ${attempt.message}`).join(" | ");
return `No supported Pixi export backend was available. Attempted: ${details}`;
@@ -358,12 +338,13 @@ export class FrameRenderer {
const app = new Application();
const initStarted = typeof performance === "undefined" ? Date.now() : performance.now();
try {
await initApplicationWithTimeout(
await initializePixiApplicationWithTimeout(
app,
{
...baseOptions,
preference: backend,
},
PIXI_RENDERER_INIT_TIMEOUT_MS,
backend,
);
const elapsed = Math.round(
@@ -389,7 +370,7 @@ export class FrameRenderer {
`[FrameRenderer] ${backend} renderer unavailable after ${elapsed}ms; trying next backend.`,
error,
);
app.destroy(true);
destroyPixiApplication(app, `${backend} export renderer initialization`);
}
}
@@ -2587,11 +2568,7 @@ export class FrameRenderer {
}
this.backgroundSprite = null;
if (this.app) {
this.app.destroy(true, {
children: true,
texture: false,
textureSource: false,
});
destroyPixiApplication(this.app, "legacy export renderer");
this.app = null;
}
this.zoomBlurFilter?.destroy();
+54 -1
View File
@@ -6,12 +6,21 @@ const {
destroyForwardFrameSourceMock,
getForwardFrameAtTimeMock,
initializeForwardFrameSourceMock,
pixiApplicationInstancesMock,
pixiInitializationErrorsMock,
resolveMediaElementSourceMock,
} = vi.hoisted(() => ({
cancelForwardFrameSourceMock: vi.fn(),
destroyForwardFrameSourceMock: vi.fn(async () => undefined),
getForwardFrameAtTimeMock: vi.fn(async () => null),
initializeForwardFrameSourceMock: vi.fn(async () => undefined),
pixiApplicationInstancesMock: [] as Array<{
destroy: ReturnType<typeof vi.fn>;
init: ReturnType<typeof vi.fn>;
renderer: { destroy: ReturnType<typeof vi.fn> };
stage: { destroy: ReturnType<typeof vi.fn> };
}>,
pixiInitializationErrorsMock: [] as Array<Error | undefined>,
resolveMediaElementSourceMock: vi.fn(async () => ({
src: "blob:background",
revoke: vi.fn(),
@@ -19,7 +28,21 @@ const {
}));
vi.mock("pixi.js", () => ({
Application: class {},
Application: class {
destroy = vi.fn(() => {
throw new TypeError("this._cancelResize is not a function");
});
init = vi.fn(async () => {
const error = pixiInitializationErrorsMock.shift();
if (error) throw error;
});
renderer = { destroy: vi.fn() };
stage = { destroy: vi.fn() };
constructor() {
pixiApplicationInstancesMock.push(this);
}
},
BlurFilter: class {},
Container: class {
visible = true;
@@ -179,6 +202,36 @@ function createRenderer() {
});
}
describe("ModernFrameRenderer Pixi lifecycle", () => {
it("continues to the next backend when failed-init cleanup would throw", async () => {
pixiApplicationInstancesMock.length = 0;
pixiInitializationErrorsMock.length = 0;
pixiInitializationErrorsMock.push(new Error("WebGPU initialization failed"), undefined);
vi.stubGlobal("navigator", { gpu: {} });
try {
const renderer = createRenderer() as unknown as {
config: { preferredRenderBackend?: "webgl" | "webgpu" };
createPixiApplication: (
canvas: HTMLCanvasElement,
) => Promise<{ backend: "webgl" | "webgpu" }>;
};
renderer.config.preferredRenderBackend = "webgpu";
await expect(renderer.createPixiApplication({} as HTMLCanvasElement)).resolves.toMatchObject({
backend: "webgl",
});
expect(pixiApplicationInstancesMock).toHaveLength(2);
expect(pixiApplicationInstancesMock[0].destroy).not.toHaveBeenCalled();
expect(pixiApplicationInstancesMock[0].stage.destroy).toHaveBeenCalledTimes(1);
expect(pixiApplicationInstancesMock[0].renderer.destroy).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllGlobals();
}
});
});
describe("ModernFrameRenderer blur export path", () => {
beforeEach(() => {
Object.assign(globalThis, {
+8 -31
View File
@@ -82,6 +82,10 @@ import {
clampMediaTimeToDuration,
getEffectiveVideoStreamDurationSeconds,
} from "@/lib/mediaTiming";
import {
destroyPixiApplication,
initializePixiApplicationWithTimeout,
} from "@/lib/pixiApplicationLifecycle";
import { isVideoWallpaperSource } from "@/lib/wallpapers";
import {
type AnnotationRenderAssets,
@@ -284,30 +288,6 @@ function isKnownRendererUnavailableError(error: unknown): boolean {
);
}
type PixiInitOptions = Parameters<Application["init"]>[0];
async function initApplicationWithTimeout(
app: Application,
options: PixiInitOptions,
backend: ExportRenderBackend,
): Promise<void> {
const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(timeoutErrorMessage));
}, PIXI_RENDERER_INIT_TIMEOUT_MS);
});
try {
await Promise.race([app.init(options), timeoutPromise]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
interface RenderSnapshot {
timeMs: number;
cursorTimeMs: number;
@@ -720,12 +700,13 @@ export class FrameRenderer {
const app = new Application();
const initStarted = typeof performance === "undefined" ? Date.now() : performance.now();
try {
await initApplicationWithTimeout(
await initializePixiApplicationWithTimeout(
app,
{
...baseOptions,
preference: backend,
},
PIXI_RENDERER_INIT_TIMEOUT_MS,
backend,
);
const elapsed = Math.round(
@@ -754,7 +735,7 @@ export class FrameRenderer {
`[FrameRenderer] ${backend} export renderer unavailable (${rendererMessage}) after ${elapsed}ms; trying next backend:`,
error,
);
app.destroy(true);
destroyPixiApplication(app, `${backend} export renderer initialization`);
}
}
@@ -3921,11 +3902,7 @@ export class FrameRenderer {
this.motionBlurFilter?.destroy();
this.backgroundBlurFilter?.destroy();
this.app?.destroy(true, {
children: true,
texture: false,
textureSource: false,
});
destroyPixiApplication(this.app, "Lightning export renderer");
for (const texture of texturesToDestroy) {
try {
+113
View File
@@ -0,0 +1,113 @@
import type { Application } from "pixi.js";
import { describe, expect, it, vi } from "vitest";
import {
destroyPixiApplication,
initializePixiApplication,
initializePixiApplicationWithTimeout,
} from "./pixiApplicationLifecycle";
function createApplication(init: () => Promise<void> = async () => undefined) {
return {
init: vi.fn(init),
destroy: vi.fn(),
stage: { destroy: vi.fn() },
renderer: { destroy: vi.fn() },
} as unknown as Application;
}
describe("Pixi application lifecycle", () => {
it("cleans a failed initialization without running uninitialized plugins", async () => {
const initializationError = new Error("No available renderer");
const app = createApplication(async () => {
throw initializationError;
});
const applicationDestroy = vi.mocked(app.destroy);
applicationDestroy.mockImplementation(() => {
throw new TypeError("this._cancelResize is not a function");
});
await expect(initializePixiApplication(app, {})).rejects.toBe(initializationError);
expect(() => destroyPixiApplication(app, "test renderer init")).not.toThrow();
expect(applicationDestroy).not.toHaveBeenCalled();
expect(app.stage.destroy).toHaveBeenCalledWith({
children: true,
texture: false,
textureSource: false,
});
expect(app.renderer.destroy).toHaveBeenCalledWith({
removeView: true,
releaseGlobalResources: false,
});
});
it("destroys a successfully initialized application at most once", async () => {
const app = createApplication();
await initializePixiApplication(app, {});
destroyPixiApplication(app, "test renderer");
destroyPixiApplication(app, "test renderer");
expect(app.destroy).toHaveBeenCalledTimes(1);
expect(app.destroy).toHaveBeenCalledWith(
{ removeView: true, releaseGlobalResources: false },
{ children: true, texture: false, textureSource: false },
);
});
it("defers teardown until an in-flight initialization settles", async () => {
let finishInitialization: (() => void) | undefined;
const app = createApplication(
() =>
new Promise<void>((resolve) => {
finishInitialization = resolve;
}),
);
const initialization = initializePixiApplication(app, {});
destroyPixiApplication(app, "timed-out renderer init");
expect(app.destroy).not.toHaveBeenCalled();
finishInitialization?.();
await initialization;
expect(app.destroy).toHaveBeenCalledTimes(1);
});
it("reports cleanup errors without throwing or retrying unsafe teardown", async () => {
const app = createApplication();
const cleanupError = new Error("renderer cleanup failed");
vi.mocked(app.destroy).mockImplementation(() => {
throw cleanupError;
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
await initializePixiApplication(app, {});
expect(() => destroyPixiApplication(app, "test renderer")).not.toThrow();
expect(() => destroyPixiApplication(app, "test renderer")).not.toThrow();
expect(app.destroy).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
"[PixiApplication] Failed to clean up test renderer:",
cleanupError,
);
warn.mockRestore();
});
it("reports the backend when initialization times out", async () => {
vi.useFakeTimers();
try {
const app = createApplication(() => new Promise<void>(() => undefined));
const initialization = initializePixiApplicationWithTimeout(app, {}, 250, "webgpu");
const rejection = initialization.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(250);
await expect(rejection).resolves.toEqual(
new Error("Initialization timed out after 250ms for webgpu renderer"),
);
} finally {
vi.useRealTimers();
}
});
});
+108
View File
@@ -0,0 +1,108 @@
import type { Application } from "pixi.js";
type PixiInitializationState = "initializing" | "initialized" | "failed";
type PixiInitOptions = Parameters<Application["init"]>[0];
const initializationStates = new WeakMap<Application, PixiInitializationState>();
const destroyRequests = new WeakSet<Application>();
const destroyContexts = new WeakMap<Application, string>();
const completedCleanups = new WeakSet<Application>();
const RENDERER_DESTROY_OPTIONS = {
removeView: true,
releaseGlobalResources: false,
} as const;
const STAGE_DESTROY_OPTIONS = {
children: true,
texture: false,
textureSource: false,
} as const;
function reportCleanupError(app: Application, error: unknown): void {
const context = destroyContexts.get(app) ?? "Pixi application";
console.warn(`[PixiApplication] Failed to clean up ${context}:`, error);
}
function destroyFailedApplication(app: Application): void {
const partialApp = app as Partial<Application>;
try {
partialApp.stage?.destroy(STAGE_DESTROY_OPTIONS);
} catch (error) {
reportCleanupError(app, error);
}
try {
partialApp.renderer?.destroy(RENDERER_DESTROY_OPTIONS);
} catch (error) {
reportCleanupError(app, error);
}
}
function completeDestroy(app: Application): void {
if (completedCleanups.has(app)) return;
completedCleanups.add(app);
if (initializationStates.get(app) !== "initialized") {
destroyFailedApplication(app);
return;
}
try {
app.destroy(RENDERER_DESTROY_OPTIONS, STAGE_DESTROY_OPTIONS);
} catch (error) {
reportCleanupError(app, error);
}
}
export async function initializePixiApplication(
app: Application,
options: PixiInitOptions,
): Promise<void> {
if (initializationStates.has(app) || destroyRequests.has(app)) {
throw new Error("Pixi application lifecycle has already started");
}
initializationStates.set(app, "initializing");
try {
await app.init(options);
initializationStates.set(app, "initialized");
} catch (error) {
initializationStates.set(app, "failed");
if (destroyRequests.has(app)) completeDestroy(app);
throw error;
}
if (destroyRequests.has(app)) completeDestroy(app);
}
export async function initializePixiApplicationWithTimeout(
app: Application,
options: PixiInitOptions,
timeoutMs: number,
backendLabel: string,
): Promise<void> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new Error(`Initialization timed out after ${timeoutMs}ms for ${backendLabel} renderer`),
);
}, timeoutMs);
});
try {
await Promise.race([initializePixiApplication(app, options), timeoutPromise]);
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
}
export function destroyPixiApplication(app: Application | null, context: string): void {
if (!app || destroyRequests.has(app) || completedCleanups.has(app)) return;
destroyRequests.add(app);
destroyContexts.set(app, context);
if (initializationStates.get(app) !== "initializing") completeDestroy(app);
}