mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
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.
205 lines
5.6 KiB
TypeScript
205 lines
5.6 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import fsp from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { app } from "electron";
|
|
|
|
type ExportStreamSession = {
|
|
streamId: string;
|
|
sessionDir: string;
|
|
tempPath: string;
|
|
fileHandle: fs.promises.FileHandle;
|
|
bytesWritten: number;
|
|
highestWatermark: number;
|
|
writeQueue: Promise<void>;
|
|
aborted: boolean;
|
|
};
|
|
|
|
const exportStreamSessions = new Map<string, ExportStreamSession>();
|
|
|
|
const EXTENSION_ALLOWLIST = /^[a-z0-9]{1,8}$/;
|
|
const SESSION_DIR_PREFIX = "recordly-export-";
|
|
|
|
// Paths that the export pipeline itself produced (stream temp files plus any
|
|
// successor temp files returned by main-process helpers such as
|
|
// muxNativeVideoExportAudio). Every renderer-facing handler that moves or
|
|
// deletes a path must assert membership here before touching disk, so a
|
|
// compromised renderer can never route arbitrary file paths into the IPC.
|
|
const ownedExportPaths = new Set<string>();
|
|
|
|
function normalizeOwnedPath(candidate: string): string {
|
|
return path.resolve(candidate);
|
|
}
|
|
|
|
export function registerOwnedExportPath(candidate: string): void {
|
|
ownedExportPaths.add(normalizeOwnedPath(candidate));
|
|
}
|
|
|
|
export function releaseOwnedExportPath(candidate: string): void {
|
|
ownedExportPaths.delete(normalizeOwnedPath(candidate));
|
|
}
|
|
|
|
export function isOwnedExportPath(candidate: string): boolean {
|
|
return ownedExportPaths.has(normalizeOwnedPath(candidate));
|
|
}
|
|
|
|
function generateStreamId() {
|
|
return `recordly-export-stream-${randomUUID()}`;
|
|
}
|
|
|
|
export async function openExportStream(options?: { extension?: string }): Promise<{
|
|
streamId: string;
|
|
tempPath: string;
|
|
}> {
|
|
const extension = options?.extension ?? "mp4";
|
|
if (!EXTENSION_ALLOWLIST.test(extension)) {
|
|
throw new Error(`Invalid export stream extension: ${extension}`);
|
|
}
|
|
const streamId = generateStreamId();
|
|
|
|
// Per-session 0700 directory defeats TOCTOU/symlink races on shared
|
|
// tempdirs (e.g. /tmp on Linux): only the current user can enter the dir,
|
|
// so an adversary cannot pre-plant a symlink at the file path.
|
|
const sessionDir = await fsp.mkdtemp(path.join(app.getPath("temp"), SESSION_DIR_PREFIX));
|
|
try {
|
|
await fsp.chmod(sessionDir, 0o700);
|
|
} catch {
|
|
// chmod is a defense-in-depth on platforms where mkdtemp already sets
|
|
// a safe mode. Non-Linux filesystems may ignore mode bits entirely.
|
|
}
|
|
const tempPath = path.join(sessionDir, `${streamId}.${extension}`);
|
|
const fileHandle = await fsp.open(
|
|
tempPath,
|
|
fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
|
0o600,
|
|
);
|
|
|
|
exportStreamSessions.set(streamId, {
|
|
streamId,
|
|
sessionDir,
|
|
tempPath,
|
|
fileHandle,
|
|
bytesWritten: 0,
|
|
highestWatermark: 0,
|
|
writeQueue: Promise.resolve(),
|
|
aborted: false,
|
|
});
|
|
registerOwnedExportPath(tempPath);
|
|
|
|
return { streamId, tempPath };
|
|
}
|
|
|
|
export async function writeToExportStream(
|
|
streamId: string,
|
|
position: number,
|
|
chunk: Uint8Array,
|
|
): Promise<void> {
|
|
const session = exportStreamSessions.get(streamId);
|
|
if (!session) {
|
|
throw new Error(`Export stream not found: ${streamId}`);
|
|
}
|
|
|
|
if (session.aborted) {
|
|
throw new Error("Export stream was aborted");
|
|
}
|
|
|
|
// Serialize writes against the session to keep byte counters consistent when
|
|
// the renderer issues concurrent chunks.
|
|
const previous = session.writeQueue;
|
|
const next = previous.then(async () => {
|
|
if (session.aborted) {
|
|
return;
|
|
}
|
|
const buffer = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
await session.fileHandle.write(buffer, 0, buffer.byteLength, position);
|
|
session.bytesWritten += buffer.byteLength;
|
|
const end = position + buffer.byteLength;
|
|
if (end > session.highestWatermark) {
|
|
session.highestWatermark = end;
|
|
}
|
|
});
|
|
session.writeQueue = next.catch(() => undefined);
|
|
await next;
|
|
}
|
|
|
|
export async function closeExportStream(
|
|
streamId: string,
|
|
options?: { abort?: boolean },
|
|
): Promise<{ tempPath: string | null; bytesWritten: number }> {
|
|
const session = exportStreamSessions.get(streamId);
|
|
if (!session) {
|
|
throw new Error(`Export stream not found: ${streamId}`);
|
|
}
|
|
|
|
const abort = options?.abort === true;
|
|
if (abort) {
|
|
session.aborted = true;
|
|
}
|
|
|
|
try {
|
|
await session.writeQueue;
|
|
} catch {
|
|
// Propagated to the in-flight write promise; closure proceeds regardless.
|
|
}
|
|
|
|
try {
|
|
await session.fileHandle.close();
|
|
} catch {
|
|
// File handle may already be closed; ignore so abort paths stay best-effort.
|
|
}
|
|
|
|
exportStreamSessions.delete(streamId);
|
|
|
|
if (abort) {
|
|
releaseOwnedExportPath(session.tempPath);
|
|
try {
|
|
await fsp.rm(session.tempPath, { force: true });
|
|
} catch {
|
|
// Temp file may be gone already.
|
|
}
|
|
try {
|
|
await fsp.rm(session.sessionDir, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
// Aborted streams return `tempPath: null` so callers cannot accidentally
|
|
// reuse a path that no longer references a file on disk (or, worse, a
|
|
// path a later session may recycle).
|
|
return { tempPath: null, bytesWritten: 0 };
|
|
}
|
|
|
|
return {
|
|
tempPath: session.tempPath,
|
|
bytesWritten: session.highestWatermark,
|
|
};
|
|
}
|
|
|
|
export function hasExportStream(streamId: string): boolean {
|
|
return exportStreamSessions.has(streamId);
|
|
}
|
|
|
|
export async function cleanupAllExportStreams(): Promise<void> {
|
|
const sessions = Array.from(exportStreamSessions.values());
|
|
exportStreamSessions.clear();
|
|
ownedExportPaths.clear();
|
|
await Promise.allSettled(
|
|
sessions.map(async (session) => {
|
|
try {
|
|
await session.fileHandle.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
await fsp.rm(session.tempPath, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
await fsp.rm(session.sessionDir, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}),
|
|
);
|
|
}
|