Merge pull request #927 from webadderallorg/codex/export-diagnostics

Make export failures easier to diagnose
This commit is contained in:
webadderall
2026-09-12 15:18:38 +10:00
committed by GitHub
9 changed files with 836 additions and 30 deletions
+26
View File
@@ -195,6 +195,27 @@ interface RendererNativeExportCapabilities {
};
}
interface RendererExportHardwareInfo {
platform: NodeJS.Platform;
release: string;
arch: string;
cpuModel: string | null;
logicalProcessors: number;
totalMemoryGb: number;
machineModel: string | null;
gpus: Array<{
name: string;
vendor: string | null;
active: boolean | null;
}>;
gpuFeatures: {
videoDecode: string | null;
videoEncode: string | null;
webgl: string | null;
webgpu: string | null;
};
}
interface Window {
electronAPI: {
hudOverlaySetIgnoreMouse: (ignore: boolean) => void;
@@ -343,6 +364,11 @@ interface Window {
capabilities?: RendererNativeExportCapabilities;
error?: string;
}>;
getExportHardwareInfo: () => Promise<{
success: boolean;
hardware?: RendererExportHardwareInfo;
error?: string;
}>;
nativeStaticLayoutExport: (options: {
sessionId?: string;
inputPath: string;
+62
View File
@@ -3,6 +3,12 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
app: {
getAppPath: vi.fn(() => process.cwd()),
getGPUFeatureStatus: vi.fn(() => ({
video_decode: "enabled",
video_encode: "enabled",
webgl: "enabled",
webgpu: "enabled",
})),
getGPUInfo: vi.fn(async () => ({ gpuDevice: [] })),
getPath: vi.fn(() => process.env.TEMP ?? process.cwd()),
isPackaged: false,
@@ -55,6 +61,7 @@ import {
buildNativeVideoAudioMuxArgs,
canCopyAudioCodecIntoMp4,
getExperimentalNvidiaCudaExportSkipReason,
getExportHardwareInfo,
getNativeExportCapabilities,
getNativeGpuCompositorStallTimeoutMs,
getNativeStaticLayoutSourceProxyBitrate,
@@ -75,6 +82,7 @@ import {
parseWindowsGpuExportProgressLine,
parseWindowsGpuExportSummary,
resolveExperimentalNvidiaCudaExportScriptPath,
sanitizeExportGpuInfo,
shouldCreateNativeStaticLayoutSourceProxy,
validateNativeStaticLayoutSourceProxyMetadata,
validateNativeVideoStreamStats,
@@ -84,6 +92,7 @@ import {
const electronAppMock = app as unknown as {
getAppPath: ReturnType<typeof vi.fn>;
getGPUFeatureStatus: ReturnType<typeof vi.fn>;
getGPUInfo: ReturnType<typeof vi.fn>;
isPackaged: boolean;
};
@@ -412,6 +421,59 @@ describe("getNativeExportCapabilities", () => {
});
});
describe("export hardware diagnostics", () => {
it("sanitizes machine and GPU details without exposing raw device identifiers", () => {
const hardware = sanitizeExportGpuInfo({
machineModelName: "MacBookPro",
machineModelVersion: "18,2",
gpuDevice: [
{
active: true,
vendorId: "0x10de",
deviceId: 9999,
deviceString: "NVIDIA GeForce RTX 4070",
},
],
});
expect(hardware).toEqual({
machineModel: "MacBookPro 18,2",
gpus: [
{
name: "NVIDIA GeForce RTX 4070",
vendor: "NVIDIA",
active: true,
},
],
});
expect(JSON.stringify(hardware)).not.toContain("deviceId");
expect(JSON.stringify(hardware)).not.toContain("vendorId");
});
it("returns system capacity and GPU acceleration status", async () => {
electronAppMock.getGPUInfo.mockResolvedValueOnce({
gpuDevice: [{ active: true, vendorId: 0x8086 }],
});
const hardware = await getExportHardwareInfo();
expect(electronAppMock.getGPUInfo).toHaveBeenCalledWith("complete");
expect(hardware).toMatchObject({
platform: process.platform,
arch: process.arch,
gpus: [{ name: "Intel", vendor: "Intel", active: true }],
gpuFeatures: {
videoDecode: "enabled",
videoEncode: "enabled",
webgl: "enabled",
webgpu: "enabled",
},
});
expect(hardware.logicalProcessors).toBeGreaterThan(0);
expect(hardware.totalMemoryGb).toBeGreaterThan(0);
});
});
describe("getExperimentalNvidiaCudaExportSkipReason", () => {
it("requires user opt-in before packaged CUDA candidates run", async () => {
const reason = await withPackagedCudaCandidate(
+113
View File
@@ -56,6 +56,7 @@ const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_MAX_BITRATE = 80_000_000;
const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_CONTAINERS = new Set([".mp4", ".m4v", ".mov"]);
type ElectronGpuDeviceLike = {
active?: boolean;
vendorId?: number | string;
vendorString?: string;
deviceString?: string;
@@ -63,8 +64,31 @@ type ElectronGpuDeviceLike = {
type ElectronGpuInfoLike = {
gpuDevice?: ElectronGpuDeviceLike[];
machineModelName?: string;
machineModelVersion?: string;
};
export interface ExportHardwareInfo {
platform: NodeJS.Platform;
release: string;
arch: string;
cpuModel: string | null;
logicalProcessors: number;
totalMemoryGb: number;
machineModel: string | null;
gpus: Array<{
name: string;
vendor: string | null;
active: boolean | null;
}>;
gpuFeatures: {
videoDecode: string | null;
videoEncode: string | null;
webgl: string | null;
webgpu: string | null;
};
}
export type NativeVideoExportSession = {
ffmpegProcess: ChildProcessByStdio<Writable, null, Readable>;
outputPath: string;
@@ -1869,6 +1893,95 @@ export function hasNvidiaGpuDeviceInGpuInfo(gpuInfo: unknown) {
return Array.isArray(devices) && devices.some(isNvidiaGpuDevice);
}
function getGpuVendorLabel(device: ElectronGpuDeviceLike): string | null {
if (device.vendorString?.trim()) {
return device.vendorString.trim();
}
const rawVendorId = device.vendorId;
const vendorId =
typeof rawVendorId === "number"
? rawVendorId
: typeof rawVendorId === "string"
? rawVendorId.toLowerCase().startsWith("0x")
? Number.parseInt(rawVendorId.slice(2), 16)
: Number.parseInt(rawVendorId, 10)
: Number.NaN;
return (
{
[0x1002]: "AMD",
[0x106b]: "Apple",
[0x10de]: "NVIDIA",
[0x8086]: "Intel",
}[vendorId] ?? null
);
}
/** Reduces Electron's GPU response to support-safe hardware fields. */
export function sanitizeExportGpuInfo(
gpuInfo: unknown,
): Pick<ExportHardwareInfo, "machineModel" | "gpus"> {
if (!gpuInfo || typeof gpuInfo !== "object") {
return { machineModel: null, gpus: [] };
}
const info = gpuInfo as ElectronGpuInfoLike;
const machineModel =
[info.machineModelName, info.machineModelVersion]
.filter((value): value is string => Boolean(value?.trim()))
.join(" ") || null;
const gpus = Array.isArray(info.gpuDevice)
? info.gpuDevice.map((device) => {
const vendor = getGpuVendorLabel(device);
return {
name: device.deviceString?.trim() || vendor || "Unknown GPU",
vendor,
active: typeof device.active === "boolean" ? device.active : null,
};
})
: [];
return { machineModel, gpus };
}
/** Captures sanitized hardware and GPU acceleration details for export support reports. */
export async function getExportHardwareInfo(): Promise<ExportHardwareInfo> {
let sanitizedGpuInfo: Pick<ExportHardwareInfo, "machineModel" | "gpus"> = {
machineModel: null,
gpus: [],
};
try {
sanitizedGpuInfo = sanitizeExportGpuInfo(await app.getGPUInfo("complete"));
} catch {
// Hardware diagnostics are best effort and must not affect exporting.
}
let gpuFeatureStatus: Record<string, string> = {};
try {
gpuFeatureStatus = app.getGPUFeatureStatus() as unknown as Record<string, string>;
} catch {
// GPU feature status can be unavailable before Chromium finishes GPU initialization.
}
const cpuModel = os.cpus()[0]?.model?.replace(/\s+/g, " ").trim() || null;
return {
platform: process.platform,
release: os.release(),
arch: process.arch,
cpuModel,
logicalProcessors: os.cpus().length,
totalMemoryGb: Math.round((os.totalmem() / 1024 ** 3) * 10) / 10,
machineModel: sanitizedGpuInfo.machineModel,
gpus: sanitizedGpuInfo.gpus,
gpuFeatures: {
videoDecode: gpuFeatureStatus.video_decode ?? null,
videoEncode: gpuFeatureStatus.video_encode ?? null,
webgl: gpuFeatureStatus.webgl ?? null,
webgpu: gpuFeatureStatus.webgpu ?? null,
},
};
}
async function hasNvidiaGpuForCudaExportCandidate() {
const hasNvidiaGpu = await probeNvidiaGpuForCudaExportCandidate();
return hasNvidiaGpu ?? true;
+22 -6
View File
@@ -5,12 +5,6 @@ import path from "node:path";
import type { Readable, Writable } from "node:stream";
import type { SaveDialogOptions } from "electron";
import { app, BrowserWindow, dialog, ipcMain } from "electron";
import {
parseCaptionSidecarPayload,
type CaptionSidecarPayload,
withCaptionSidecarMessage,
writeCaptionSidecarsBestEffort,
} from "./exportCaptionSidecars";
import {
closeExportStream,
isOwnedExportPath,
@@ -24,6 +18,7 @@ import {
enqueueNativeVideoExportFrameWrites,
exportNativeStaticLayoutVideo,
flushNativeVideoExportPendingWriteRequests,
getExportHardwareInfo,
getNativeExportCapabilities,
getNativeVideoExportMaxQueuedWriteBytes,
getNativeVideoExportSessionError,
@@ -51,6 +46,12 @@ import {
} from "../nativeVideoExport";
import { isAllowedLocalReadPath, resolveApprovedLocalMediaPath } from "../project/manager";
import { approveUserPath } from "../utils";
import {
type CaptionSidecarPayload,
parseCaptionSidecarPayload,
withCaptionSidecarMessage,
writeCaptionSidecarsBestEffort,
} from "./exportCaptionSidecars";
function getPartialExportDestinationPath(destinationPath: string) {
const parsed = path.parse(destinationPath);
@@ -428,6 +429,21 @@ export function registerExportHandlers() {
}
});
ipcMain.handle("get-export-hardware-info", async () => {
try {
return {
success: true,
hardware: await getExportHardwareInfo(),
};
} catch (error) {
console.warn("[export-hardware-info] Failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
});
ipcMain.handle(
"native-static-layout-export",
async (event, options: NativeStaticLayoutExportOptions) => {
+27
View File
@@ -102,6 +102,26 @@ type NativeExportCapabilities = {
userOptInRequired: boolean;
};
};
type ExportHardwareInfo = {
platform: NodeJS.Platform;
release: string;
arch: string;
cpuModel: string | null;
logicalProcessors: number;
totalMemoryGb: number;
machineModel: string | null;
gpus: Array<{
name: string;
vendor: string | null;
active: boolean | null;
}>;
gpuFeatures: {
videoDecode: string | null;
videoEncode: string | null;
webgl: string | null;
webgpu: string | null;
};
};
const nativeVideoExportWriteRequests = new Map<
number,
@@ -220,6 +240,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
error?: string;
}>;
},
getExportHardwareInfo: () => {
return ipcRenderer.invoke("get-export-hardware-info") as Promise<{
success: boolean;
hardware?: ExportHardwareInfo;
error?: string;
}>;
},
nativeStaticLayoutExport: (options: {
sessionId?: string;
inputPath: string;
@@ -290,6 +290,114 @@ describe("ModernVideoExporter native fallback routing", () => {
expect(mocks.muxerFinalize).toHaveBeenCalledTimes(1);
});
it("builds actionable diagnostics for input decoder failures", () => {
vi.stubGlobal("navigator", {
platform: "Win32",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
});
const exporter = new ModernVideoExporter({
videoUrl: "file:///recording.mp4",
width: 1200,
height: 570,
frameRate: 60,
bitrate: 8_000_000,
backendPreference: "auto",
} as never) as unknown as {
buildLightningExportError: (error: unknown) => string;
sourceVideoInfo: typeof mocks.videoInfo;
renderBackend: "webgpu";
encodeBackend: "ffmpeg";
encoderName: string;
processedFrameCount: number;
totalExportStartTimeMs: number;
mediaSourceRetryAttempted: boolean;
effectiveDurationSec: number;
runtimeDiagnostics: {
appVersion: string;
userAgent: string;
logicalProcessors: number;
deviceMemoryGb: number;
hardware: RendererExportHardwareInfo;
};
backpressureProfile: {
name: string;
maxDecodeQueue: number;
maxPendingFrames: number;
maxEncodeQueue: number;
};
};
exporter.sourceVideoInfo = mocks.videoInfo;
exporter.renderBackend = "webgpu";
exporter.encodeBackend = "ffmpeg";
exporter.encoderName = "h264-stream-copy";
exporter.processedFrameCount = 314;
exporter.totalExportStartTimeMs = 1;
exporter.mediaSourceRetryAttempted = true;
exporter.effectiveDurationSec = 10;
exporter.runtimeDiagnostics = {
appVersion: "1.4.0",
userAgent: "RecordlyTest/1.0 Electron/43.1.0",
logicalProcessors: 12,
deviceMemoryGb: 8,
hardware: {
platform: "win32",
release: "10.0.26100",
arch: "x64",
cpuModel: "AMD Ryzen 9 7900X",
logicalProcessors: 24,
totalMemoryGb: 31.8,
machineModel: "Custom PC",
gpus: [
{
name: "NVIDIA GeForce RTX 4070",
vendor: "NVIDIA",
active: true,
},
],
gpuFeatures: {
videoDecode: "enabled",
videoEncode: "enabled",
webgl: "enabled",
webgpu: "enabled",
},
},
};
exporter.backpressureProfile = {
name: "webcodecs-balanced-plus",
maxDecodeQueue: 12,
maxPendingFrames: 32,
maxEncodeQueue: 72,
};
const report = exporter.buildLightningExportError(
new Error(
"[VIDEO_DECODE_ENCODING_ERROR] VideoDecoder failure: EncodingError: bad frame",
),
);
expect(report).toContain("Failure code: VIDEO_DECODE_ENCODING_ERROR");
expect(report).toContain("Failure stage: Input video decoding");
expect(report).toContain("Output: 1200x570 @ 60 FPS; 8.00 Mbps; mode=default");
expect(report).toContain("Recordly version: 1.4.0");
expect(report).toContain("Runtime: RecordlyTest/1.0 Electron/43.1.0");
expect(report).toContain("System: win32 10.0.26100 (x64); model=Custom PC");
expect(report).toContain("CPU: AMD Ryzen 9 7900X; 24 logical processors");
expect(report).toContain("Memory: 31.8 GB");
expect(report).toContain("GPU 1: NVIDIA GeForce RTX 4070; active");
expect(report).toContain(
"GPU acceleration: video decode=enabled; video encode=enabled; WebGL=enabled; WebGPU=enabled",
);
expect(report).toContain("Source: h264 1920x1080 @ 30.000 FPS; 1.000s");
expect(report).toContain("Source audio: none");
expect(report).toContain("Progress at failure: 314/600 (52.3%) rendered frames after");
expect(report).toContain("Media source retry: attempted with a fresh source");
expect(report).toContain(
"Pipeline tuning: webcodecs-balanced-plus; decode queue=12; pending frames=32; encode queue=72",
);
expect(report).toContain("If only this recording fails");
expect(report).not.toContain("Windows Lightning exports can use WebCodecs or FFmpeg");
});
it("forwards cursor click-effect settings into the modern frame renderer", async () => {
const { ModernVideoExporter } = await import("./modernVideoExporter");
const { FrameRenderer } = await import("./modernFrameRenderer");
+173 -7
View File
@@ -161,6 +161,14 @@ interface VideoExporterConfig extends ExportConfig {
preferredEncoderPath?: SupportedMp4EncoderPath | null;
}
interface ExportRuntimeDiagnostics {
appVersion?: string;
userAgent?: string;
logicalProcessors?: number;
deviceMemoryGb?: number;
hardware?: RendererExportHardwareInfo;
}
type NativeAudioPlan =
| {
audioMode: "none";
@@ -367,6 +375,9 @@ export class ModernVideoExporter {
private lastProgressSampleTimeMs = 0;
private lastProgressSampleFrame = 0;
private displayedRenderFps = 0;
private sourceVideoInfo: DecodedVideoInfo | null = null;
private mediaSourceRetryAttempted = false;
private runtimeDiagnostics: ExportRuntimeDiagnostics = {};
constructor(config: VideoExporterConfig) {
this.config = config;
@@ -375,6 +386,8 @@ export class ModernVideoExporter {
async export(): Promise<ExportResult> {
let useFallbackMediaSource = false;
let retriedWithFallbackMediaSource = false;
this.mediaSourceRetryAttempted = false;
this.runtimeDiagnostics = await this.collectRuntimeDiagnostics();
while (true) {
let shouldRetryWithFallbackMediaSource = false;
@@ -386,6 +399,7 @@ export class ModernVideoExporter {
this.nativeStaticLayoutSkipReason = null;
this.nativeStaticLayoutSkipReasons = [];
this.nativeStaticLayoutBackgroundSkipReason = null;
this.sourceVideoInfo = null;
this.totalExportStartTimeMs = this.getNowMs();
const backendPreference = this.config.backendPreference ?? "auto";
const runtimePlatform = this.getRuntimePlatform();
@@ -526,6 +540,7 @@ export class ModernVideoExporter {
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl, {
useFallbackMediaSource,
});
this.sourceVideoInfo = videoInfo;
this.metadataLoadTimeMs = this.getNowMs() - stageStartedAt;
const nativeAudioPlan = this.buildNativeAudioPlan(videoInfo);
const shouldUsePitchPreservingFfmpegAudio =
@@ -894,6 +909,7 @@ export class ModernVideoExporter {
this.shouldRetryWithFallbackMediaSource(error)
) {
retriedWithFallbackMediaSource = true;
this.mediaSourceRetryAttempted = true;
useFallbackMediaSource = true;
shouldRetryWithFallbackMediaSource = true;
console.warn(
@@ -965,13 +981,70 @@ export class ModernVideoExporter {
return normalizeLightningRuntimePlatform(navigator.platform || navigator.userAgent || "");
}
private async collectRuntimeDiagnostics(): Promise<ExportRuntimeDiagnostics> {
const diagnostics: ExportRuntimeDiagnostics = {};
if (typeof navigator !== "undefined") {
const navigatorWithMemory = navigator as Navigator & { deviceMemory?: number };
if (navigator.userAgent) diagnostics.userAgent = navigator.userAgent;
if (navigator.hardwareConcurrency > 0) {
diagnostics.logicalProcessors = navigator.hardwareConcurrency;
}
if (
typeof navigatorWithMemory.deviceMemory === "number" &&
navigatorWithMemory.deviceMemory > 0
) {
diagnostics.deviceMemoryGb = navigatorWithMemory.deviceMemory;
}
}
try {
if (
typeof window !== "undefined" &&
typeof window.electronAPI?.getAppVersion === "function"
) {
diagnostics.appVersion = await window.electronAPI.getAppVersion();
}
} catch {
// Environment diagnostics must never prevent an export attempt.
}
try {
if (
typeof window !== "undefined" &&
typeof window.electronAPI?.getExportHardwareInfo === "function"
) {
const result = await window.electronAPI.getExportHardwareInfo();
if (result.success && result.hardware) {
diagnostics.hardware = result.hardware;
}
}
} catch {
// Environment diagnostics must never prevent an export attempt.
}
return diagnostics;
}
private getLightningErrorGuidance(message: string): string[] {
const guidance = new Set<string>();
const platform = this.getPlatformLabel();
const isVideoDecodeFailure = /VideoDecoder failure|VIDEO_DECODE|VIDEO_CODEC/i.test(message);
guidance.add(
"Lightning is designed to work on macOS, Windows, and Linux, but the available encoder path depends on WebCodecs support, GPU drivers, and the bundled FFmpeg encoders.",
);
if (isVideoDecodeFailure) {
guidance.add(
"The input video decoder failed before Recordly could finish rendering the source frames.",
);
guidance.add(
"If only this recording fails, remux or convert it to a standard H.264 MP4; the source may contain a damaged or unsupported frame.",
);
guidance.add(
"If every recording fails, update the GPU/media driver and retry at 30 FPS to reduce decoder pressure.",
);
} else {
guidance.add(
"Lightning is designed to work on macOS, Windows, and Linux, but the available encoder path depends on WebCodecs support, GPU drivers, and the bundled FFmpeg encoders.",
);
}
if (/even output dimensions/i.test(message)) {
guidance.add(
@@ -996,15 +1069,15 @@ export class ModernVideoExporter {
);
}
if (platform === "Windows") {
if (!isVideoDecodeFailure && platform === "Windows") {
guidance.add(
"Windows Lightning exports can use WebCodecs or FFmpeg encoders such as h264_nvenc, h264_qsv, h264_amf, h264_mf, or libx264 depending on the machine.",
);
} else if (platform === "Linux") {
} else if (!isVideoDecodeFailure && platform === "Linux") {
guidance.add(
"Linux Lightning exports can use WebCodecs when supported, or FFmpeg encoders such as libx264 and optional GPU paths depending on the distro build.",
);
} else if (platform === "macOS") {
} else if (!isVideoDecodeFailure && platform === "macOS") {
guidance.add(
"macOS Lightning exports can use WebCodecs or VideoToolbox/libx264 through Breeze depending on the output profile.",
);
@@ -1015,6 +1088,8 @@ export class ModernVideoExporter {
private buildLightningExportError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const failureCode = message.match(/\[([A-Z][A-Z0-9_]+)\]/)?.[1];
const isVideoDecodeFailure = /VideoDecoder failure|VIDEO_DECODE|VIDEO_CODEC/i.test(message);
const resolvedEncodePath =
this.encodeBackend === "ffmpeg"
? `${NATIVE_EXPORT_ENGINE_NAME} native`
@@ -1023,12 +1098,97 @@ export class ModernVideoExporter {
: null;
const lines = [
`${LIGHTNING_PIPELINE_NAME} export failed.`,
...(failureCode ? [`Failure code: ${failureCode}`] : []),
...(isVideoDecodeFailure ? ["Failure stage: Input video decoding"] : []),
`Reason: ${message}`,
`Platform: ${this.getPlatformLabel()}`,
`Requested backend mode: ${this.config.backendPreference ?? "auto"}`,
`Output: ${this.config.width}x${this.config.height} @ ${this.config.frameRate} FPS`,
`Output: ${this.config.width}x${this.config.height} @ ${this.config.frameRate} FPS; ${(this.config.bitrate / 1_000_000).toFixed(2)} Mbps; mode=${this.config.encodingMode ?? "default"}`,
];
if (this.runtimeDiagnostics.appVersion) {
lines.push(`Recordly version: ${this.runtimeDiagnostics.appVersion}`);
}
if (this.runtimeDiagnostics.userAgent) {
lines.push(`Runtime: ${this.runtimeDiagnostics.userAgent}`);
}
const hardware = this.runtimeDiagnostics.hardware;
if (hardware) {
lines.push(
`System: ${hardware.platform} ${hardware.release} (${hardware.arch})${hardware.machineModel ? `; model=${hardware.machineModel}` : ""}`,
);
lines.push(
`CPU: ${hardware.cpuModel ?? "Unknown"}; ${hardware.logicalProcessors} logical processors`,
);
lines.push(`Memory: ${hardware.totalMemoryGb} GB`);
for (const [index, gpu] of hardware.gpus.entries()) {
const details = [
gpu.vendor && !gpu.name.toLowerCase().includes(gpu.vendor.toLowerCase())
? `vendor=${gpu.vendor}`
: null,
gpu.active === true ? "active" : gpu.active === false ? "inactive" : null,
].filter((value): value is string => Boolean(value));
lines.push(
`GPU ${index + 1}: ${gpu.name}${details.length ? `; ${details.join("; ")}` : ""}`,
);
}
const gpuFeatures = [
hardware.gpuFeatures.videoDecode
? `video decode=${hardware.gpuFeatures.videoDecode}`
: null,
hardware.gpuFeatures.videoEncode
? `video encode=${hardware.gpuFeatures.videoEncode}`
: null,
hardware.gpuFeatures.webgl ? `WebGL=${hardware.gpuFeatures.webgl}` : null,
hardware.gpuFeatures.webgpu ? `WebGPU=${hardware.gpuFeatures.webgpu}` : null,
].filter((value): value is string => Boolean(value));
if (gpuFeatures.length > 0) {
lines.push(`GPU acceleration: ${gpuFeatures.join("; ")}`);
}
} else {
const hardwareParts = [
this.runtimeDiagnostics.logicalProcessors
? `${this.runtimeDiagnostics.logicalProcessors} logical processors`
: null,
this.runtimeDiagnostics.deviceMemoryGb
? `${this.runtimeDiagnostics.deviceMemoryGb} GB device memory`
: null,
].filter((value): value is string => Boolean(value));
if (hardwareParts.length > 0) {
lines.push(`Hardware capacity: ${hardwareParts.join("; ")}`);
}
}
if (this.sourceVideoInfo) {
lines.push(
`Source: ${this.sourceVideoInfo.codec} ${this.sourceVideoInfo.width}x${this.sourceVideoInfo.height} @ ${this.sourceVideoInfo.frameRate.toFixed(3)} FPS; ${this.sourceVideoInfo.duration.toFixed(3)}s`,
);
lines.push(
this.sourceVideoInfo.hasAudio
? `Source audio: ${this.sourceVideoInfo.audioCodec ?? "unknown codec"}${this.sourceVideoInfo.audioSampleRate ? ` @ ${this.sourceVideoInfo.audioSampleRate} Hz` : ""}`
: "Source audio: none",
);
}
if (this.totalExportStartTimeMs > 0) {
const elapsedSeconds = Math.max(
0,
(this.getNowMs() - this.totalExportStartTimeMs) / 1000,
);
const expectedFrames = Math.ceil(this.effectiveDurationSec * this.config.frameRate);
const progressSuffix =
expectedFrames > 0
? `/${expectedFrames} (${Math.min(100, (this.processedFrameCount / expectedFrames) * 100).toFixed(1)}%)`
: "";
lines.push(
`Progress at failure: ${this.processedFrameCount}${progressSuffix} rendered frames after ${elapsedSeconds.toFixed(2)}s`,
);
}
if (this.mediaSourceRetryAttempted) {
lines.push("Media source retry: attempted with a fresh source");
}
if (this.renderBackend) {
lines.push(`Renderer: ${this.renderBackend}`);
}
@@ -1039,6 +1199,12 @@ export class ModernVideoExporter {
);
}
if (this.backpressureProfile) {
lines.push(
`Pipeline tuning: ${this.backpressureProfile.name}; decode queue=${this.config.maxDecodeQueue ?? this.backpressureProfile.maxDecodeQueue}; pending frames=${this.config.maxPendingFrames ?? this.backpressureProfile.maxPendingFrames}; encode queue=${this.config.maxEncodeQueue ?? this.backpressureProfile.maxEncodeQueue}`,
);
}
if (this.lastNativeExportError && !message.includes(this.lastNativeExportError)) {
lines.push(`${NATIVE_EXPORT_ENGINE_NAME} fallback: ${this.lastNativeExportError}`);
}
+159 -1
View File
@@ -1,15 +1,97 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildVideoDecodeFailure,
getDecodedFrameStartupOffsetUs,
getDecodedFrameTimelineOffsetUs,
getVideoDecodeFailureCode,
preserveFirstVideoDecodeFailure,
StreamingVideoDecoder,
} from "./streamingDecoder";
describe("buildVideoDecodeFailure", () => {
it("assigns stable failure codes for common WebCodecs errors", () => {
expect(getVideoDecodeFailureCode(new DOMException("bad frame", "EncodingError"))).toBe(
"VIDEO_DECODE_ENCODING_ERROR",
);
expect(getVideoDecodeFailureCode(new DOMException("busy", "QuotaExceededError"))).toBe(
"VIDEO_DECODER_RESOURCE_EXHAUSTED",
);
expect(getVideoDecodeFailureCode(new Error("demux read failed"))).toBe(
"VIDEO_DECODE_FAILED",
);
});
it("reports the original decoder error with codec and chunk context", () => {
const originalError = new DOMException("Failed to decode frame", "EncodingError");
const chunk = {
type: "delta",
timestamp: 1_500_000,
duration: 16_667,
byteLength: 4,
} as EncodedVideoChunk;
const error = buildVideoDecodeFailure(originalError, {
decoderConfig: {
codec: "avc1.640034",
codedWidth: 1920,
codedHeight: 1080,
hardwareAcceleration: "prefer-hardware",
},
sourceMetadata: {
width: 1920,
height: 1080,
duration: 61.25,
frameRate: 60,
codec: "avc1.640034",
hasAudio: true,
},
chunkIndex: 42,
chunk,
decoderState: "closed",
decodeQueueSize: 7,
});
expect(error.message).toContain("EncodingError: Failed to decode frame");
expect(error.message).toContain("[VIDEO_DECODE_ENCODING_ERROR]");
expect(error.message).toContain("codec=avc1.640034");
expect(error.message).toContain("codedSize=1920x1080");
expect(error.message).toContain("chunkIndex=42");
expect(error.message).toContain("chunkType=delta");
expect(error.message).toContain("chunkTimestampUs=1500000");
expect(error.message).toContain("sourceTimeSec=1.500");
expect(error.message).toContain("chunkDurationUs=16667");
expect(error.message).toContain("chunkBytes=4");
expect(error.message).toContain("sourceFps=60");
expect(error.message).toContain("sourceDurationSec=61.25");
expect(error.message).toContain("decoderState=closed");
expect(error.cause).toBe(originalError);
});
it("does not replace the original failure with a later closed-codec exception", () => {
const originalFailure = new Error("VideoDecoder failure: EncodingError: bad frame");
const result = preserveFirstVideoDecodeFailure(
originalFailure,
new DOMException("Cannot call 'decode' on a closed codec.", "InvalidStateError"),
{
decoderConfig: {
codec: "avc1.640034",
codedWidth: 1920,
codedHeight: 1080,
},
decoderState: "closed",
},
);
expect(result).toBe(originalFailure);
});
});
const {
mockDemuxerLoad,
mockDemuxerGetMediaInfo,
mockDemuxerDestroy,
mockDemuxerGetDecoderConfig,
mockDemuxerRead,
} = vi.hoisted(() => ({
mockDemuxerLoad: vi.fn(),
mockDemuxerGetMediaInfo: vi.fn(async () => ({
@@ -29,6 +111,7 @@ const {
})),
mockDemuxerDestroy: vi.fn(),
mockDemuxerGetDecoderConfig: vi.fn(),
mockDemuxerRead: vi.fn(),
}));
vi.mock("web-demuxer", () => ({
@@ -37,6 +120,7 @@ vi.mock("web-demuxer", () => ({
getMediaInfo = mockDemuxerGetMediaInfo;
destroy = mockDemuxerDestroy;
getDecoderConfig = mockDemuxerGetDecoderConfig;
read = mockDemuxerRead;
},
}));
@@ -46,6 +130,80 @@ const mockGetLocalMediaUrl = vi.fn(async (filePath: string) => ({
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
}));
describe("StreamingVideoDecoder decode failures", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockDemuxerLoad.mockResolvedValue(undefined);
mockDemuxerGetDecoderConfig.mockResolvedValue({
codec: "avc1.640034",
codedWidth: 1920,
codedHeight: 1080,
});
mockDemuxerRead.mockReturnValue(
new ReadableStream({
start(controller) {
controller.enqueue({
type: "key",
timestamp: 0,
duration: 33_333,
byteLength: 4,
});
controller.close();
},
}),
);
Object.assign(globalThis, {
window: {
location: { href: "http://localhost:5173/" },
electronAPI: {
readLocalFile: mockReadLocalFile,
getLocalMediaUrl: mockGetLocalMediaUrl,
},
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("does not emit frozen remaining frames after a decoder error", async () => {
const frame = { timestamp: 0, close: vi.fn() } as unknown as VideoFrame;
class FailingVideoDecoder {
state: CodecState = "unconfigured";
decodeQueueSize = 0;
constructor(
private readonly callbacks: {
output: (decodedFrame: VideoFrame) => void;
error: (error: DOMException) => void;
},
) {}
configure() {
this.state = "configured";
}
decode() {
this.callbacks.output(frame);
this.callbacks.error(new DOMException("bad frame", "EncodingError"));
}
async flush() {}
close() {
this.state = "closed";
}
}
vi.stubGlobal("VideoDecoder", FailingVideoDecoder);
const decoder = new StreamingVideoDecoder();
await decoder.loadMetadata("/tmp/failing.mp4");
const onFrame = vi.fn(async () => {});
await expect(decoder.decodeAll(30, undefined, undefined, onFrame)).rejects.toThrow(
"[VIDEO_DECODE_ENCODING_ERROR]",
);
expect(onFrame).not.toHaveBeenCalled();
expect(frame.close).toHaveBeenCalledTimes(1);
});
});
describe("StreamingVideoDecoder local media loading", () => {
beforeEach(() => {
vi.restoreAllMocks();
+146 -16
View File
@@ -27,6 +27,95 @@ interface StreamingVideoDecoderLoadOptions {
useFallbackMediaSource?: boolean;
}
interface VideoDecodeFailureContext {
decoderConfig: VideoDecoderConfig;
sourceMetadata?: DecodedVideoInfo;
chunkIndex?: number;
chunk?: EncodedVideoChunk;
decoderState?: CodecState;
decodeQueueSize?: number;
}
/** Maps WebCodecs failures to stable support-facing identifiers. */
export function getVideoDecodeFailureCode(error: unknown): string {
const name = error instanceof DOMException ? error.name : "";
switch (name) {
case "EncodingError":
return "VIDEO_DECODE_ENCODING_ERROR";
case "NotSupportedError":
return "VIDEO_CODEC_UNSUPPORTED";
case "QuotaExceededError":
return "VIDEO_DECODER_RESOURCE_EXHAUSTED";
case "InvalidStateError":
return "VIDEO_DECODER_INVALID_STATE";
default:
return "VIDEO_DECODE_FAILED";
}
}
function describeUnknownError(error: unknown): string {
if (error instanceof DOMException) {
return `${error.name}: ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
/** Builds a decode error with codec, source, chunk, and decoder-state context. */
export function buildVideoDecodeFailure(error: unknown, context: VideoDecodeFailureContext): Error {
const details = [`codec=${context.decoderConfig.codec}`];
const failureCode = getVideoDecodeFailureCode(error);
const width = context.decoderConfig.codedWidth;
const height = context.decoderConfig.codedHeight;
if (width && height) {
details.push(`codedSize=${width}x${height}`);
}
if (context.decoderConfig.hardwareAcceleration) {
details.push(`hardwareAcceleration=${context.decoderConfig.hardwareAcceleration}`);
}
if (context.sourceMetadata) {
details.push(`sourceFps=${context.sourceMetadata.frameRate}`);
details.push(`sourceDurationSec=${context.sourceMetadata.duration}`);
}
if (context.chunkIndex !== undefined) {
details.push(`chunkIndex=${context.chunkIndex}`);
}
if (context.chunk) {
details.push(`chunkType=${context.chunk.type}`);
details.push(`chunkTimestampUs=${context.chunk.timestamp}`);
details.push(`sourceTimeSec=${(context.chunk.timestamp / 1_000_000).toFixed(3)}`);
if (typeof context.chunk.duration === "number") {
details.push(`chunkDurationUs=${context.chunk.duration}`);
}
details.push(`chunkBytes=${context.chunk.byteLength}`);
}
if (context.decoderState) {
details.push(`decoderState=${context.decoderState}`);
}
if (context.decodeQueueSize !== undefined) {
details.push(`decodeQueueSize=${context.decodeQueueSize}`);
}
const failure = new Error(
`[${failureCode}] VideoDecoder failure: ${describeUnknownError(error)} (${details.join(", ")})`,
);
(failure as Error & { cause?: unknown }).cause = error;
return failure;
}
/** Keeps the original decoder failure when cleanup triggers secondary errors. */
export function preserveFirstVideoDecodeFailure(
existingError: Error | null,
error: unknown,
context: VideoDecodeFailureContext,
): Error {
return existingError ?? buildVideoDecodeFailure(error, context);
}
/** Decoder retains ownership of the VideoFrame and closes it after use. */
type OnFrameCallback = (
frame: VideoFrame,
@@ -260,6 +349,31 @@ export class StreamingVideoDecoder {
let decodeDone = false;
let firstDecodedFrameTimestampUs: number | null = null;
let decodedFrameTimelineOffsetUs = 0;
let submittedChunkCount = 0;
let lastSubmittedChunk: EncodedVideoChunk | undefined;
let lastSubmittedChunkIndex: number | undefined;
const preferredDecoderConfig = shouldPreferSoftwareDecode
? {
...decoderConfig,
hardwareAcceleration: "prefer-software" as const,
}
: decoderConfig;
let activeDecoderConfig = preferredDecoderConfig;
const getDecoderFailureContext = (): VideoDecodeFailureContext => ({
decoderConfig: activeDecoderConfig,
sourceMetadata: this.metadata ?? undefined,
chunkIndex: lastSubmittedChunkIndex,
chunk: lastSubmittedChunk,
decoderState: this.decoder?.state,
decodeQueueSize: this.decoder?.decodeQueueSize,
});
const recordFirstDecodeError = (error: unknown) => {
decodeError = preserveFirstVideoDecodeFailure(
decodeError,
error,
getDecoderFailureContext(),
);
};
this.decoder = new VideoDecoder({
output: (frame: VideoFrame) => {
@@ -273,7 +387,7 @@ export class StreamingVideoDecoder {
notifyBackpressureProgress();
},
error: (e: DOMException) => {
decodeError = new Error(`VideoDecoder error: ${e.message}`);
recordFirstDecodeError(e);
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
@@ -282,25 +396,23 @@ export class StreamingVideoDecoder {
notifyBackpressureProgress();
},
});
const preferredDecoderConfig = shouldPreferSoftwareDecode
? {
...decoderConfig,
hardwareAcceleration: "prefer-software" as const,
}
: decoderConfig;
try {
this.decoder.configure(preferredDecoderConfig);
} catch (error) {
if (!shouldPreferSoftwareDecode) {
throw error;
throw buildVideoDecodeFailure(error, getDecoderFailureContext());
}
// Fall back to default decoder config if software preference is unsupported.
this.decoder.configure(decoderConfig);
activeDecoderConfig = decoderConfig;
try {
this.decoder.configure(decoderConfig);
} catch (fallbackError) {
throw buildVideoDecodeFailure(fallbackError, getDecoderFailureContext());
}
}
const getNextFrame = (): Promise<VideoFrame | null> => {
if (decodeError) throw decodeError;
if (decodeError) return Promise.resolve(null);
if (pendingFrames.length > 0) {
const frame = pendingFrames.shift()!;
notifyBackpressureProgress();
@@ -325,7 +437,7 @@ export class StreamingVideoDecoder {
// Feed chunks to decoder in background with backpressure
const feedPromise = (async () => {
try {
while (!this.cancelled) {
while (!this.cancelled && !decodeError) {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
@@ -347,22 +459,36 @@ export class StreamingVideoDecoder {
// Backpressure on both decode queue and decoded frame backlog.
while (
!decodeError &&
this.decoder!.state === "configured" &&
(this.decoder!.decodeQueueSize > decodeQueueLimit ||
pendingFrames.length > pendingFrameLimit) &&
!this.cancelled
) {
await waitForBackpressureProgress();
}
if (this.cancelled) break;
if (this.cancelled || decodeError) break;
if (this.decoder!.state !== "configured") {
recordFirstDecodeError(
new DOMException(
"Decoder closed before the next video chunk was submitted.",
"InvalidStateError",
),
);
break;
}
lastSubmittedChunk = chunk;
lastSubmittedChunkIndex = submittedChunkCount;
this.decoder!.decode(chunk);
submittedChunkCount++;
}
if (!this.cancelled && this.decoder!.state === "configured") {
await this.decoder!.flush();
}
} catch (e) {
decodeError = e instanceof Error ? e : new Error(String(e));
recordFirstDecodeError(e);
} finally {
decodeDone = true;
if (frameResolve) {
@@ -507,7 +633,7 @@ export class StreamingVideoDecoder {
}
// Flush remaining output frames for the last decoded frame.
if (heldFrame && segmentIdx < segments.length) {
if (!decodeError && heldFrame && segmentIdx < segments.length) {
while (!this.cancelled && segmentIdx < segments.length) {
const segment = segments[segmentIdx];
if (heldFrameSec < segment.startSec - epsilonSec) {
@@ -535,7 +661,7 @@ export class StreamingVideoDecoder {
}
// Drain leftover decoded frames
while (!decodeDone) {
while (!decodeDone && !decodeError) {
const frame = await getNextFrame();
if (!frame) break;
frame.close();
@@ -555,6 +681,10 @@ export class StreamingVideoDecoder {
}
this.decoder = null;
if (decodeError) {
throw decodeError;
}
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
if (
!this.cancelled &&