mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
Merge pull request #927 from webadderallorg/codex/export-diagnostics
Make export failures easier to diagnose
This commit is contained in:
Vendored
+26
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user