From 316f079b2da474ef94cd77bef71ed1d38481631c Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:31:04 +1000 Subject: [PATCH] Improve export failure diagnostics --- .../modernVideoExporter.fallback.test.ts | 45 +++++ src/lib/exporter/modernVideoExporter.ts | 55 +++++- src/lib/exporter/streamingDecoder.test.ts | 81 +++++++++ src/lib/exporter/streamingDecoder.ts | 157 ++++++++++++++++-- 4 files changed, 317 insertions(+), 21 deletions(-) diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts index e1afc68c..9f62e935 100644 --- a/src/lib/exporter/modernVideoExporter.fallback.test.ts +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -290,6 +290,51 @@ 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; + }; + exporter.sourceVideoInfo = mocks.videoInfo; + exporter.renderBackend = "webgpu"; + exporter.encodeBackend = "ffmpeg"; + exporter.encoderName = "h264-stream-copy"; + exporter.processedFrameCount = 314; + exporter.totalExportStartTimeMs = 1; + exporter.mediaSourceRetryAttempted = true; + + 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("Source: h264 1920x1080 @ 30.000 FPS; 1.000s"); + expect(report).toContain("Progress at failure: 314 rendered frames after"); + expect(report).toContain("Media source retry: attempted with a fresh source"); + 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"); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 3bb58eb8..af4b3169 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -367,6 +367,8 @@ export class ModernVideoExporter { private lastProgressSampleTimeMs = 0; private lastProgressSampleFrame = 0; private displayedRenderFps = 0; + private sourceVideoInfo: DecodedVideoInfo | null = null; + private mediaSourceRetryAttempted = false; constructor(config: VideoExporterConfig) { this.config = config; @@ -375,6 +377,7 @@ export class ModernVideoExporter { async export(): Promise { let useFallbackMediaSource = false; let retriedWithFallbackMediaSource = false; + this.mediaSourceRetryAttempted = false; while (true) { let shouldRetryWithFallbackMediaSource = false; @@ -386,6 +389,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 +530,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 +899,7 @@ export class ModernVideoExporter { this.shouldRetryWithFallbackMediaSource(error) ) { retriedWithFallbackMediaSource = true; + this.mediaSourceRetryAttempted = true; useFallbackMediaSource = true; shouldRetryWithFallbackMediaSource = true; console.warn( @@ -968,10 +974,23 @@ export class ModernVideoExporter { private getLightningErrorGuidance(message: string): string[] { const guidance = new Set(); 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 +1015,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 +1034,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 +1044,34 @@ 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`, ]; + 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`, + ); + } + + if (this.totalExportStartTimeMs > 0) { + const elapsedSeconds = Math.max( + 0, + (this.getNowMs() - this.totalExportStartTimeMs) / 1000, + ); + lines.push( + `Progress at failure: ${this.processedFrameCount} 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}`); } diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts index 394febec..d8e4de74 100644 --- a/src/lib/exporter/streamingDecoder.test.ts +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -1,10 +1,91 @@ import { 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, diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 004f1fec..17956aef 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -27,6 +27,92 @@ interface StreamingVideoDecoderLoadOptions { useFallbackMediaSource?: boolean; } +interface VideoDecodeFailureContext { + decoderConfig: VideoDecoderConfig; + sourceMetadata?: DecodedVideoInfo; + chunkIndex?: number; + chunk?: EncodedVideoChunk; + decoderState?: CodecState; + decodeQueueSize?: number; +} + +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); +} + +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; +} + +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 +346,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 +384,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 +393,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 => { - if (decodeError) throw decodeError; + if (decodeError) return Promise.resolve(null); if (pendingFrames.length > 0) { const frame = pendingFrames.shift()!; notifyBackpressureProgress(); @@ -325,7 +434,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 +456,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) { @@ -535,7 +658,7 @@ export class StreamingVideoDecoder { } // Drain leftover decoded frames - while (!decodeDone) { + while (!decodeDone && !decodeError) { const frame = await getNextFrame(); if (!frame) break; frame.close(); @@ -555,6 +678,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 &&