mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
Stop work after decode failures and enrich diagnostics
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user