diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts index 9f62e935..c781ca1d 100644 --- a/src/lib/exporter/modernVideoExporter.fallback.test.ts +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -311,6 +311,19 @@ describe("ModernVideoExporter native fallback routing", () => { processedFrameCount: number; totalExportStartTimeMs: number; mediaSourceRetryAttempted: boolean; + effectiveDurationSec: number; + runtimeDiagnostics: { + appVersion: string; + userAgent: string; + logicalProcessors: number; + deviceMemoryGb: number; + }; + backpressureProfile: { + name: string; + maxDecodeQueue: number; + maxPendingFrames: number; + maxEncodeQueue: number; + }; }; exporter.sourceVideoInfo = mocks.videoInfo; exporter.renderBackend = "webgpu"; @@ -319,6 +332,19 @@ describe("ModernVideoExporter native fallback routing", () => { 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, + }; + exporter.backpressureProfile = { + name: "webcodecs-balanced-plus", + maxDecodeQueue: 12, + maxPendingFrames: 32, + maxEncodeQueue: 72, + }; const report = exporter.buildLightningExportError( new Error( @@ -328,9 +354,17 @@ describe("ModernVideoExporter native fallback routing", () => { 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("Hardware capacity: 12 logical processors; 8 GB device memory"); expect(report).toContain("Source: h264 1920x1080 @ 30.000 FPS; 1.000s"); - expect(report).toContain("Progress at failure: 314 rendered frames after"); + 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"); }); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index af4b3169..1bedbb7b 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -161,6 +161,13 @@ interface VideoExporterConfig extends ExportConfig { preferredEncoderPath?: SupportedMp4EncoderPath | null; } +interface ExportRuntimeDiagnostics { + appVersion?: string; + userAgent?: string; + logicalProcessors?: number; + deviceMemoryGb?: number; +} + type NativeAudioPlan = | { audioMode: "none"; @@ -369,6 +376,7 @@ export class ModernVideoExporter { private displayedRenderFps = 0; private sourceVideoInfo: DecodedVideoInfo | null = null; private mediaSourceRetryAttempted = false; + private runtimeDiagnostics: ExportRuntimeDiagnostics = {}; constructor(config: VideoExporterConfig) { this.config = config; @@ -378,6 +386,7 @@ export class ModernVideoExporter { let useFallbackMediaSource = false; let retriedWithFallbackMediaSource = false; this.mediaSourceRetryAttempted = false; + this.runtimeDiagnostics = await this.collectRuntimeDiagnostics(); while (true) { let shouldRetryWithFallbackMediaSource = false; @@ -971,6 +980,36 @@ export class ModernVideoExporter { return normalizeLightningRuntimePlatform(navigator.platform || navigator.userAgent || ""); } + private async collectRuntimeDiagnostics(): Promise { + 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. + } + + return diagnostics; + } + private getLightningErrorGuidance(message: string): string[] { const guidance = new Set(); const platform = this.getPlatformLabel(); @@ -1049,13 +1088,36 @@ export class ModernVideoExporter { `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 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) { @@ -1063,8 +1125,13 @@ export class ModernVideoExporter { 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} rendered frames after ${elapsedSeconds.toFixed(2)}s`, + `Progress at failure: ${this.processedFrameCount}${progressSuffix} rendered frames after ${elapsedSeconds.toFixed(2)}s`, ); } @@ -1082,6 +1149,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}`); } diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts index d8e4de74..11805fd0 100644 --- a/src/lib/exporter/streamingDecoder.test.ts +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildVideoDecodeFailure, getDecodedFrameStartupOffsetUs, @@ -91,6 +91,7 @@ const { mockDemuxerGetMediaInfo, mockDemuxerDestroy, mockDemuxerGetDecoderConfig, + mockDemuxerRead, } = vi.hoisted(() => ({ mockDemuxerLoad: vi.fn(), mockDemuxerGetMediaInfo: vi.fn(async () => ({ @@ -110,6 +111,7 @@ const { })), mockDemuxerDestroy: vi.fn(), mockDemuxerGetDecoderConfig: vi.fn(), + mockDemuxerRead: vi.fn(), })); vi.mock("web-demuxer", () => ({ @@ -118,6 +120,7 @@ vi.mock("web-demuxer", () => ({ getMediaInfo = mockDemuxerGetMediaInfo; destroy = mockDemuxerDestroy; getDecoderConfig = mockDemuxerGetDecoderConfig; + read = mockDemuxerRead; }, })); @@ -127,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(); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 17956aef..ee59a0ac 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -36,6 +36,7 @@ interface VideoDecodeFailureContext { 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) { @@ -64,6 +65,7 @@ function describeUnknownError(error: unknown): string { 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); @@ -105,6 +107,7 @@ export function buildVideoDecodeFailure(error: unknown, context: VideoDecodeFail return failure; } +/** Keeps the original decoder failure when cleanup triggers secondary errors. */ export function preserveFirstVideoDecodeFailure( existingError: Error | null, error: unknown, @@ -630,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) {