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 1/5] 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 && From a0b50df0f25f890b622b23c40a0e56d64fa4ad9d Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:51:58 +1000 Subject: [PATCH 2/5] Stop work after decode failures and enrich diagnostics --- .../modernVideoExporter.fallback.test.ts | 36 ++++++++- src/lib/exporter/modernVideoExporter.ts | 77 +++++++++++++++++- src/lib/exporter/streamingDecoder.test.ts | 79 ++++++++++++++++++- src/lib/exporter/streamingDecoder.ts | 5 +- 4 files changed, 192 insertions(+), 5 deletions(-) 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) { From 0b1b2596bb9664d5d046de8c9e6441e19732d51a Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:12:23 +1000 Subject: [PATCH 3/5] Add computer hardware to export diagnostics --- electron/electron-env.d.ts | 27 ++++ electron/ipc/export/native-video.test.ts | 63 ++++++++++ electron/ipc/export/native-video.ts | 119 ++++++++++++++++++ electron/ipc/register/export.ts | 28 ++++- electron/preload.ts | 28 +++++ .../modernVideoExporter.fallback.test.ts | 32 ++++- src/lib/exporter/modernVideoExporter.ts | 71 +++++++++-- 7 files changed, 351 insertions(+), 17 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c00ef322..856fb643 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -195,6 +195,28 @@ interface RendererNativeExportCapabilities { }; } +interface RendererExportHardwareInfo { + platform: NodeJS.Platform; + release: string; + arch: string; + cpuModel: string | null; + logicalProcessors: number; + totalMemoryGb: number; + machineModel: string | null; + gpus: Array<{ + name: string; + vendor: string | null; + active: boolean | null; + videoMemoryMb: number | null; + }>; + gpuFeatures: { + videoDecode: string | null; + videoEncode: string | null; + webgl: string | null; + webgpu: string | null; + }; +} + interface Window { electronAPI: { hudOverlaySetIgnoreMouse: (ignore: boolean) => void; @@ -343,6 +365,11 @@ interface Window { capabilities?: RendererNativeExportCapabilities; error?: string; }>; + getExportHardwareInfo: () => Promise<{ + success: boolean; + hardware?: RendererExportHardwareInfo; + error?: string; + }>; nativeStaticLayoutExport: (options: { sessionId?: string; inputPath: string; diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts index ee31dcdd..a55ddbd2 100644 --- a/electron/ipc/export/native-video.test.ts +++ b/electron/ipc/export/native-video.test.ts @@ -3,6 +3,12 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("electron", () => ({ app: { getAppPath: vi.fn(() => process.cwd()), + getGPUFeatureStatus: vi.fn(() => ({ + video_decode: "enabled", + video_encode: "enabled", + webgl: "enabled", + webgpu: "enabled", + })), getGPUInfo: vi.fn(async () => ({ gpuDevice: [] })), getPath: vi.fn(() => process.env.TEMP ?? process.cwd()), isPackaged: false, @@ -55,6 +61,7 @@ import { buildNativeVideoAudioMuxArgs, canCopyAudioCodecIntoMp4, getExperimentalNvidiaCudaExportSkipReason, + getExportHardwareInfo, getNativeExportCapabilities, getNativeGpuCompositorStallTimeoutMs, getNativeStaticLayoutSourceProxyBitrate, @@ -75,6 +82,7 @@ import { parseWindowsGpuExportProgressLine, parseWindowsGpuExportSummary, resolveExperimentalNvidiaCudaExportScriptPath, + sanitizeExportGpuInfo, shouldCreateNativeStaticLayoutSourceProxy, validateNativeStaticLayoutSourceProxyMetadata, validateNativeVideoStreamStats, @@ -84,6 +92,7 @@ import { const electronAppMock = app as unknown as { getAppPath: ReturnType; + getGPUFeatureStatus: ReturnType; getGPUInfo: ReturnType; isPackaged: boolean; }; @@ -412,6 +421,60 @@ describe("getNativeExportCapabilities", () => { }); }); +describe("export hardware diagnostics", () => { + it("sanitizes machine and GPU details without exposing raw device identifiers", () => { + const hardware = sanitizeExportGpuInfo({ + machineModelName: "MacBookPro", + machineModelVersion: "18,2", + gpuDevice: [ + { + active: true, + vendorId: "0x10de", + deviceId: 9999, + deviceString: "NVIDIA GeForce RTX 4070", + videoMemory: 12_288, + }, + ], + }); + + expect(hardware).toEqual({ + machineModel: "MacBookPro 18,2", + gpus: [ + { + name: "NVIDIA GeForce RTX 4070", + vendor: "NVIDIA", + active: true, + videoMemoryMb: 12_288, + }, + ], + }); + expect(JSON.stringify(hardware)).not.toContain("deviceId"); + expect(JSON.stringify(hardware)).not.toContain("vendorId"); + }); + + it("returns system capacity and GPU acceleration status", async () => { + electronAppMock.getGPUInfo.mockResolvedValueOnce({ + gpuDevice: [{ active: true, vendorId: 0x8086 }], + }); + + const hardware = await getExportHardwareInfo(); + + expect(hardware).toMatchObject({ + platform: process.platform, + arch: process.arch, + gpus: [{ name: "Intel", vendor: "Intel", active: true }], + gpuFeatures: { + videoDecode: "enabled", + videoEncode: "enabled", + webgl: "enabled", + webgpu: "enabled", + }, + }); + expect(hardware.logicalProcessors).toBeGreaterThan(0); + expect(hardware.totalMemoryGb).toBeGreaterThan(0); + }); +}); + describe("getExperimentalNvidiaCudaExportSkipReason", () => { it("requires user opt-in before packaged CUDA candidates run", async () => { const reason = await withPackagedCudaCandidate( diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index 16b2993e..f01351b5 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -56,15 +56,41 @@ const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_MAX_BITRATE = 80_000_000; const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_CONTAINERS = new Set([".mp4", ".m4v", ".mov"]); type ElectronGpuDeviceLike = { + active?: boolean; vendorId?: number | string; vendorString?: string; deviceString?: string; + videoMemory?: number; }; type ElectronGpuInfoLike = { gpuDevice?: ElectronGpuDeviceLike[]; + machineModelName?: string; + machineModelVersion?: string; }; +export interface ExportHardwareInfo { + platform: NodeJS.Platform; + release: string; + arch: string; + cpuModel: string | null; + logicalProcessors: number; + totalMemoryGb: number; + machineModel: string | null; + gpus: Array<{ + name: string; + vendor: string | null; + active: boolean | null; + videoMemoryMb: number | null; + }>; + gpuFeatures: { + videoDecode: string | null; + videoEncode: string | null; + webgl: string | null; + webgpu: string | null; + }; +} + export type NativeVideoExportSession = { ffmpegProcess: ChildProcessByStdio; outputPath: string; @@ -1869,6 +1895,99 @@ export function hasNvidiaGpuDeviceInGpuInfo(gpuInfo: unknown) { return Array.isArray(devices) && devices.some(isNvidiaGpuDevice); } +function getGpuVendorLabel(device: ElectronGpuDeviceLike): string | null { + if (device.vendorString?.trim()) { + return device.vendorString.trim(); + } + + const rawVendorId = device.vendorId; + const vendorId = + typeof rawVendorId === "number" + ? rawVendorId + : typeof rawVendorId === "string" + ? rawVendorId.toLowerCase().startsWith("0x") + ? Number.parseInt(rawVendorId.slice(2), 16) + : Number.parseInt(rawVendorId, 10) + : Number.NaN; + return ( + { + [0x1002]: "AMD", + [0x106b]: "Apple", + [0x10de]: "NVIDIA", + [0x8086]: "Intel", + }[vendorId] ?? null + ); +} + +/** Reduces Electron's GPU response to support-safe hardware fields. */ +export function sanitizeExportGpuInfo( + gpuInfo: unknown, +): Pick { + if (!gpuInfo || typeof gpuInfo !== "object") { + return { machineModel: null, gpus: [] }; + } + + const info = gpuInfo as ElectronGpuInfoLike; + const machineModel = + [info.machineModelName, info.machineModelVersion] + .filter((value): value is string => Boolean(value?.trim())) + .join(" ") || null; + const gpus = Array.isArray(info.gpuDevice) + ? info.gpuDevice.map((device) => { + const vendor = getGpuVendorLabel(device); + return { + name: device.deviceString?.trim() || vendor || "Unknown GPU", + vendor, + active: typeof device.active === "boolean" ? device.active : null, + videoMemoryMb: + typeof device.videoMemory === "number" && device.videoMemory > 0 + ? device.videoMemory + : null, + }; + }) + : []; + + return { machineModel, gpus }; +} + +/** Captures sanitized hardware and GPU acceleration details for export support reports. */ +export async function getExportHardwareInfo(): Promise { + let sanitizedGpuInfo: Pick = { + machineModel: null, + gpus: [], + }; + try { + sanitizedGpuInfo = sanitizeExportGpuInfo(await app.getGPUInfo("basic")); + } catch { + // Hardware diagnostics are best effort and must not affect exporting. + } + + let gpuFeatureStatus: Record = {}; + try { + gpuFeatureStatus = app.getGPUFeatureStatus() as unknown as Record; + } catch { + // GPU feature status can be unavailable before Chromium finishes GPU initialization. + } + + const cpuModel = os.cpus()[0]?.model?.replace(/\s+/g, " ").trim() || null; + return { + platform: process.platform, + release: os.release(), + arch: process.arch, + cpuModel, + logicalProcessors: os.cpus().length, + totalMemoryGb: Math.round((os.totalmem() / 1024 ** 3) * 10) / 10, + machineModel: sanitizedGpuInfo.machineModel, + gpus: sanitizedGpuInfo.gpus, + gpuFeatures: { + videoDecode: gpuFeatureStatus.video_decode ?? null, + videoEncode: gpuFeatureStatus.video_encode ?? null, + webgl: gpuFeatureStatus.webgl ?? null, + webgpu: gpuFeatureStatus.webgpu ?? null, + }, + }; +} + async function hasNvidiaGpuForCudaExportCandidate() { const hasNvidiaGpu = await probeNvidiaGpuForCudaExportCandidate(); return hasNvidiaGpu ?? true; diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index 2eabb978..82b523de 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -5,12 +5,6 @@ import path from "node:path"; import type { Readable, Writable } from "node:stream"; import type { SaveDialogOptions } from "electron"; import { app, BrowserWindow, dialog, ipcMain } from "electron"; -import { - parseCaptionSidecarPayload, - type CaptionSidecarPayload, - withCaptionSidecarMessage, - writeCaptionSidecarsBestEffort, -} from "./exportCaptionSidecars"; import { closeExportStream, isOwnedExportPath, @@ -24,6 +18,7 @@ import { enqueueNativeVideoExportFrameWrites, exportNativeStaticLayoutVideo, flushNativeVideoExportPendingWriteRequests, + getExportHardwareInfo, getNativeExportCapabilities, getNativeVideoExportMaxQueuedWriteBytes, getNativeVideoExportSessionError, @@ -51,6 +46,12 @@ import { } from "../nativeVideoExport"; import { isAllowedLocalReadPath, resolveApprovedLocalMediaPath } from "../project/manager"; import { approveUserPath } from "../utils"; +import { + type CaptionSidecarPayload, + parseCaptionSidecarPayload, + withCaptionSidecarMessage, + writeCaptionSidecarsBestEffort, +} from "./exportCaptionSidecars"; function getPartialExportDestinationPath(destinationPath: string) { const parsed = path.parse(destinationPath); @@ -428,6 +429,21 @@ export function registerExportHandlers() { } }); + ipcMain.handle("get-export-hardware-info", async () => { + try { + return { + success: true, + hardware: await getExportHardwareInfo(), + }; + } catch (error) { + console.warn("[export-hardware-info] Failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + ipcMain.handle( "native-static-layout-export", async (event, options: NativeStaticLayoutExportOptions) => { diff --git a/electron/preload.ts b/electron/preload.ts index a3037231..768c3d40 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -102,6 +102,27 @@ type NativeExportCapabilities = { userOptInRequired: boolean; }; }; +type ExportHardwareInfo = { + platform: NodeJS.Platform; + release: string; + arch: string; + cpuModel: string | null; + logicalProcessors: number; + totalMemoryGb: number; + machineModel: string | null; + gpus: Array<{ + name: string; + vendor: string | null; + active: boolean | null; + videoMemoryMb: number | null; + }>; + gpuFeatures: { + videoDecode: string | null; + videoEncode: string | null; + webgl: string | null; + webgpu: string | null; + }; +}; const nativeVideoExportWriteRequests = new Map< number, @@ -220,6 +241,13 @@ contextBridge.exposeInMainWorld("electronAPI", { error?: string; }>; }, + getExportHardwareInfo: () => { + return ipcRenderer.invoke("get-export-hardware-info") as Promise<{ + success: boolean; + hardware?: ExportHardwareInfo; + error?: string; + }>; + }, nativeStaticLayoutExport: (options: { sessionId?: string; inputPath: string; diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts index c781ca1d..6fe6c0b5 100644 --- a/src/lib/exporter/modernVideoExporter.fallback.test.ts +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -317,6 +317,7 @@ describe("ModernVideoExporter native fallback routing", () => { userAgent: string; logicalProcessors: number; deviceMemoryGb: number; + hardware: RendererExportHardwareInfo; }; backpressureProfile: { name: string; @@ -338,6 +339,29 @@ describe("ModernVideoExporter native fallback routing", () => { userAgent: "RecordlyTest/1.0 Electron/43.1.0", logicalProcessors: 12, deviceMemoryGb: 8, + hardware: { + platform: "win32", + release: "10.0.26100", + arch: "x64", + cpuModel: "AMD Ryzen 9 7900X", + logicalProcessors: 24, + totalMemoryGb: 31.8, + machineModel: "Custom PC", + gpus: [ + { + name: "NVIDIA GeForce RTX 4070", + vendor: "NVIDIA", + active: true, + videoMemoryMb: 12_288, + }, + ], + gpuFeatures: { + videoDecode: "enabled", + videoEncode: "enabled", + webgl: "enabled", + webgpu: "enabled", + }, + }, }; exporter.backpressureProfile = { name: "webcodecs-balanced-plus", @@ -357,7 +381,13 @@ describe("ModernVideoExporter native fallback routing", () => { 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("System: win32 10.0.26100 (x64); model=Custom PC"); + expect(report).toContain("CPU: AMD Ryzen 9 7900X; 24 logical processors"); + expect(report).toContain("Memory: 31.8 GB"); + expect(report).toContain("GPU 1: NVIDIA GeForce RTX 4070; VRAM=12288 MB; active"); + expect(report).toContain( + "GPU acceleration: video decode=enabled; video encode=enabled; WebGL=enabled; WebGPU=enabled", + ); expect(report).toContain("Source: h264 1920x1080 @ 30.000 FPS; 1.000s"); expect(report).toContain("Source audio: none"); expect(report).toContain("Progress at failure: 314/600 (52.3%) rendered frames after"); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 1bedbb7b..d7490680 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -166,6 +166,7 @@ interface ExportRuntimeDiagnostics { userAgent?: string; logicalProcessors?: number; deviceMemoryGb?: number; + hardware?: RendererExportHardwareInfo; } type NativeAudioPlan = @@ -1007,6 +1008,20 @@ export class ModernVideoExporter { // Environment diagnostics must never prevent an export attempt. } + try { + if ( + typeof window !== "undefined" && + typeof window.electronAPI?.getExportHardwareInfo === "function" + ) { + const result = await window.electronAPI.getExportHardwareInfo(); + if (result.success && result.hardware) { + diagnostics.hardware = result.hardware; + } + } + } catch { + // Environment diagnostics must never prevent an export attempt. + } + return diagnostics; } @@ -1097,16 +1112,52 @@ export class ModernVideoExporter { 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("; ")}`); + const hardware = this.runtimeDiagnostics.hardware; + if (hardware) { + lines.push( + `System: ${hardware.platform} ${hardware.release} (${hardware.arch})${hardware.machineModel ? `; model=${hardware.machineModel}` : ""}`, + ); + lines.push( + `CPU: ${hardware.cpuModel ?? "Unknown"}; ${hardware.logicalProcessors} logical processors`, + ); + lines.push(`Memory: ${hardware.totalMemoryGb} GB`); + for (const [index, gpu] of hardware.gpus.entries()) { + const details = [ + gpu.vendor && !gpu.name.toLowerCase().includes(gpu.vendor.toLowerCase()) + ? `vendor=${gpu.vendor}` + : null, + gpu.videoMemoryMb ? `VRAM=${gpu.videoMemoryMb} MB` : null, + gpu.active === true ? "active" : gpu.active === false ? "inactive" : null, + ].filter((value): value is string => Boolean(value)); + lines.push( + `GPU ${index + 1}: ${gpu.name}${details.length ? `; ${details.join("; ")}` : ""}`, + ); + } + const gpuFeatures = [ + hardware.gpuFeatures.videoDecode + ? `video decode=${hardware.gpuFeatures.videoDecode}` + : null, + hardware.gpuFeatures.videoEncode + ? `video encode=${hardware.gpuFeatures.videoEncode}` + : null, + hardware.gpuFeatures.webgl ? `WebGL=${hardware.gpuFeatures.webgl}` : null, + hardware.gpuFeatures.webgpu ? `WebGPU=${hardware.gpuFeatures.webgpu}` : null, + ].filter((value): value is string => Boolean(value)); + if (gpuFeatures.length > 0) { + lines.push(`GPU acceleration: ${gpuFeatures.join("; ")}`); + } + } else { + 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) { From 570d5472a09b10a085970db0caa28d3952e41ebf Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:22:09 +1000 Subject: [PATCH 4/5] Collect complete GPU details for export reports --- electron/ipc/export/native-video.test.ts | 1 + electron/ipc/export/native-video.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts index a55ddbd2..9f1245cc 100644 --- a/electron/ipc/export/native-video.test.ts +++ b/electron/ipc/export/native-video.test.ts @@ -459,6 +459,7 @@ describe("export hardware diagnostics", () => { const hardware = await getExportHardwareInfo(); + expect(electronAppMock.getGPUInfo).toHaveBeenCalledWith("complete"); expect(hardware).toMatchObject({ platform: process.platform, arch: process.arch, diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index f01351b5..4c91d6d7 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -1957,7 +1957,7 @@ export async function getExportHardwareInfo(): Promise { gpus: [], }; try { - sanitizedGpuInfo = sanitizeExportGpuInfo(await app.getGPUInfo("basic")); + sanitizedGpuInfo = sanitizeExportGpuInfo(await app.getGPUInfo("complete")); } catch { // Hardware diagnostics are best effort and must not affect exporting. } From 10ecb701419475d9659575151016319fd6c91bbe Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:41:17 +1000 Subject: [PATCH 5/5] Remove unsupported VRAM diagnostics --- electron/electron-env.d.ts | 1 - electron/ipc/export/native-video.test.ts | 2 -- electron/ipc/export/native-video.ts | 6 ------ electron/preload.ts | 1 - src/lib/exporter/modernVideoExporter.fallback.test.ts | 3 +-- src/lib/exporter/modernVideoExporter.ts | 1 - 6 files changed, 1 insertion(+), 13 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 856fb643..1a926718 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -207,7 +207,6 @@ interface RendererExportHardwareInfo { name: string; vendor: string | null; active: boolean | null; - videoMemoryMb: number | null; }>; gpuFeatures: { videoDecode: string | null; diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts index 9f1245cc..89448539 100644 --- a/electron/ipc/export/native-video.test.ts +++ b/electron/ipc/export/native-video.test.ts @@ -432,7 +432,6 @@ describe("export hardware diagnostics", () => { vendorId: "0x10de", deviceId: 9999, deviceString: "NVIDIA GeForce RTX 4070", - videoMemory: 12_288, }, ], }); @@ -444,7 +443,6 @@ describe("export hardware diagnostics", () => { name: "NVIDIA GeForce RTX 4070", vendor: "NVIDIA", active: true, - videoMemoryMb: 12_288, }, ], }); diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index 4c91d6d7..81b56b48 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -60,7 +60,6 @@ type ElectronGpuDeviceLike = { vendorId?: number | string; vendorString?: string; deviceString?: string; - videoMemory?: number; }; type ElectronGpuInfoLike = { @@ -81,7 +80,6 @@ export interface ExportHardwareInfo { name: string; vendor: string | null; active: boolean | null; - videoMemoryMb: number | null; }>; gpuFeatures: { videoDecode: string | null; @@ -1939,10 +1937,6 @@ export function sanitizeExportGpuInfo( name: device.deviceString?.trim() || vendor || "Unknown GPU", vendor, active: typeof device.active === "boolean" ? device.active : null, - videoMemoryMb: - typeof device.videoMemory === "number" && device.videoMemory > 0 - ? device.videoMemory - : null, }; }) : []; diff --git a/electron/preload.ts b/electron/preload.ts index 768c3d40..990ee7a8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -114,7 +114,6 @@ type ExportHardwareInfo = { name: string; vendor: string | null; active: boolean | null; - videoMemoryMb: number | null; }>; gpuFeatures: { videoDecode: string | null; diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts index 6fe6c0b5..252d4280 100644 --- a/src/lib/exporter/modernVideoExporter.fallback.test.ts +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -352,7 +352,6 @@ describe("ModernVideoExporter native fallback routing", () => { name: "NVIDIA GeForce RTX 4070", vendor: "NVIDIA", active: true, - videoMemoryMb: 12_288, }, ], gpuFeatures: { @@ -384,7 +383,7 @@ describe("ModernVideoExporter native fallback routing", () => { expect(report).toContain("System: win32 10.0.26100 (x64); model=Custom PC"); expect(report).toContain("CPU: AMD Ryzen 9 7900X; 24 logical processors"); expect(report).toContain("Memory: 31.8 GB"); - expect(report).toContain("GPU 1: NVIDIA GeForce RTX 4070; VRAM=12288 MB; active"); + expect(report).toContain("GPU 1: NVIDIA GeForce RTX 4070; active"); expect(report).toContain( "GPU acceleration: video decode=enabled; video encode=enabled; WebGL=enabled; WebGPU=enabled", ); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index d7490680..15afb5bc 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -1126,7 +1126,6 @@ export class ModernVideoExporter { gpu.vendor && !gpu.name.toLowerCase().includes(gpu.vendor.toLowerCase()) ? `vendor=${gpu.vendor}` : null, - gpu.videoMemoryMb ? `VRAM=${gpu.videoMemoryMb} MB` : null, gpu.active === true ? "active" : gpu.active === false ? "inactive" : null, ].filter((value): value is string => Boolean(value)); lines.push(