mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
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:
Vendored
+51
-1
@@ -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<{
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user