Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ interface Window {
},
) => Promise<{
success: boolean;
data?: Uint8Array;
tempPath?: string;
error?: string;
metrics?: RendererFfmpegAudioMuxMetrics;
}>;
Expand Down
69 changes: 69 additions & 0 deletions electron/ipc/export/native-video.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";

vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp"),
},
}));

vi.mock("../ffmpeg/binary", () => ({
getFfmpegBinaryPath: vi.fn(() => "/usr/bin/ffmpeg"),
}));

vi.mock("../state", () => ({
cachedNativeVideoEncoder: null,
setCachedNativeVideoEncoder: vi.fn(),
}));

const fsMocks = vi.hoisted(() => ({
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 { muxExportedVideoAudioBuffer } from "./native-video";

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" });

// Path-based contract: caller (IPC handler) registers ownership and
// hands the path to the renderer's finalize-exported-video flow.
expect(typeof result.outputPath).toBe("string");
expect(result.outputPath.length).toBeGreaterThan(0);
// The 2 GiB bug was a fs.readFile of the muxed output. The fix relies on
// stat-only metric collection — readFile must stay unused.
expect(fsMocks.readFile).not.toHaveBeenCalled();
// We still record byte size so export metrics survive the change.
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" });

// muxNativeVideoExportAudio short-circuits when audioMode === "none" and
// returns the input path unchanged. We surface that so the renderer can
// finalize the same temp file the buffer was written to.
expect(result.outputPath).toMatch(/recordly-export-video-/);
});
});
42 changes: 28 additions & 14 deletions electron/ipc/export/native-video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,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();
Expand All @@ -500,23 +502,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)),
);
}
}
}
7 changes: 6 additions & 1 deletion electron/ipc/register/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,9 +382,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) {
Expand Down
2 changes: 1 addition & 1 deletion electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,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;
}>;
Expand Down
7 changes: 4 additions & 3 deletions src/lib/exporter/modernVideoExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1244,17 +1244,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,
};
}

Expand Down
8 changes: 4 additions & 4 deletions src/lib/exporter/videoExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,19 +981,19 @@ 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",
metrics: this.buildExportMetrics(),
};
}

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(),
};
}
Expand Down