diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 8de8b685..7490f454 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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[0]; - -async function initApplicationWithTimeout( - app: Application, - options: PixiInitOptions, - backend: PixiPreviewBackend, -): Promise { - const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`; - let timeoutId: ReturnType | undefined; - const timeoutPromise = new Promise((_, 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( 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( autoStart: true, sharedTicker: false, }, + PIXI_RENDERER_INIT_TIMEOUT_MS, backend, ); const elapsed = Math.round( @@ -722,7 +703,10 @@ const VideoPlayback = forwardRef( `[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( 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( 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; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 3fa2c1f3..018bd176 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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[0]; - -async function initApplicationWithTimeout( - app: Application, - options: PixiInitOptions, - backend: ExportRenderBackend, -): Promise { - const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`; - let timeoutId: ReturnType | undefined; - const timeoutPromise = new Promise((_, 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(); diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index 688e51f2..612a5898 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -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; + init: ReturnType; + renderer: { destroy: ReturnType }; + stage: { destroy: ReturnType }; + }>, + pixiInitializationErrorsMock: [] as Array, 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, { diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 529b2109..33aad0dd 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -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[0]; - -async function initApplicationWithTimeout( - app: Application, - options: PixiInitOptions, - backend: ExportRenderBackend, -): Promise { - const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`; - let timeoutId: ReturnType | undefined; - const timeoutPromise = new Promise((_, 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 { diff --git a/src/lib/pixiApplicationLifecycle.test.ts b/src/lib/pixiApplicationLifecycle.test.ts new file mode 100644 index 00000000..6b576242 --- /dev/null +++ b/src/lib/pixiApplicationLifecycle.test.ts @@ -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 = 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((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(() => 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(); + } + }); +}); diff --git a/src/lib/pixiApplicationLifecycle.ts b/src/lib/pixiApplicationLifecycle.ts new file mode 100644 index 00000000..62a0f743 --- /dev/null +++ b/src/lib/pixiApplicationLifecycle.ts @@ -0,0 +1,108 @@ +import type { Application } from "pixi.js"; + +type PixiInitializationState = "initializing" | "initialized" | "failed"; +type PixiInitOptions = Parameters[0]; + +const initializationStates = new WeakMap(); +const destroyRequests = new WeakSet(); +const destroyContexts = new WeakMap(); +const completedCleanups = new WeakSet(); + +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; + + 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 { + 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 { + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, 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); +}