Prefer WebCodecs auto export and harden Pixi fallback

This commit is contained in:
webadderall
2026-04-06 15:23:06 +10:00
parent 96d2ca9e5c
commit 012bec21a7
7 changed files with 188 additions and 37 deletions
+44 -1
View File
@@ -815,6 +815,9 @@ export default function VideoEditor() {
percentage: 100,
estimatedTimeRemaining: 0,
renderFps: previous?.renderFps,
renderBackend: previous?.renderBackend,
encodeBackend: previous?.encodeBackend,
encoderName: previous?.encoderName,
phase: "saving",
}));
}, []);
@@ -3463,7 +3466,6 @@ export default function VideoEditor() {
setIsExporting(false);
exporterRef.current = null;
setShowExportDropdown(keepExportDialogOpen);
setExportProgress(null);
remountPreview();
}
},
@@ -3755,6 +3757,38 @@ export default function VideoEditor() {
fps: exportProgress.renderFps.toFixed(1),
})
: null;
const exportRuntimeLabel = useMemo(() => {
const renderBackend = exportProgress?.renderBackend;
const encodeBackend = exportProgress?.encodeBackend;
const encoderName = exportProgress?.encoderName;
if (!renderBackend && !encodeBackend && !encoderName) {
return null;
}
const rendererLabel =
renderBackend === "webgpu"
? "WebGPU"
: renderBackend === "webgl"
? "WebGL"
: null;
const encoderLabel =
encodeBackend === "ffmpeg"
? "Breeze"
: encodeBackend === "webcodecs"
? "WebCodecs"
: null;
const pathLabel =
rendererLabel && encoderLabel
? `${rendererLabel} + ${encoderLabel}`
: rendererLabel ?? encoderLabel;
if (!pathLabel) {
return encoderName ?? null;
}
return encoderName ? `${pathLabel} (${encoderName})` : pathLabel;
}, [exportProgress]);
const exportPercentLabel = exportProgress
? isExportSaving
? t("editor.exportStatus.saving", "Opening save dialog...")
@@ -3971,12 +4005,18 @@ export default function VideoEditor() {
{exportRenderSpeedLabel ? (
<p className="mt-1 text-[11px] text-slate-500">{exportRenderSpeedLabel}</p>
) : null}
{exportRuntimeLabel ? (
<p className="mt-1 text-[11px] text-slate-500">Path: {exportRuntimeLabel}</p>
) : null}
</div>
) : exportError ? (
<div className="rounded-2xl border border-white/10 bg-[#17171a] p-4 text-slate-200 shadow-2xl">
<p className="text-sm font-semibold text-white">
{t("editor.exportStatus.issue", "Export issue")}
</p>
{exportRuntimeLabel ? (
<p className="mt-1 text-[11px] text-slate-500">Path: {exportRuntimeLabel}</p>
) : null}
<p className="mt-1 whitespace-pre-line text-xs leading-relaxed text-slate-400">{exportError}</p>
<div className="mt-4 flex gap-2">
{hasPendingExportSave ? (
@@ -4009,6 +4049,9 @@ export default function VideoEditor() {
"Your file was saved successfully.",
)}
</p>
{exportRuntimeLabel ? (
<p className="mt-1 text-[11px] text-slate-500">Path: {exportRuntimeLabel}</p>
) : null}
<p className="mt-3 truncate text-xs text-slate-500">
{exportedFilePath.split("/").pop()}
</p>
@@ -1044,8 +1044,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
height: container.clientHeight,
backgroundAlpha: 0,
antialias: true,
failIfMajorPerformanceCaveat: false,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
preference: "webgl",
});
app.ticker.maxFPS = 60;
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { normalizeLightningRuntimePlatform, shouldPreferNativeAutoBackend } from "./backendPolicy";
describe("backendPolicy", () => {
it("normalizes common platform hints", () => {
expect(normalizeLightningRuntimePlatform("Win32")).toBe("win32");
expect(normalizeLightningRuntimePlatform("Linux x86_64")).toBe("linux");
expect(normalizeLightningRuntimePlatform("MacIntel")).toBe("darwin");
expect(normalizeLightningRuntimePlatform("unknown")).toBe("unknown");
});
it("keeps auto backend WebCodecs-first on every platform", () => {
expect(shouldPreferNativeAutoBackend("win32")).toBe(false);
expect(shouldPreferNativeAutoBackend("linux")).toBe(false);
expect(shouldPreferNativeAutoBackend("darwin")).toBe(false);
expect(shouldPreferNativeAutoBackend("unknown")).toBe(false);
});
});
+27
View File
@@ -0,0 +1,27 @@
export type LightningRuntimePlatform = "darwin" | "win32" | "linux" | "unknown";
export function normalizeLightningRuntimePlatform(
platformHint: string | null | undefined,
): LightningRuntimePlatform {
if (!platformHint) {
return "unknown";
}
if (/win/i.test(platformHint)) {
return "win32";
}
if (/linux/i.test(platformHint)) {
return "linux";
}
if (/mac|iphone|ipad|ipod/i.test(platformHint)) {
return "darwin";
}
return "unknown";
}
export function shouldPreferNativeAutoBackend(_platform: LightningRuntimePlatform): boolean {
return false;
}
+1
View File
@@ -210,6 +210,7 @@ export class FrameRenderer {
height: this.config.height,
backgroundAlpha: 0,
antialias: true,
failIfMajorPerformanceCaveat: false,
resolution: 1,
autoDensity: true,
autoStart: false,
+1
View File
@@ -470,6 +470,7 @@ export class FrameRenderer {
height: this.config.height,
backgroundAlpha: 0,
antialias: true,
failIfMajorPerformanceCaveat: false,
resolution: 1,
autoDensity: true,
autoStart: false,
+94 -36
View File
@@ -13,6 +13,11 @@ import type {
ZoomTransitionEasing,
} from "@/components/video-editor/types";
import { AudioProcessor } from "./audioEncoder";
import {
normalizeLightningRuntimePlatform,
shouldPreferNativeAutoBackend,
type LightningRuntimePlatform,
} from "./backendPolicy";
import { FrameRenderer as ModernFrameRenderer } from "./modernFrameRenderer";
import {
getOrderedSupportedMp4EncoderCandidates,
@@ -158,6 +163,10 @@ export class ModernVideoExporter {
let stageStartedAt = this.getNowMs();
const backendPreference = this.config.backendPreference ?? "auto";
const runtimePlatform =
backendPreference === "auto" ? await this.getRuntimePlatform() : "unknown";
const preferNativeFirstInAuto =
backendPreference === "auto" && shouldPreferNativeAutoBackend(runtimePlatform);
let useNativeEncoder = false;
this.lastNativeExportError = null;
@@ -172,39 +181,70 @@ export class ModernVideoExporter {
);
}
} else {
try {
const configuredWebCodecsPath = await this.initializeEncoder();
if (
backendPreference === "auto" &&
configuredWebCodecsPath.hardwareAcceleration === "prefer-software"
) {
console.warn(
"[VideoExporter] Auto backend resolved to a software WebCodecs encoder; trying Breeze native export instead.",
);
stageStartedAt = this.getNowMs();
useNativeEncoder = await this.tryStartNativeVideoExport();
this.nativeSessionStartTimeMs = this.getNowMs() - stageStartedAt;
if (useNativeEncoder) {
this.disposeEncoder();
}
}
} catch (error) {
const webCodecsError = error instanceof Error ? error : new Error(String(error));
if (backendPreference === "webcodecs") {
throw webCodecsError;
}
console.warn(
`[VideoExporter] WebCodecs encoder unavailable, trying ${NATIVE_EXPORT_ENGINE_NAME} native export fallback`,
webCodecsError,
);
this.disposeEncoder();
if (preferNativeFirstInAuto) {
stageStartedAt = this.getNowMs();
useNativeEncoder = await this.tryStartNativeVideoExport();
this.nativeSessionStartTimeMs = this.getNowMs() - stageStartedAt;
if (useNativeEncoder) {
console.log(
`[VideoExporter] Auto backend preferred ${NATIVE_EXPORT_ENGINE_NAME} first on ${runtimePlatform}; skipping WebCodecs startup.`,
);
} else {
console.log(
`[VideoExporter] Auto backend could not start ${NATIVE_EXPORT_ENGINE_NAME} on ${runtimePlatform}; falling back to WebCodecs.`,
);
}
}
if (!useNativeEncoder) {
throw webCodecsError;
if (!useNativeEncoder) {
try {
const configuredWebCodecsPath = await this.initializeEncoder();
if (
!preferNativeFirstInAuto &&
backendPreference === "auto" &&
configuredWebCodecsPath.hardwareAcceleration === "prefer-software"
) {
console.warn(
"[VideoExporter] Auto backend resolved to a software WebCodecs encoder; trying Breeze native export instead.",
);
stageStartedAt = this.getNowMs();
useNativeEncoder = await this.tryStartNativeVideoExport();
this.nativeSessionStartTimeMs = this.getNowMs() - stageStartedAt;
if (useNativeEncoder) {
this.disposeEncoder();
}
} else if (
preferNativeFirstInAuto &&
backendPreference === "auto" &&
configuredWebCodecsPath.hardwareAcceleration === "prefer-software"
) {
console.warn(
`[VideoExporter] Auto backend fell back to a software WebCodecs encoder after ${NATIVE_EXPORT_ENGINE_NAME} startup was unavailable on ${runtimePlatform}.`,
);
}
} catch (error) {
const webCodecsError = error instanceof Error ? error : new Error(String(error));
if (backendPreference === "webcodecs") {
throw webCodecsError;
}
if (preferNativeFirstInAuto) {
throw webCodecsError;
}
console.warn(
`[VideoExporter] WebCodecs encoder unavailable, trying ${NATIVE_EXPORT_ENGINE_NAME} native export fallback`,
webCodecsError,
);
this.disposeEncoder();
stageStartedAt = this.getNowMs();
useNativeEncoder = await this.tryStartNativeVideoExport();
this.nativeSessionStartTimeMs = this.getNowMs() - stageStartedAt;
if (!useNativeEncoder) {
throw webCodecsError;
}
}
}
}
@@ -453,23 +493,41 @@ export class ModernVideoExporter {
}
}
private async getRuntimePlatform(): Promise<LightningRuntimePlatform> {
if (typeof window !== "undefined" && window.electronAPI?.getPlatform) {
try {
return normalizeLightningRuntimePlatform(await window.electronAPI.getPlatform());
} catch (error) {
console.warn(
"[VideoExporter] Failed to read runtime platform from Electron API; falling back to navigator hints.",
error,
);
}
}
if (typeof navigator === "undefined") {
return "unknown";
}
return normalizeLightningRuntimePlatform(navigator.platform || navigator.userAgent || "");
}
private getPlatformLabel(): string {
if (typeof navigator === "undefined") {
return "Unknown";
}
const platformHint = navigator.platform || navigator.userAgent || "";
if (/Win/i.test(platformHint)) {
switch (normalizeLightningRuntimePlatform(platformHint)) {
case "win32":
return "Windows";
}
if (/Linux/i.test(platformHint)) {
case "linux":
return "Linux";
}
if (/Mac|iPhone|iPad|iPod/i.test(platformHint)) {
case "darwin":
return "macOS";
default:
return platformHint || "Unknown";
}
return platformHint || "Unknown";
}
private getLightningErrorGuidance(message: string): string[] {