Improve export failure diagnostics

This commit is contained in:
webadderall
2026-09-11 17:31:04 +10:00
parent a1fbfe7a6a
commit 316f079b2d
4 changed files with 317 additions and 21 deletions
@@ -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");
+49 -6
View File
@@ -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<ExportResult> {
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<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 +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}`);
}
+81
View File
@@ -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,
+142 -15
View File
@@ -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<VideoFrame | null> => {
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 &&