Exports of recordings whose muxed output exceeds 2 GiB failed with
RangeError [ERR_FS_FILE_TOO_LARGE]: Node's fs.readFile rejects files
larger than kIoMaxLength (2 ** 31 - 1). The legacy export pipeline hit
this in muxExportedVideoAudioBuffer, which called
await fs.readFile(finalized.outputPath)
to ship the muxed bytes back to the renderer.
Mirror the path-based contract that mux-exported-video-audio-from-path
already uses:
- muxExportedVideoAudioBuffer now returns { outputPath, metrics } and
collects byte size via fs.stat instead of fs.readFile. The unmuxed
intermediate is still cleaned up; the muxed output is left for the
IPC handler to register and the renderer to finalize.
- The mux-exported-video-audio IPC handler registers the muxed output
via registerOwnedExportPath and returns { tempPath, metrics }.
- preload.ts and electron-env.d.ts: tempPath replaces data in the
renderer-facing return type.
- videoExporter.ts and modernVideoExporter.ts (the
finalizeExportWithFfmpegAudio fallback paths) now return
{ tempFilePath } so VideoEditor's existing finalize-exported-video
flow handles the move — the same path the modern stream-mode export
already takes.
The renderer already preferred tempFilePath over blob in
VideoEditor.tsx for MP4 saves (with the explicit comment "avoids ever
allocating a multi-GiB ArrayBuffer in the renderer"), so this just
removes the buffer-mode regression for large legacy exports.
Adds electron/ipc/export/native-video.test.ts asserting the new
contract: muxExportedVideoAudioBuffer returns a path, never calls
fs.readFile, and still records muxedVideoBytes via stat.
Closes#380
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.