mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 07:45:34 +00:00
fix(export): preserve Pixi fallback after renderer init failure
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
enablePitchPreservingPlayback,
|
||||
getMediaSyncPlaybackRate,
|
||||
} from "@/lib/mediaTiming";
|
||||
import { destroyPixiApplication, initializePixiApplication } from "@/lib/pixiApplicationLifecycle";
|
||||
import {
|
||||
DEFAULT_WALLPAPER_PATH,
|
||||
DEFAULT_WALLPAPER_RELATIVE_PATH,
|
||||
@@ -276,7 +277,7 @@ async function initApplicationWithTimeout(
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([app.init(options), timeoutPromise]);
|
||||
await Promise.race([initializePixiApplication(app, options), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -723,7 +724,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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2127,11 +2131,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;
|
||||
}
|
||||
|
||||
@@ -2227,13 +2227,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;
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
clampMediaTimeToDuration,
|
||||
getEffectiveVideoStreamDurationSeconds,
|
||||
} from "@/lib/mediaTiming";
|
||||
import { destroyPixiApplication, initializePixiApplication } from "@/lib/pixiApplicationLifecycle";
|
||||
import { isVideoWallpaperSource } from "@/lib/wallpapers";
|
||||
import { renderAnnotations } from "./annotationRenderer";
|
||||
import { renderCaptions } from "./captionRenderer";
|
||||
@@ -189,7 +190,7 @@ async function initApplicationWithTimeout(
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([app.init(options), timeoutPromise]);
|
||||
await Promise.race([initializePixiApplication(app, options), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -389,7 +390,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 +2588,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();
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
clampMediaTimeToDuration,
|
||||
getEffectiveVideoStreamDurationSeconds,
|
||||
} from "@/lib/mediaTiming";
|
||||
import { destroyPixiApplication, initializePixiApplication } from "@/lib/pixiApplicationLifecycle";
|
||||
import { isVideoWallpaperSource } from "@/lib/wallpapers";
|
||||
import {
|
||||
type AnnotationRenderAssets,
|
||||
@@ -300,7 +301,7 @@ async function initApplicationWithTimeout(
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([app.init(options), timeoutPromise]);
|
||||
await Promise.race([initializePixiApplication(app, options), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -754,7 +755,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 +3922,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 {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Application } from "pixi.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { destroyPixiApplication, initializePixiApplication } 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
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 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);
|
||||
}
|
||||
Reference in New Issue
Block a user