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.
This commit is contained in:
大彪
2026-04-24 16:02:37 +08:00
parent 62f2a52860
commit 9ac4dbd2e8
14 changed files with 1295 additions and 124 deletions
+51 -1
View File
@@ -206,7 +206,7 @@ interface Window {
},
) => Promise<{
success: boolean;
data?: Uint8Array;
tempPath?: string;
encoderName?: string;
error?: string;
metrics?: RendererFfmpegAudioMuxMetrics;
@@ -232,6 +232,56 @@ interface Window {
error?: string;
metrics?: RendererFfmpegAudioMuxMetrics;
}>;
muxExportedVideoAudioFromPath: (
videoPath: string,
options?: {
audioMode?: "none" | "copy-source" | "trim-source" | "edited-track";
audioSourcePath?: string | null;
audioSourceSampleRate?: number;
trimSegments?: Array<{ startMs: number; endMs: number }>;
editedTrackStrategy?: "filtergraph-fast-path" | "offline-render-fallback";
editedTrackSegments?: Array<{ startMs: number; endMs: number; speed: number }>;
editedAudioData?: ArrayBuffer;
editedAudioMimeType?: string | null;
},
) => Promise<{
success: boolean;
tempPath?: string;
error?: string;
metrics?: RendererFfmpegAudioMuxMetrics;
}>;
openExportStream: (options?: { extension?: string }) => Promise<{
success: boolean;
streamId?: string;
tempPath?: string;
error?: string;
}>;
writeExportStreamChunk: (
streamId: string,
position: number,
chunk: Uint8Array,
) => Promise<{ success: boolean; error?: string }>;
closeExportStream: (
streamId: string,
options?: { abort?: boolean },
) => Promise<{
success: boolean;
tempPath?: string;
bytesWritten?: number;
error?: string;
}>;
finalizeExportedVideo: (payload: {
tempPath: string;
fileName: string;
outputPath?: string | null;
}) => Promise<{
success: boolean;
path?: string;
canceled?: boolean;
message?: string;
error?: string;
}>;
discardExportedTemp: (tempPath: string) => Promise<{ success: boolean; error?: string }>;
getVideoAudioFallbackPaths: (
videoPath: string,
) => Promise<{
+139
View File
@@ -0,0 +1,139 @@
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/,
);
});
});
+204
View File
@@ -0,0 +1,204 @@
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
}
}),
);
}
+1
View File
@@ -22,6 +22,7 @@ import {
windowsCaptureProcess,
} from "./state";
export { cleanupAllExportStreams } from "./export/exportStream";
export { cleanupNativeVideoExportSessions } from "./export/native-video";
/** Returns the currently selected source ID for setDisplayMediaRequestHandler */
+245 -10
View File
@@ -2,10 +2,17 @@ import type { ChildProcessByStdio } from "node:child_process";
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { performance } from "node:perf_hooks";
import type { Readable, Writable } from "node:stream";
import type { SaveDialogOptions } from "electron";
import { app, BrowserWindow, dialog, ipcMain } from "electron";
import {
closeExportStream,
isOwnedExportPath,
openExportStream,
registerOwnedExportPath,
releaseOwnedExportPath,
writeToExportStream,
} from "../export/exportStream";
import {
enqueueNativeVideoExportFrameWrite,
flushNativeVideoExportPendingWriteRequests,
@@ -32,6 +39,44 @@ import {
} from "../nativeVideoExport";
import { approveUserPath } from "../utils";
async function moveExportedTempFile(tempPath: string, destinationPath: string) {
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
try {
await fs.rename(tempPath, destinationPath);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EXDEV" && code !== "EPERM" && code !== "ENOTEMPTY") {
throw error;
}
// Cross-device or Windows permission quirks — fall back to copy + unlink so
// exporting to a different volume still works.
}
await fs.copyFile(tempPath, destinationPath);
try {
await fs.rm(tempPath, { force: true });
} catch (unlinkError) {
// Copy succeeded, so the export itself is safe; surface the leaked temp
// path instead of silently swallowing the failure so operators can
// reclaim disk space manually if the OS temp reaper misses it.
console.warn(
`[export] Failed to remove temp file after cross-volume copy (${tempPath}):`,
unlinkError,
);
}
}
function isTempPathSafe(tempPath: string): boolean {
const tempRoot = path.resolve(app.getPath("temp"));
const candidate = path.resolve(tempPath);
if (candidate === tempRoot) {
return false;
}
const withSep = tempRoot.endsWith(path.sep) ? tempRoot : tempRoot + path.sep;
return candidate.startsWith(withSep);
}
export function registerExportHandlers() {
ipcMain.handle(
"native-video-export-start",
@@ -263,20 +308,25 @@ export function registerExportHandlers() {
session.outputPath,
options ?? {},
);
const muxedVideoReadStartedAt = performance.now();
const data = await fs.readFile(finalized.outputPath);
nativeVideoExportSessions.delete(sessionId);
await removeTemporaryExportFile(finalized.outputPath);
// Register the finalized path so only app-produced paths can flow back
// through finalize-exported-video / discard-exported-temp.
registerOwnedExportPath(finalized.outputPath);
if (finalized.outputPath !== session.outputPath) {
// muxNativeVideoExportAudio removes the intermediate on success, but
// clear our registry entry defensively in case a future refactor
// changes that contract.
releaseOwnedExportPath(session.outputPath);
}
// Return a temp path instead of reading the file back into memory so we
// never hit V8's per-ArrayBuffer limit on >2 GiB exports. The renderer
// uses finalize-exported-video to move the file to its final path.
return {
success: true,
data: new Uint8Array(data),
tempPath: finalized.outputPath,
encoderName: session.encoderName,
metrics: {
...finalized.metrics,
muxedVideoReadMs: performance.now() - muxedVideoReadStartedAt,
muxedVideoBytes: data.byteLength,
},
metrics: finalized.metrics,
};
} catch (error) {
flushNativeVideoExportPendingWriteRequests(sessionId, session, String(error));
@@ -292,6 +342,41 @@ export function registerExportHandlers() {
},
);
ipcMain.handle(
"mux-exported-video-audio-from-path",
async (_, videoPath: string, options?: NativeVideoExportFinishOptions) => {
if (typeof videoPath !== "string" || !isOwnedExportPath(videoPath)) {
return {
success: false,
error: "Video path is not an app-managed export temp",
};
}
try {
const finalized = await muxNativeVideoExportAudio(videoPath, options ?? {});
if (finalized.outputPath !== videoPath) {
registerOwnedExportPath(finalized.outputPath);
// muxNativeVideoExportAudio removes the intermediate on success, so
// the input is no longer owned by the registry after the call
// returns.
releaseOwnedExportPath(videoPath);
}
return {
success: true,
tempPath: finalized.outputPath,
metrics: finalized.metrics,
};
} catch (error) {
// Only clean up the input path if it is still an owned temp (i.e.
// muxNativeVideoExportAudio failed before consuming it).
if (isOwnedExportPath(videoPath)) {
await removeTemporaryExportFile(videoPath);
releaseOwnedExportPath(videoPath);
}
return { success: false, error: String(error) };
}
},
);
ipcMain.handle(
"mux-exported-video-audio",
async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => {
@@ -311,6 +396,43 @@ export function registerExportHandlers() {
},
);
ipcMain.handle("export-stream-open", async (_event, options?: { extension?: string }) => {
try {
const result = await openExportStream(options);
return { success: true, streamId: result.streamId, tempPath: result.tempPath };
} catch (error) {
return { success: false, error: String(error) };
}
});
ipcMain.handle(
"export-stream-write",
async (_event, streamId: string, position: number, chunk: Uint8Array) => {
try {
await writeToExportStream(streamId, position, chunk);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
);
ipcMain.handle(
"export-stream-close",
async (_event, streamId: string, options?: { abort?: boolean }) => {
try {
const result = await closeExportStream(streamId, options);
return {
success: true,
tempPath: result.tempPath,
bytesWritten: result.bytesWritten,
};
} catch (error) {
return { success: false, error: String(error) };
}
},
);
ipcMain.handle("native-video-export-cancel", async (_, sessionId: string) => {
const session = nativeVideoExportSessions.get(sessionId);
if (!session) {
@@ -421,4 +543,117 @@ export function registerExportHandlers() {
}
},
);
ipcMain.handle(
"finalize-exported-video",
async (
event,
payload: {
tempPath: string;
fileName: string;
outputPath?: string | null;
},
) => {
const tempPath = payload?.tempPath;
const fileName = payload?.fileName;
if (typeof tempPath !== "string" || typeof fileName !== "string") {
return { success: false, error: "Invalid finalize-exported-video payload" };
}
if (!isTempPathSafe(tempPath) || !isOwnedExportPath(tempPath)) {
return {
success: false,
error: "Temp path is not an app-managed export temp",
};
}
try {
await fs.access(tempPath);
} catch {
return {
success: false,
error: `Exported video temp file is missing: ${tempPath}`,
};
}
try {
if (payload.outputPath) {
const resolvedPath = path.resolve(payload.outputPath);
await moveExportedTempFile(tempPath, resolvedPath);
releaseOwnedExportPath(tempPath);
approveUserPath(resolvedPath);
return {
success: true,
path: resolvedPath,
canceled: false,
message: "Video exported successfully",
};
}
const isGif = fileName.toLowerCase().endsWith(".gif");
const filters = isGif
? [{ name: "GIF Image", extensions: ["gif"] }]
: [{ name: "MP4 Video", extensions: ["mp4"] }];
const parentWindow = BrowserWindow.fromWebContents(event.sender);
const saveDialogOptions: SaveDialogOptions = {
title: isGif ? "Save Exported GIF" : "Save Exported Video",
defaultPath: path.join(app.getPath("downloads"), fileName),
filters,
properties: ["createDirectory", "showOverwriteConfirmation"],
};
const result = parentWindow
? await dialog.showSaveDialog(parentWindow, saveDialogOptions)
: await dialog.showSaveDialog(saveDialogOptions);
if (result.canceled || !result.filePath) {
// Leave the temp file in place so the renderer can offer "Save Again"
// without re-rendering. The renderer owns cleanup on discard.
return {
success: false,
canceled: true,
message: "Export canceled",
};
}
await moveExportedTempFile(tempPath, result.filePath);
releaseOwnedExportPath(tempPath);
approveUserPath(result.filePath);
return {
success: true,
path: result.filePath,
canceled: false,
message: "Video exported successfully",
};
} catch (error) {
console.error("Failed to finalize exported video:", error);
return {
success: false,
canceled: false,
message: "Failed to save exported video",
error: String(error),
};
}
},
);
ipcMain.handle("discard-exported-temp", async (_event, tempPath: string) => {
if (typeof tempPath !== "string" || tempPath.length === 0) {
return { success: false, error: "Invalid temp path" };
}
if (!isTempPathSafe(tempPath) || !isOwnedExportPath(tempPath)) {
return {
success: false,
error: "Temp path is not an app-managed export temp",
};
}
try {
await removeTemporaryExportFile(tempPath);
releaseOwnedExportPath(tempPath);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
});
}
+2
View File
@@ -19,6 +19,7 @@ import { showCursor } from "./cursorHider";
import { registerExtensionIpcHandlers } from "./extensions/extensionIpc";
import { getGpuSwitches } from "./gpuSwitches";
import {
cleanupAllExportStreams,
cleanupNativeVideoExportSessions,
getSelectedSourceId,
killWindowsCaptureProcess,
@@ -773,6 +774,7 @@ app.on("before-quit", () => {
killWindowsCaptureProcess();
showCursor();
cleanupNativeVideoExportSessions();
void cleanupAllExportStreams();
});
app.on("window-all-closed", () => {
+31
View File
@@ -204,6 +204,37 @@ contextBridge.exposeInMainWorld("electronAPI", {
metrics?: NativeVideoAudioMuxMetrics;
}>;
},
muxExportedVideoAudioFromPath: (
videoPath: string,
options?: {
audioMode?: "none" | "copy-source" | "trim-source" | "edited-track";
audioSourcePath?: string | null;
trimSegments?: Array<{ startMs: number; endMs: number }>;
editedAudioData?: ArrayBuffer;
editedAudioMimeType?: string | null;
},
) => {
return ipcRenderer.invoke("mux-exported-video-audio-from-path", videoPath, options);
},
openExportStream: (options?: { extension?: string }) => {
return ipcRenderer.invoke("export-stream-open", options);
},
writeExportStreamChunk: (streamId: string, position: number, chunk: Uint8Array) => {
return ipcRenderer.invoke("export-stream-write", streamId, position, chunk);
},
closeExportStream: (streamId: string, options?: { abort?: boolean }) => {
return ipcRenderer.invoke("export-stream-close", streamId, options);
},
finalizeExportedVideo: (payload: {
tempPath: string;
fileName: string;
outputPath?: string | null;
}) => {
return ipcRenderer.invoke("finalize-exported-video", payload);
},
discardExportedTemp: (tempPath: string) => {
return ipcRenderer.invoke("discard-exported-temp", tempPath);
},
getVideoAudioFallbackPaths: (videoPath: string) => {
return ipcRenderer.invoke("get-video-audio-fallback-paths", videoPath);
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "recordly",
"version": "1.1.23",
"version": "1.1.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "recordly",
"version": "1.1.23",
"version": "1.1.24",
"hasInstallScript": true,
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
+118 -17
View File
@@ -192,7 +192,12 @@ type EditorHistorySnapshot = {
type PendingExportSave = {
fileName: string;
arrayBuffer: ArrayBuffer;
// Exactly one of these is populated. `tempFilePath` is the preferred form
// for MP4 exports — the main process holds the finished file on disk, so
// "Save Again" just renames it instead of round-tripping through the
// renderer's ArrayBuffer heap.
arrayBuffer?: ArrayBuffer;
tempFilePath?: string;
};
type CancelableExporter = {
@@ -561,6 +566,12 @@ export default function VideoEditor() {
const [resolvedWebcamVideoUrl, setResolvedWebcamVideoUrl] = useState<string | null>(null);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
// Tracks the videoSourcePath for which the cursor telemetry IPC has already
// resolved. The smoke-export auto-trigger waits on this so long recordings
// still bake cursor/zoom animations into the output — without it, the
// auto-export fires as soon as the video loads and the telemetry arrives
// after encoding has started.
const [cursorTelemetrySourcePath, setCursorTelemetrySourcePath] = useState<string | null>(null);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
@@ -708,8 +719,14 @@ export default function VideoEditor() {
}, []);
const clearPendingExportSave = useCallback(() => {
const pending = pendingExportSaveRef.current;
pendingExportSaveRef.current = null;
setHasPendingExportSave(false);
if (pending?.tempFilePath && typeof window !== "undefined") {
// Best-effort cleanup — main-process also reaps stale temp files on
// before-quit, so we ignore failures here.
void window.electronAPI.discardExportedTemp?.(pending.tempFilePath);
}
}, []);
const refreshProjectLibrary = useCallback(async () => {
@@ -947,7 +964,11 @@ export default function VideoEditor() {
return () => {
exporterRef.current?.cancel();
exporterRef.current = null;
const pending = pendingExportSaveRef.current;
pendingExportSaveRef.current = null;
if (pending?.tempFilePath && typeof window !== "undefined") {
void window.electronAPI.discardExportedTemp?.(pending.tempFilePath);
}
if (pendingTelemetryRetryTimeoutRef.current !== null) {
window.clearTimeout(pendingTelemetryRetryTimeoutRef.current);
pendingTelemetryRetryTimeoutRef.current = null;
@@ -2429,6 +2450,7 @@ export default function VideoEditor() {
if (!videoPath || !videoSourcePath) {
if (mounted) {
setCursorTelemetry([]);
setCursorTelemetrySourcePath(null);
}
return;
}
@@ -2438,6 +2460,7 @@ export default function VideoEditor() {
if (mounted) {
const samples = result.success ? result.samples : [];
setCursorTelemetry(samples);
setCursorTelemetrySourcePath(videoSourcePath);
const shouldRetryFreshRecordingTelemetry =
pendingFreshRecordingAutoZoomPathRef.current === videoPath &&
@@ -2458,6 +2481,7 @@ export default function VideoEditor() {
console.warn("Unable to load cursor telemetry:", telemetryError);
if (mounted) {
setCursorTelemetry([]);
setCursorTelemetrySourcePath(videoSourcePath);
if (
pendingFreshRecordingAutoZoomPathRef.current === videoPath &&
autoSuggestedVideoPathRef.current !== videoPath &&
@@ -4057,19 +4081,51 @@ export default function VideoEditor() {
? Math.round(performance.now() - smokeExportStartedAt)
: undefined;
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
if (result.success && (result.blob || result.tempFilePath)) {
const timestamp = Date.now();
const fileName = `export-${timestamp}.mp4`;
markExportAsSaving();
const saveResult =
smokeExportConfig.enabled && smokeExportConfig.outputPath
? await window.electronAPI.writeExportedVideoToPath(
arrayBuffer,
smokeExportConfig.outputPath,
)
: await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
let saveResult: {
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
};
let pendingOnCancel: PendingExportSave;
if (result.tempFilePath) {
// Preferred path: main process already holds the finished MP4 on
// disk, so we just ask it to move the temp file into place. This
// avoids ever allocating a multi-GiB ArrayBuffer in the renderer.
saveResult = await window.electronAPI.finalizeExportedVideo({
tempPath: result.tempFilePath,
fileName,
outputPath:
smokeExportConfig.enabled && smokeExportConfig.outputPath
? smokeExportConfig.outputPath
: null,
});
pendingOnCancel = { fileName, tempFilePath: result.tempFilePath };
} else if (result.blob) {
// Legacy fallback: small exports may still surface a Blob (GIF,
// smoke tests in non-Electron environments, etc.).
const arrayBuffer = await result.blob.arrayBuffer();
saveResult =
smokeExportConfig.enabled && smokeExportConfig.outputPath
? await window.electronAPI.writeExportedVideoToPath(
arrayBuffer,
smokeExportConfig.outputPath,
)
: await window.electronAPI.saveExportedVideo(
arrayBuffer,
fileName,
);
pendingOnCancel = { fileName, arrayBuffer };
} else {
saveResult = { success: false, message: "Export produced no output" };
pendingOnCancel = { fileName };
}
if (saveResult.canceled) {
if (smokeExportConfig.enabled) {
@@ -4087,7 +4143,7 @@ export default function VideoEditor() {
metrics: result.metrics,
});
}
pendingExportSaveRef.current = { arrayBuffer, fileName };
pendingExportSaveRef.current = pendingOnCancel;
setHasPendingExportSave(true);
setExportError(
"Save dialog canceled. Click Save Again to save without re-rendering.",
@@ -4139,6 +4195,15 @@ export default function VideoEditor() {
}
setExportError(saveResult.message || "Failed to save video");
toast.error(saveResult.message || "Failed to save video");
// Keep the pending-save entry so the user can retry without
// re-rendering. The temp file is still on disk (the main
// process only moves/deletes it on success) and the
// ArrayBuffer fallback still references its in-memory blob.
if (pendingOnCancel.tempFilePath || pendingOnCancel.arrayBuffer) {
pendingExportSaveRef.current = pendingOnCancel;
setHasPendingExportSave(true);
keepExportDialogOpen = true;
}
if (smokeExportConfig.enabled) {
window.close();
return;
@@ -4282,6 +4347,18 @@ export default function VideoEditor() {
return;
}
// When smoke-export opens a .recordly project, the cursor telemetry
// sidecar is loaded asynchronously after the editor state applies.
// Without this gate the auto-export fires before telemetry arrives and
// produces a video with no cursor/zoom animations.
if (
smokeExportConfig.projectPath &&
videoSourcePath &&
cursorTelemetrySourcePath !== videoSourcePath
) {
return;
}
smokeExportStartedRef.current = true;
void handleExport({
format: "mp4",
@@ -4289,12 +4366,15 @@ export default function VideoEditor() {
encodingMode: smokeExportConfig.encodingMode ?? "balanced",
});
}, [
cursorTelemetrySourcePath,
error,
handleExport,
loading,
smokeExportConfig.enabled,
smokeExportConfig.encodingMode,
smokeExportConfig.projectPath,
videoPath,
videoSourcePath,
]);
const handleOpenExportDropdown = useCallback(() => {
@@ -4397,10 +4477,27 @@ export default function VideoEditor() {
return;
}
const saveResult = await window.electronAPI.saveExportedVideo(
pendingSave.arrayBuffer,
pendingSave.fileName,
);
let saveResult: {
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
};
if (pendingSave.tempFilePath) {
saveResult = await window.electronAPI.finalizeExportedVideo({
tempPath: pendingSave.tempFilePath,
fileName: pendingSave.fileName,
outputPath: null,
});
} else if (pendingSave.arrayBuffer) {
saveResult = await window.electronAPI.saveExportedVideo(
pendingSave.arrayBuffer,
pendingSave.fileName,
);
} else {
saveResult = { success: false, message: "No pending export to save" };
}
if (saveResult.canceled) {
setExportError("Save dialog canceled. Click Save Again to save without re-rendering.");
@@ -4409,7 +4506,11 @@ export default function VideoEditor() {
}
if (saveResult.success && saveResult.path) {
clearPendingExportSave();
// finalizeExportedVideo already moved the temp file into place, so the
// pending-save entry no longer refers to a file on disk. Flip the flag
// directly to avoid clearPendingExportSave issuing a spurious discard.
pendingExportSaveRef.current = null;
setHasPendingExportSave(false);
setExportError(null);
setExportedFilePath(saveResult.path);
showExportSuccessToast(saveResult.path);
@@ -4420,7 +4521,7 @@ export default function VideoEditor() {
const errorMessage = saveResult.message || "Failed to save video";
setExportError(errorMessage);
toast.error(errorMessage);
}, [clearPendingExportSave, showExportSuccessToast]);
}, [showExportSuccessToast]);
const handleOpenCropEditor = useCallback(() => {
cropSnapshotRef.current = { ...cropRegion };
+91 -40
View File
@@ -435,7 +435,7 @@ export class ModernVideoExporter {
}
const finishResult = await this.finishNativeVideoExport(nativeAudioPlan);
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
if (!finishResult.success || !finishResult.blob) {
if (!finishResult.success || (!finishResult.tempFilePath && !finishResult.blob)) {
return {
success: false,
error: finishResult.error || `${NATIVE_EXPORT_ENGINE_NAME} export failed`,
@@ -445,6 +445,7 @@ export class ModernVideoExporter {
return {
success: true,
tempFilePath: finishResult.tempFilePath,
blob: finishResult.blob,
metrics: this.buildExportMetrics(),
};
@@ -509,7 +510,7 @@ export class ModernVideoExporter {
}
this.reportFinalizingProgress(totalFrames, 99);
const blob = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
const muxerResult = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
this.awaitWithFinalizationTimeout(
this.muxer!.finalize(),
"muxer finalization",
@@ -523,9 +524,12 @@ export class ModernVideoExporter {
console.warn(
`[VideoExporter] Browser AAC encoding is unavailable; falling back to FFmpeg audio muxing.`,
);
const muxedResult = await this.finalizeExportWithFfmpegAudio(blob, nativeAudioPlan);
const muxedResult = await this.finalizeExportWithFfmpegAudio(
muxerResult,
nativeAudioPlan,
);
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
if (!muxedResult.success || !muxedResult.blob) {
if (!muxedResult.success || (!muxedResult.blob && !muxedResult.tempFilePath)) {
return {
success: false,
error: muxedResult.error || "Failed to mux audio with FFmpeg",
@@ -536,12 +540,24 @@ export class ModernVideoExporter {
return {
success: true,
blob: muxedResult.blob,
metrics: this.buildExportMetrics(),
tempFilePath: muxedResult.tempFilePath,
metrics: muxedResult.metrics ?? this.buildExportMetrics(),
};
}
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
return { success: true, blob, metrics: this.buildExportMetrics() };
if (muxerResult.mode === "stream") {
return {
success: true,
tempFilePath: muxerResult.tempFilePath,
metrics: this.buildExportMetrics(),
};
}
return {
success: true,
blob: muxerResult.blob,
metrics: this.buildExportMetrics(),
};
} catch (error) {
if (this.cancelled && !this.encoderError) {
return {
@@ -1100,26 +1116,24 @@ export class ModernVideoExporter {
}
this.encoderName = result.encoderName ?? this.encoderName;
if (!result.data) {
if (!result.tempPath) {
return {
success: false,
error: `${NATIVE_EXPORT_ENGINE_NAME} export did not return video data`,
error: `${NATIVE_EXPORT_ENGINE_NAME} export did not return a temp path`,
};
}
const videoBytes = result.data.slice();
return {
success: true,
blob: new Blob([videoBytes.buffer], { type: "video/mp4" }),
tempFilePath: result.tempPath,
};
}
private async finalizeExportWithFfmpegAudio(
videoBlob: Blob,
videoSource: import("./muxer").MuxerFinalizeResult,
audioPlan: NativeAudioPlan,
): Promise<ExportResult> {
if (typeof window === "undefined" || !window.electronAPI?.muxExportedVideoAudio) {
if (typeof window === "undefined") {
return {
success: false,
error: "FFmpeg audio fallback is unavailable in this environment.",
@@ -1156,35 +1170,72 @@ export class ModernVideoExporter {
editedAudioMimeType = audioBlob.type || null;
}
const videoBuffer = await videoBlob.arrayBuffer();
const muxOptions = {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" ||
audioPlan.audioMode === "trim-source" ||
(audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path")
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedTrackStrategy:
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
editedTrackSegments:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.editedTrackSegments
: undefined,
audioSourceSampleRate:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.audioSourceSampleRate
: undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
};
if (videoSource.mode === "stream") {
if (!window.electronAPI?.muxExportedVideoAudioFromPath) {
return {
success: false,
error: "FFmpeg audio fallback via temp path is unavailable in this environment.",
};
}
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
this.awaitWithFinalizationTimeout(
window.electronAPI.muxExportedVideoAudioFromPath(
videoSource.tempFilePath,
muxOptions,
),
"FFmpeg audio muxing",
"audio",
),
);
if (result.metrics) {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
if (!result.success || !result.tempPath) {
return {
success: false,
error: result.error || "Failed to mux exported audio with FFmpeg",
};
}
return { success: true, tempFilePath: result.tempPath };
}
if (!window.electronAPI?.muxExportedVideoAudio) {
return {
success: false,
error: "FFmpeg audio fallback is unavailable in this environment.",
};
}
const videoBuffer = await videoSource.blob.arrayBuffer();
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
this.awaitWithFinalizationTimeout(
window.electronAPI.muxExportedVideoAudio(videoBuffer, {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" ||
audioPlan.audioMode === "trim-source" ||
(audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path")
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedTrackStrategy:
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
editedTrackSegments:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.editedTrackSegments
: undefined,
audioSourceSampleRate:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.audioSourceSampleRate
: undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
window.electronAPI.muxExportedVideoAudio(videoBuffer, muxOptions),
"FFmpeg audio muxing",
"audio",
),
+186
View File
@@ -0,0 +1,186 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { type MuxerTargetMode, VideoMuxer } from "./muxer";
import type { ExportConfig } from "./types";
vi.mock("mediabunny", () => {
class FakeBufferTarget {
onwrite: ((start: number, end: number) => unknown) | null = null;
buffer: ArrayBuffer | null = null;
}
class FakeStreamTarget {
onwrite: ((start: number, end: number) => unknown) | null = null;
readonly writable: WritableStream<{
type: "write";
data: Uint8Array;
position: number;
}>;
constructor(
writable: WritableStream<{ type: "write"; data: Uint8Array; position: number }>,
) {
this.writable = writable;
}
}
const addedVideoTracks: unknown[] = [];
const addedAudioTracks: unknown[] = [];
const startedOutputs: unknown[] = [];
class FakeOutput {
readonly target: FakeBufferTarget | FakeStreamTarget;
constructor(options: {
format: unknown;
target: FakeBufferTarget | FakeStreamTarget;
}) {
this.target = options.target;
}
addVideoTrack(source: unknown, opts: unknown) {
addedVideoTracks.push({ source, opts });
}
addAudioTrack(source: unknown) {
addedAudioTracks.push(source);
}
async start() {
startedOutputs.push(this);
}
async finalize() {
if (this.target instanceof FakeBufferTarget) {
this.target.buffer = new ArrayBuffer(4);
return;
}
const writer = (this.target as FakeStreamTarget).writable.getWriter();
await writer.write({
type: "write",
data: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]),
position: 0,
});
await writer.close();
}
}
class FakeMp4OutputFormat {
constructor(public options: unknown) {}
}
class FakeEncodedVideoPacketSource {
constructor(public codec: string) {}
async add() {}
}
class FakeEncodedAudioPacketSource {
constructor(public codec: string) {}
async add() {}
}
const EncodedPacket = {
fromEncodedChunk: (chunk: unknown) => ({ chunk }),
};
return {
BufferTarget: FakeBufferTarget,
StreamTarget: FakeStreamTarget,
Output: FakeOutput,
Mp4OutputFormat: FakeMp4OutputFormat,
EncodedVideoPacketSource: FakeEncodedVideoPacketSource,
EncodedAudioPacketSource: FakeEncodedAudioPacketSource,
EncodedPacket,
};
});
const baseConfig: ExportConfig = {
width: 1920,
height: 1080,
frameRate: 30,
bitrate: 10_000_000,
};
describe("VideoMuxer target selection", () => {
const originalWindow = globalThis.window;
afterEach(() => {
// biome-ignore lint/suspicious/noExplicitAny: restoring the test globals.
(globalThis as any).window = originalWindow;
});
it("defaults to BufferTarget when no electronAPI stream IPC is available", async () => {
// biome-ignore lint/suspicious/noExplicitAny: isolating the muxer from Electron.
(globalThis as any).window = undefined;
const muxer = new VideoMuxer(baseConfig);
expect(muxer.getTargetMode()).toBe<MuxerTargetMode>("buffer");
await muxer.initialize();
const result = await muxer.finalize();
expect(result.mode).toBe("buffer");
if (result.mode === "buffer") {
expect(result.blob.type).toBe("video/mp4");
}
});
it("uses StreamTarget and routes chunks through the renderer IPC when available", async () => {
const chunkCalls: Array<{
streamId: string;
position: number;
bytes: number;
}> = [];
const fakeApi = {
openExportStream: vi.fn(async () => ({
success: true,
streamId: "stream-1",
tempPath: "/tmp/muxer-stream.mp4",
})),
writeExportStreamChunk: vi.fn(
async (streamId: string, position: number, chunk: Uint8Array) => {
chunkCalls.push({ streamId, position, bytes: chunk.byteLength });
return { success: true };
},
),
closeExportStream: vi.fn(async (streamId: string) => ({
success: true,
tempPath: "/tmp/muxer-stream.mp4",
bytesWritten: 8,
})),
};
// biome-ignore lint/suspicious/noExplicitAny: mocking the Electron bridge.
(globalThis as any).window = { electronAPI: fakeApi } as any;
const muxer = new VideoMuxer(baseConfig);
expect(muxer.getTargetMode()).toBe<MuxerTargetMode>("stream");
await muxer.initialize();
const result = await muxer.finalize();
expect(fakeApi.openExportStream).toHaveBeenCalledTimes(1);
expect(fakeApi.writeExportStreamChunk).toHaveBeenCalledTimes(1);
expect(fakeApi.closeExportStream).toHaveBeenCalledWith("stream-1", undefined);
expect(chunkCalls).toEqual([{ streamId: "stream-1", position: 0, bytes: 8 }]);
expect(result.mode).toBe("stream");
if (result.mode === "stream") {
expect(result.tempFilePath).toBe("/tmp/muxer-stream.mp4");
expect(result.bytesWritten).toBe(8);
}
});
it("aborts the stream session when the muxer is destroyed mid-flight", async () => {
const closeSpy = vi.fn(async () => ({
success: true,
tempPath: "/tmp/abort.mp4",
bytesWritten: 0,
}));
const fakeApi = {
openExportStream: vi.fn(async () => ({
success: true,
streamId: "stream-abort",
tempPath: "/tmp/abort.mp4",
})),
writeExportStreamChunk: vi.fn(async () => ({ success: true })),
closeExportStream: closeSpy,
};
// biome-ignore lint/suspicious/noExplicitAny: mocking the Electron bridge.
(globalThis as any).window = { electronAPI: fakeApi } as any;
const muxer = new VideoMuxer(baseConfig);
await muxer.initialize();
await muxer.abortStream();
expect(closeSpy).toHaveBeenCalledWith("stream-abort", { abort: true });
});
});
+134 -16
View File
@@ -3,27 +3,124 @@ import {
EncodedAudioPacketSource,
EncodedPacket,
EncodedVideoPacketSource,
type Target as MediabunnyTarget,
Mp4OutputFormat,
Output,
StreamTarget,
} from "mediabunny";
import type { ExportConfig } from "./types";
/**
* Chunk boundary used by both the mediabunny StreamTarget and the IPC writer.
* 16 MiB is well below Electron's IPC size limits and keeps per-chunk overhead
* low — large enough that a 35-minute 1080p export only produces a few hundred
* writes across the renderer/main boundary.
*/
const EXPORT_STREAM_CHUNK_BYTES = 16 * 1024 * 1024;
type IpcStreamSink = {
readonly streamId: string;
readonly tempPath: string;
};
async function openIpcExportStream(): Promise<IpcStreamSink> {
if (typeof window === "undefined" || !window.electronAPI?.openExportStream) {
throw new Error("openExportStream IPC is unavailable in this environment");
}
const result = await window.electronAPI.openExportStream({ extension: "mp4" });
if (!result.success || !result.streamId || !result.tempPath) {
throw new Error(result.error || "Failed to open export stream");
}
return { streamId: result.streamId, tempPath: result.tempPath };
}
async function writeIpcExportStream(
streamId: string,
position: number,
chunk: Uint8Array,
): Promise<void> {
// Mediabunny owns the underlying buffer for the lifetime of the call, so we
// copy the bytes before crossing the IPC boundary — ipcRenderer.invoke uses
// a structured clone under the hood and the original buffer can be reused
// by the muxer immediately after await returns.
const copy = new Uint8Array(chunk.byteLength);
copy.set(chunk);
const result = await window.electronAPI!.writeExportStreamChunk(streamId, position, copy);
if (!result.success) {
throw new Error(result.error || "Failed to write export chunk");
}
}
async function closeIpcExportStream(
streamId: string,
options?: { abort?: boolean },
): Promise<{ tempPath: string; bytesWritten: number }> {
const result = await window.electronAPI!.closeExportStream(streamId, options);
if (!result.success || !result.tempPath) {
throw new Error(result.error || "Failed to close export stream");
}
return { tempPath: result.tempPath, bytesWritten: result.bytesWritten ?? 0 };
}
export type MuxerTargetMode = "stream" | "buffer";
export type MuxerFinalizeResult =
| { mode: "stream"; tempFilePath: string; bytesWritten: number }
| { mode: "buffer"; blob: Blob };
function shouldUseStreamTarget(): boolean {
return (
typeof window !== "undefined" &&
typeof window.electronAPI?.openExportStream === "function" &&
typeof window.electronAPI?.writeExportStreamChunk === "function" &&
typeof window.electronAPI?.closeExportStream === "function"
);
}
export class VideoMuxer {
private output: Output | null = null;
private videoSource: EncodedVideoPacketSource | null = null;
private audioSource: EncodedAudioPacketSource | null = null;
private hasAudio: boolean;
private target: BufferTarget | null = null;
private target: MediabunnyTarget | null = null;
private config: ExportConfig;
private mode: MuxerTargetMode;
private streamSink: IpcStreamSink | null = null;
constructor(config: ExportConfig, hasAudio = false) {
constructor(config: ExportConfig, hasAudio = false, mode?: MuxerTargetMode) {
this.config = config;
this.hasAudio = hasAudio;
this.mode = mode ?? (shouldUseStreamTarget() ? "stream" : "buffer");
}
getTargetMode(): MuxerTargetMode {
return this.mode;
}
async initialize(): Promise<void> {
// Create the buffer target
this.target = new BufferTarget();
if (this.mode === "stream") {
const sink = await openIpcExportStream();
this.streamSink = sink;
const streamId = sink.streamId;
const writableStream = new WritableStream<{
type: "write";
data: Uint8Array;
position: number;
}>({
async write(chunk) {
if (chunk.type !== "write") {
return;
}
await writeIpcExportStream(streamId, chunk.position, chunk.data);
},
});
this.target = new StreamTarget(writableStream, {
chunked: true,
chunkSize: EXPORT_STREAM_CHUNK_BYTES,
});
} else {
this.target = new BufferTarget();
}
this.output = new Output({
format: new Mp4OutputFormat({
@@ -32,19 +129,16 @@ export class VideoMuxer {
target: this.target,
});
// Create video source - codec will be deduced from metadata
this.videoSource = new EncodedVideoPacketSource("avc");
this.output.addVideoTrack(this.videoSource, {
frameRate: this.config.frameRate,
});
// Create audio source if needed
if (this.hasAudio) {
this.audioSource = new EncodedAudioPacketSource("aac");
this.output.addAudioTrack(this.audioSource);
}
// Start the output to begin accepting media data
await this.output.start();
}
@@ -53,10 +147,7 @@ export class VideoMuxer {
throw new Error("Muxer not initialized");
}
// Convert WebCodecs chunk to Mediabunny packet
const packet = EncodedPacket.fromEncodedChunk(chunk);
// Add metadata with the first chunk
await this.videoSource.add(packet, meta);
}
@@ -65,26 +156,50 @@ export class VideoMuxer {
throw new Error("Audio not configured for this muxer");
}
// Convert WebCodecs chunk to Mediabunny packet
const packet = EncodedPacket.fromEncodedChunk(chunk);
// Add metadata with the first chunk
await this.audioSource.add(packet, meta);
}
async finalize(): Promise<Blob> {
async finalize(): Promise<MuxerFinalizeResult> {
if (!this.output || !this.target) {
throw new Error("Muxer not initialized");
}
await this.output.finalize();
const buffer = this.target.buffer;
if (this.mode === "stream") {
const sink = this.streamSink;
if (!sink) {
throw new Error("Stream target closed before finalization");
}
const closeResult = await closeIpcExportStream(sink.streamId);
this.streamSink = null;
return {
mode: "stream",
tempFilePath: closeResult.tempPath,
bytesWritten: closeResult.bytesWritten,
};
}
const buffer = (this.target as BufferTarget).buffer;
if (!buffer) {
throw new Error("Failed to finalize output");
}
return { mode: "buffer", blob: new Blob([buffer], { type: "video/mp4" }) };
}
return new Blob([buffer], { type: "video/mp4" });
async abortStream(): Promise<void> {
if (this.mode !== "stream" || !this.streamSink) {
return;
}
try {
await closeIpcExportStream(this.streamSink.streamId, { abort: true });
} catch {
// Best-effort cleanup on cancel — the main process also reaps stale
// streams on before-quit via cleanupAllExportStreams.
} finally {
this.streamSink = null;
}
}
destroy(): void {
@@ -92,5 +207,8 @@ export class VideoMuxer {
this.videoSource = null;
this.audioSource = null;
this.target = null;
if (this.streamSink) {
void this.abortStream();
}
}
}
+12
View File
@@ -86,6 +86,18 @@ export interface ExportMetrics {
export interface ExportResult {
success: boolean;
/**
* Absolute path to a main-process temp file containing the finished export.
* Preferred for MP4 output because it avoids loading multi-gigabyte files
* into the renderer's ArrayBuffer heap. The renderer should move the temp
* file to its final destination via `finalize-exported-video`.
*/
tempFilePath?: string;
/**
* In-renderer Blob for exports that fit in memory (GIF, smoke tests, legacy
* fallback). Mutually exclusive with `tempFilePath` — consumers should
* prefer the temp path when both are set.
*/
blob?: Blob;
filePath?: string;
error?: string;
+79 -38
View File
@@ -380,9 +380,9 @@ export class VideoExporter {
}
}
// Finalize muxer and get output blob
// Finalize muxer and get output (temp path for streaming, blob for legacy)
this.reportFinalizingProgress(totalFrames, 99);
const blob = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
const muxerResult = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
this.awaitWithFinalizationTimeout(
this.muxer!.finalize(),
"muxer finalization",
@@ -395,7 +395,7 @@ export class VideoExporter {
"[VideoExporter] Browser AAC encoding is unavailable; falling back to FFmpeg audio muxing.",
);
const result = await this.finalizeExportWithFfmpegAudio(
blob,
muxerResult,
audioPlan,
totalFrames,
);
@@ -407,7 +407,14 @@ export class VideoExporter {
}
this.finalizationTimeMs = this.getNowMs() - finalizationStartedAt;
return { success: true, blob, metrics: this.buildExportMetrics() };
if (muxerResult.mode === "stream") {
return {
success: true,
tempFilePath: muxerResult.tempFilePath,
metrics: this.buildExportMetrics(),
};
}
return { success: true, blob: muxerResult.blob, metrics: this.buildExportMetrics() };
} catch (error) {
if (this.cancelled && !this.encoderError) {
return {
@@ -843,7 +850,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 finalize native video export",
@@ -851,22 +858,19 @@ export class VideoExporter {
};
}
const blobData = new Uint8Array(result.data.byteLength);
blobData.set(result.data);
return {
success: true,
blob: new Blob([blobData.buffer], { type: "video/mp4" }),
tempFilePath: result.tempPath,
metrics: this.buildExportMetrics(),
};
}
private async finalizeExportWithFfmpegAudio(
videoBlob: Blob,
videoSource: import("./muxer").MuxerFinalizeResult,
audioPlan: NativeAudioPlan,
totalFrames: number,
): Promise<ExportResult> {
if (typeof window === "undefined" || !window.electronAPI?.muxExportedVideoAudio) {
if (typeof window === "undefined") {
return {
success: false,
error: "FFmpeg audio fallback is unavailable in this environment.",
@@ -903,35 +907,72 @@ export class VideoExporter {
editedAudioMimeType = audioBlob.type || null;
}
const videoBuffer = await videoBlob.arrayBuffer();
const muxOptions = {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" ||
audioPlan.audioMode === "trim-source" ||
(audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path")
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedTrackStrategy:
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
editedTrackSegments:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.editedTrackSegments
: undefined,
audioSourceSampleRate:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.audioSourceSampleRate
: undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
};
if (videoSource.mode === "stream") {
if (!window.electronAPI?.muxExportedVideoAudioFromPath) {
return {
success: false,
error: "FFmpeg audio fallback via temp path is unavailable in this environment.",
};
}
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
this.awaitWithFinalizationTimeout(
window.electronAPI.muxExportedVideoAudioFromPath(
videoSource.tempFilePath,
muxOptions,
),
"ffmpeg audio muxing",
"audio",
),
);
if (result.metrics) {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
if (!result.success || !result.tempPath) {
return {
success: false,
error: result.error || "Failed to mux exported audio with FFmpeg",
};
}
return { success: true, tempFilePath: result.tempPath };
}
if (!window.electronAPI?.muxExportedVideoAudio) {
return {
success: false,
error: "FFmpeg audio fallback is unavailable in this environment.",
};
}
const videoBuffer = await videoSource.blob.arrayBuffer();
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
this.awaitWithFinalizationTimeout(
window.electronAPI.muxExportedVideoAudio(videoBuffer, {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" ||
audioPlan.audioMode === "trim-source" ||
(audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path")
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedTrackStrategy:
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
editedTrackSegments:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.editedTrackSegments
: undefined,
audioSourceSampleRate:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.audioSourceSampleRate
: undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
window.electronAPI.muxExportedVideoAudio(videoBuffer, muxOptions),
"ffmpeg audio muxing",
"audio",
),