Stop work after decode failures and enrich diagnostics

This commit is contained in:
webadderall
2026-09-11 17:51:58 +10:00
parent 316f079b2d
commit a0b50df0f2
4 changed files with 192 additions and 5 deletions
@@ -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");
});
+75 -2
View File
@@ -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<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.
}
return diagnostics;
}
private getLightningErrorGuidance(message: string): string[] {
const guidance = new Set<string>();
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}`);
}
+78 -1
View File
@@ -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();
+4 -1
View File
@@ -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) {