diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index a1aa8880..63f1caef 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -427,7 +427,7 @@ interface Window { }, ) => Promise<{ success: boolean; - data?: Uint8Array; + tempPath?: string; error?: string; metrics?: RendererFfmpegAudioMuxMetrics; }>; diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts index 91f11c01..7bc25eaa 100644 --- a/electron/ipc/export/native-video.test.ts +++ b/electron/ipc/export/native-video.test.ts @@ -2,19 +2,52 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("electron", () => ({ app: { - getAppPath: () => process.cwd(), - getPath: () => process.env.TEMP ?? process.cwd(), + getAppPath: vi.fn(() => process.cwd()), + getPath: vi.fn(() => process.env.TEMP ?? process.cwd()), isPackaged: false, }, })); vi.mock("../ffmpeg/binary", () => ({ - getFfmpegBinaryPath: () => "ffmpeg", + getFfmpegBinaryPath: vi.fn(() => "ffmpeg"), +})); + +vi.mock("../state", () => ({ + cachedNativeVideoEncoder: null, + setCachedNativeVideoEncoder: vi.fn(), +})); + +const fsMocks = vi.hoisted(() => ({ + access: vi.fn(async () => { + throw new Error("missing"); + }), + writeFile: vi.fn(async () => undefined), + readFile: vi.fn(), + stat: vi.fn(async () => ({ size: 5_000_000_000 })), + unlink: vi.fn(async () => undefined), +})); + +vi.mock("node:fs/promises", () => ({ + default: fsMocks, + ...fsMocks, +})); + +const execFileMock = vi.hoisted(() => + vi.fn((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null) => void) => { + cb(null); + return { stdout: "", stderr: "" } as unknown; + }), +); + +vi.mock("node:child_process", () => ({ + execFile: execFileMock, + spawn: vi.fn(), })); import { buildNativeVideoAudioMuxArgs, getNvidiaCudaAudioExportSkipReason, + muxExportedVideoAudioBuffer, normalizeNativeStaticLayoutBackground, parseFfmpegDurationSeconds, parseFfmpegFrameRate, @@ -97,6 +130,26 @@ describe("getNvidiaCudaAudioExportSkipReason", () => { }); }); +describe("muxExportedVideoAudioBuffer", () => { + it("returns the muxed output path without reading the muxed file into memory", async () => { + const videoData = new ArrayBuffer(64); + const result = await muxExportedVideoAudioBuffer(videoData, { audioMode: "none" }); + + expect(typeof result.outputPath).toBe("string"); + expect(result.outputPath.length).toBeGreaterThan(0); + // The >2 GiB fix relies on stat-only metric collection; readFile must stay unused. + expect(fsMocks.readFile).not.toHaveBeenCalled(); + expect(result.metrics.muxedVideoBytes).toBe(5_000_000_000); + }); + + it("preserves the input temp path when audioMode='none' (no re-mux)", async () => { + const videoData = new ArrayBuffer(32); + const result = await muxExportedVideoAudioBuffer(videoData, { audioMode: "none" }); + + expect(result.outputPath).toMatch(/recordly-export-video-/); + }); +}); + describe("buildNativeVideoAudioMuxArgs", () => { it("stream-copies source audio and preserves the requested video duration", () => { const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.mp4", "out.mp4", { @@ -172,7 +225,7 @@ describe("parseWindowsGpuExportSummary", () => { }); describe("parseNvidiaCudaExportSummary", () => { - it("parses the pretty JSON summary emitted by the CUDA lab wrapper", () => { + it("parses the pretty JSON summary emitted by the CUDA wrapper", () => { const summary = parseNvidiaCudaExportSummary( [ "preflight", @@ -198,7 +251,7 @@ describe("parseNvidiaCudaExportSummary", () => { }); it("returns null when the wrapper output has no JSON object", () => { - expect(parseNvidiaCudaExportSummary("native probe failed before summary")).toBeNull(); + expect(parseNvidiaCudaExportSummary("native helper failed before summary")).toBeNull(); }); }); diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index 97964ada..eb79fafc 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -2843,6 +2843,8 @@ export async function muxExportedVideoAudioBuffer( `recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`, ); const metrics: NativeVideoAudioMuxMetrics = {}; + let succeeded = false; + let outputPath = tempVideoPath; try { const tempVideoWriteStartedAt = getNowMs(); @@ -2851,23 +2853,35 @@ export async function muxExportedVideoAudioBuffer( metrics.tempVideoBytes = videoData.byteLength; const finalized = await muxNativeVideoExportAudio(tempVideoPath, options); Object.assign(metrics, finalized.metrics); - const muxedVideoReadStartedAt = getNowMs(); - const muxedData = await fs.readFile(finalized.outputPath); - metrics.muxedVideoReadMs = getNowMs() - muxedVideoReadStartedAt; - metrics.muxedVideoBytes = muxedData.byteLength; + outputPath = finalized.outputPath; + // Record byte size via stat instead of reading the whole file into a + // Buffer — fs.readFile throws ERR_FS_FILE_TOO_LARGE on >2 GiB outputs. + try { + const stat = await fs.stat(outputPath); + metrics.muxedVideoBytes = stat.size; + } catch { + // Stat failures are non-fatal; size is purely metric data. + } + succeeded = true; return { - data: new Uint8Array(muxedData), + outputPath, metrics, }; } finally { - await Promise.allSettled([ - removeTemporaryExportFile(tempVideoPath), - removeTemporaryExportFile( - path.join( - path.dirname(tempVideoPath), - `${path.basename(tempVideoPath, path.extname(tempVideoPath))}-final.mp4`, - ), - ), - ]); + // Always remove the unmuxed intermediate when the muxer wrote a separate + // file. Only remove the muxed output on failure — on success the caller + // owns it and is responsible for moving/deleting it. + const cleanupTargets: string[] = []; + if (outputPath !== tempVideoPath) { + cleanupTargets.push(tempVideoPath); + } + if (!succeeded) { + cleanupTargets.push(outputPath); + } + if (cleanupTargets.length > 0) { + await Promise.allSettled( + cleanupTargets.map((target) => removeTemporaryExportFile(target)), + ); + } } } diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index 05af28a9..8a73e17b 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -488,9 +488,14 @@ export function registerExportHandlers() { async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => { try { const result = await muxExportedVideoAudioBuffer(videoData, options ?? {}); + // Register the muxed output so finalize-exported-video / discard- + // exported-temp accept it. Returning a temp path (instead of the + // muxed bytes) keeps us off Node's >2 GiB fs.readFile cap and + // avoids a redundant copy through the renderer. + registerOwnedExportPath(result.outputPath); return { success: true, - data: result.data, + tempPath: result.outputPath, metrics: result.metrics, }; } catch (error) { diff --git a/electron/preload.ts b/electron/preload.ts index 69f2ef8b..f298ae29 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -368,7 +368,7 @@ contextBridge.exposeInMainWorld("electronAPI", { ) => { return ipcRenderer.invoke("mux-exported-video-audio", videoData, options) as Promise<{ success: boolean; - data?: Uint8Array; + tempPath?: string; error?: string; metrics?: NativeVideoAudioMuxMetrics; }>; diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index bb374f12..ed3df285 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -2153,17 +2153,18 @@ export class ModernVideoExporter { this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; } - if (!result.success || !result.data) { + if (!result.success || !result.tempPath) { return { success: false, error: result.error || "Failed to mux exported audio with FFmpeg", }; } - const videoBytes = result.data.slice(); + // Returning a temp path (instead of buffering the muxed bytes back into + // the renderer) is what keeps >2 GiB exports off Node's fs.readFile cap. return { success: true, - blob: new Blob([videoBytes.buffer], { type: "video/mp4" }), + tempFilePath: result.tempPath, }; } diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index bcc887ca..f8581910 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -981,7 +981,7 @@ export class VideoExporter { this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; } - if (!result.success || !result.data) { + if (!result.success || !result.tempPath) { return { success: false, error: result.error || "Failed to mux exported audio with FFmpeg", @@ -989,11 +989,11 @@ export class VideoExporter { }; } - const blobData = new Uint8Array(result.data.byteLength); - blobData.set(result.data); + // Returning a temp path (instead of buffering the muxed bytes back into + // the renderer) is what keeps >2 GiB exports off Node's fs.readFile cap. return { success: true, - blob: new Blob([blobData.buffer], { type: "video/mp4" }), + tempFilePath: result.tempPath, metrics: this.buildExportMetrics(), }; }