Files
大彪 9ac4dbd2e8 fix(export): stream MP4 output to disk to unblock >2 GiB exports
Long recordings (35-minute screencaps were the motivating case) fail at
the 99% "Finalizing" step with a RangeError once the muxed MP4 would
exceed V8's ~2 GiB per-ArrayBuffer limit. Both export paths accumulate
the whole file in renderer memory and round-trip it through IPC, so no
output size past that point can complete:

- Legacy: src/lib/exporter/muxer.ts uses mediabunny's BufferTarget,
  which holds the entire MP4 in a single ArrayBuffer. finalize() → Blob
  → blob.arrayBuffer() → ipcRenderer.invoke('write-exported-video-to-
  path', arrayBuffer, path) — every step wants a ≥2 GiB contiguous
  allocation.
- Lightning: native-video-export-finish did fs.readFile(finalizedPath)
  and shipped the bytes back to the renderer, which re-serialized them
  again. Same ceiling.

This change moves the finished MP4 across the renderer↔main boundary
via a temp file instead of an ArrayBuffer:

- New electron/ipc/export/exportStream.ts manages streaming temp files
  via fh.write(buf, 0, len, position) so out-of-order writes (moov box
  rewrites, etc.) stay safe. Each session lives in a 0700 mkdtemp()
  directory opened with O_CREAT | O_EXCL so a hostile local user on a
  shared tempdir cannot pre-plant a symlink at the predicted path.
- New renderer-facing IPCs: export-stream-open/write/close,
  finalize-exported-video (renames temp to final path, copy+unlink
  fallback on EXDEV/EPERM/ENOTEMPTY with console.warn on leaked bytes),
  mux-exported-video-audio-from-path (FFmpeg audio fallback that takes
  a path instead of an ArrayBuffer), and discard-exported-temp. Every
  handler validates the caller-supplied path against an owned-export-
  paths registry before touching disk, so a compromised renderer cannot
  route arbitrary filesystem paths into main-process deletes/moves.
- The muxer now picks mediabunny's StreamTarget automatically when the
  Electron bridge is available (BufferTarget stays for tests and any
  non-Electron callers). finalize() returns { mode, tempFilePath,
  bytesWritten } or { mode, blob } so the exporter can branch.
- Exporters forward tempFilePath through ExportResult. Lightning's
  finish returns the ffmpeg temp path directly; the FFmpeg audio
  fallback forks on the muxer result type. modernVideoExporter's
  Lightning success branch now accepts tempFilePath (previously it
  checked blob only, which regressed every native export).
- VideoEditor.tsx dispatches on tempFilePath: finalize via the new IPC,
  keep the temp in place when the save dialog is canceled so "Save
  Again" still works without re-rendering, keep the pending-save entry
  alive on non-canceled save failures, and discard the temp on unmount
  or explicit clear. GIF and smoke-test code paths still use the
  legacy Blob path unchanged.
- app.on('before-quit') also reaps any open streaming sessions via
  cleanupAllExportStreams().

Chunk size is 16 MiB — well under Electron/Mojo IPC message limits
while keeping total writes low (~160 for a 2.5 GB export).

Tested locally: exported a 35:13 source (~2.7 GiB H.264 input) at
Original 1920×1080 + Balanced. Previously failed on finalize with a
RangeError; with this patch the Legacy pipeline produced a valid 3.7
GiB MP4 whose ffmpeg -i duration/streams match the source.

Addresses #194.
2026-04-24 16:02:37 +08:00

140 lines
4.4 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const TMP_ROOT = path.join(os.tmpdir(), `recordly-export-stream-test-${Date.now()}`);
vi.mock("electron", () => ({
app: {
getPath: (key: string) => {
if (key === "temp") {
return TMP_ROOT;
}
throw new Error(`Unexpected app.getPath key: ${key}`);
},
},
}));
import {
cleanupAllExportStreams,
closeExportStream,
hasExportStream,
isOwnedExportPath,
openExportStream,
writeToExportStream,
} from "./exportStream";
async function readBytes(filePath: string): Promise<Uint8Array> {
return new Uint8Array(await fs.readFile(filePath));
}
describe("exportStream", () => {
const openedTempPaths: string[] = [];
beforeAll(async () => {
await fs.mkdir(TMP_ROOT, { recursive: true });
});
afterAll(async () => {
await fs.rm(TMP_ROOT, { recursive: true, force: true });
});
beforeEach(() => {
openedTempPaths.length = 0;
});
afterEach(async () => {
await cleanupAllExportStreams();
await Promise.allSettled(
openedTempPaths.map((tempPath) => fs.rm(tempPath, { force: true })),
);
});
it("persists multiple chunks in order and reports the highest watermark on close", async () => {
const { streamId, tempPath } = await openExportStream();
openedTempPaths.push(tempPath);
expect(hasExportStream(streamId)).toBe(true);
await writeToExportStream(streamId, 0, new Uint8Array([1, 2, 3, 4]));
await writeToExportStream(streamId, 4, new Uint8Array([5, 6, 7, 8]));
const result = await closeExportStream(streamId);
expect(result.tempPath).toBe(tempPath);
expect(result.bytesWritten).toBe(8);
const bytes = await readBytes(tempPath);
expect(Array.from(bytes)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
expect(hasExportStream(streamId)).toBe(false);
});
it("supports out-of-order writes and keeps the watermark at the highest offset", async () => {
const { streamId, tempPath } = await openExportStream();
openedTempPaths.push(tempPath);
await writeToExportStream(streamId, 16, new Uint8Array([0xff, 0xee]));
await writeToExportStream(streamId, 0, new Uint8Array([0x01, 0x02]));
const result = await closeExportStream(streamId);
expect(result.bytesWritten).toBe(18);
const bytes = await readBytes(tempPath);
expect(bytes.byteLength).toBe(18);
expect(bytes[0]).toBe(0x01);
expect(bytes[1]).toBe(0x02);
expect(bytes[16]).toBe(0xff);
expect(bytes[17]).toBe(0xee);
});
it("removes the temp file when closed with abort and returns a null tempPath", async () => {
const { streamId, tempPath } = await openExportStream();
openedTempPaths.push(tempPath);
await writeToExportStream(streamId, 0, new Uint8Array([9, 9, 9]));
const result = await closeExportStream(streamId, { abort: true });
await expect(fs.access(tempPath)).rejects.toThrow();
expect(hasExportStream(streamId)).toBe(false);
expect(result.tempPath).toBeNull();
expect(result.bytesWritten).toBe(0);
expect(isOwnedExportPath(tempPath)).toBe(false);
});
it("tracks open temp paths in the owned-path registry until close", async () => {
const { streamId, tempPath } = await openExportStream();
openedTempPaths.push(tempPath);
expect(isOwnedExportPath(tempPath)).toBe(true);
// Spoofed paths must not satisfy the registry check.
expect(isOwnedExportPath("/tmp/not-ours.mp4")).toBe(false);
// A successful close keeps ownership so callers can still move or discard
// the file via the subsequent IPC call.
const result = await closeExportStream(streamId);
expect(result.tempPath).toBe(tempPath);
expect(isOwnedExportPath(tempPath)).toBe(true);
});
it("rejects writes after the stream has been aborted", async () => {
const { streamId, tempPath } = await openExportStream();
openedTempPaths.push(tempPath);
await closeExportStream(streamId, { abort: true });
await expect(writeToExportStream(streamId, 0, new Uint8Array([1]))).rejects.toThrow(
/Export stream not found/,
);
});
it("rejects an extension that would escape the temp directory", async () => {
await expect(openExportStream({ extension: "mp4/../etc/passwd" })).rejects.toThrow(
/Invalid export stream extension/,
);
await expect(openExportStream({ extension: ".." })).rejects.toThrow(
/Invalid export stream extension/,
);
await expect(openExportStream({ extension: "MP4" })).rejects.toThrow(
/Invalid export stream extension/,
);
});
});