From 9ac4dbd2e8e72bbeb6aeb66f0df784b011c21f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E5=BD=AA?= Date: Mon, 20 Apr 2026 22:42:20 +0800 Subject: [PATCH 1/3] fix(export): stream MP4 output to disk to unblock >2 GiB exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- electron/electron-env.d.ts | 52 +++- electron/ipc/export/exportStream.test.ts | 139 +++++++++++ electron/ipc/export/exportStream.ts | 204 ++++++++++++++++ electron/ipc/handlers.ts | 1 + electron/ipc/register/export.ts | 255 +++++++++++++++++++- electron/main.ts | 2 + electron/preload.ts | 31 +++ package-lock.json | 4 +- src/components/video-editor/VideoEditor.tsx | 135 +++++++++-- src/lib/exporter/modernVideoExporter.ts | 131 +++++++--- src/lib/exporter/muxer.test.ts | 186 ++++++++++++++ src/lib/exporter/muxer.ts | 150 ++++++++++-- src/lib/exporter/types.ts | 12 + src/lib/exporter/videoExporter.ts | 117 ++++++--- 14 files changed, 1295 insertions(+), 124 deletions(-) create mode 100644 electron/ipc/export/exportStream.test.ts create mode 100644 electron/ipc/export/exportStream.ts create mode 100644 src/lib/exporter/muxer.test.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 1f88763b..99c90db8 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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<{ diff --git a/electron/ipc/export/exportStream.test.ts b/electron/ipc/export/exportStream.test.ts new file mode 100644 index 00000000..d8889437 --- /dev/null +++ b/electron/ipc/export/exportStream.test.ts @@ -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 { + 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/, + ); + }); +}); diff --git a/electron/ipc/export/exportStream.ts b/electron/ipc/export/exportStream.ts new file mode 100644 index 00000000..d0aac406 --- /dev/null +++ b/electron/ipc/export/exportStream.ts @@ -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; + aborted: boolean; +}; + +const exportStreamSessions = new Map(); + +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(); + +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 { + 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 { + 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 + } + }), + ); +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 4f179417..f6e4dc02 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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 */ diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index 9583a748..63b6f6df 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -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) }; + } + }); } diff --git a/electron/main.ts b/electron/main.ts index 689aa52c..9d195a9d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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", () => { diff --git a/electron/preload.ts b/electron/preload.ts index a47c13e5..e4db7f3e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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); }, diff --git a/package-lock.json b/package-lock.json index 34292cd5..5500762b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 6e4e7b98..9d0f4ece 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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(null); const [zoomRegions, setZoomRegions] = useState([]); const [cursorTelemetry, setCursorTelemetry] = useState([]); + // 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(null); const [selectedZoomId, setSelectedZoomId] = useState(null); const [trimRegions, setTrimRegions] = useState([]); const [selectedTrimId, setSelectedTrimId] = useState(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 }; diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 52b38441..9f4393ed 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -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 { - 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", ), diff --git a/src/lib/exporter/muxer.test.ts b/src/lib/exporter/muxer.test.ts new file mode 100644 index 00000000..01c66f00 --- /dev/null +++ b/src/lib/exporter/muxer.test.ts @@ -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("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("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 }); + }); +}); diff --git a/src/lib/exporter/muxer.ts b/src/lib/exporter/muxer.ts index 3b19cc8a..cfb78ca9 100644 --- a/src/lib/exporter/muxer.ts +++ b/src/lib/exporter/muxer.ts @@ -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 { + 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 { + // 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 { - // 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 { + async finalize(): Promise { 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 { + 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(); + } } } diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index 937f34ec..330213ef 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -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; diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 452d64ab..bcc887ca 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -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 { - 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", ), From f8122bde18bcf49949cd88d951b50636200a5e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E5=BD=AA?= Date: Mon, 20 Apr 2026 21:58:52 +0800 Subject: [PATCH 2/3] chore(smoke-export): let the smoke harness open .recordly project files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the smoke-export harness only accepted a raw MP4 input path. Validating the export pipeline on a real editor project (with its own zoom regions, wallpaper, annotations, and webcam state) required opening the editor by hand and clicking through the GUI. This adds three new environment variables that are picked up by the existing smoke-export query string plumbing: - RECORDLY_SMOKE_EXPORT_PROJECT — path to a .recordly project file; when set, the editor opens it via openProjectFileAtPath and applies the saved state before the auto-export fires. - RECORDLY_SMOKE_EXPORT_QUALITY — overrides the hard-coded "good" quality used by the auto-export trigger; accepts medium / good / high / source. - RECORDLY_SMOKE_EXPORT_FPS — overrides the frame rate; accepts 24 / 30 / 60. No behavior change for existing RECORDLY_SMOKE_EXPORT_INPUT runs; the new inputs are strictly additive and optional. The startup log line prefers the project path over the raw-input path when both are populated. --- electron/main.ts | 8 +-- electron/windows.ts | 9 ++++ src/components/video-editor/VideoEditor.tsx | 55 ++++++++++++++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 9d195a9d..c792ba3e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -882,9 +882,11 @@ app.whenReady().then(async () => { if (IS_SMOKE_EXPORT) { await logSmokeExportGpuDiagnostics(); - console.log( - `[smoke-export] Starting editor smoke export for ${process.env.RECORDLY_SMOKE_EXPORT_INPUT ?? ""}`, - ); + const smokeSource = + process.env.RECORDLY_SMOKE_EXPORT_PROJECT ?? + process.env.RECORDLY_SMOKE_EXPORT_INPUT ?? + ""; + console.log(`[smoke-export] Starting editor smoke export for ${smokeSource}`); createEditorWindowWrapper(); return; } diff --git a/electron/windows.ts b/electron/windows.ts index be618617..5ab6675a 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -89,6 +89,15 @@ function getEditorWindowQuery(): Record { if (process.env.RECORDLY_SMOKE_EXPORT_MAX_PENDING_FRAMES) { query.smokeMaxPendingFrames = process.env.RECORDLY_SMOKE_EXPORT_MAX_PENDING_FRAMES; } + if (process.env.RECORDLY_SMOKE_EXPORT_PROJECT) { + query.smokeProject = process.env.RECORDLY_SMOKE_EXPORT_PROJECT; + } + if (process.env.RECORDLY_SMOKE_EXPORT_QUALITY) { + query.smokeQuality = process.env.RECORDLY_SMOKE_EXPORT_QUALITY; + } + if (process.env.RECORDLY_SMOKE_EXPORT_FPS) { + query.smokeFps = process.env.RECORDLY_SMOKE_EXPORT_FPS; + } } return query; diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9d0f4ece..919ed14e 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -57,6 +57,7 @@ import { GifExporter, type GifFrameRate, type GifSizePreset, + isValidMp4FrameRate, ModernVideoExporter, probeSupportedMp4Dimensions, type SupportedMp4Dimensions, @@ -219,6 +220,9 @@ type SmokeExportConfig = { maxEncodeQueue?: number; maxDecodeQueue?: number; maxPendingFrames?: number; + projectPath?: string | null; + quality?: ExportQuality; + fps?: ExportMp4FrameRate; }; async function writeSmokeExportReport( @@ -290,6 +294,19 @@ function parseSmokeExportNonNegativeNumber(value: string | null): number | undef return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; } +function parseSmokeExportQuality(value: string | null): ExportQuality | undefined { + if (value === "medium" || value === "good" || value === "high" || value === "source") { + return value; + } + return undefined; +} + +function parseSmokeExportFps(value: string | null): ExportMp4FrameRate | undefined { + if (value === null) return undefined; + const parsed = Number.parseInt(value, 10); + return isValidMp4FrameRate(parsed) ? parsed : undefined; +} + function getSmokeExportConfig(search: string): SmokeExportConfig { const params = new URLSearchParams(search); const enabled = params.get("smokeExport") === "1"; @@ -340,6 +357,9 @@ function getSmokeExportConfig(search: string): SmokeExportConfig { maxPendingFrames: enabled ? parseSmokeExportNumber(params.get("smokeMaxPendingFrames")) : undefined, + projectPath: enabled ? params.get("smokeProject") : null, + quality: enabled ? parseSmokeExportQuality(params.get("smokeQuality")) : undefined, + fps: enabled ? parseSmokeExportFps(params.get("smokeFps")) : undefined, }; } @@ -1771,6 +1791,32 @@ export default function VideoEditor() { useEffect(() => { async function loadInitialData() { try { + if (smokeExportConfig.enabled && smokeExportConfig.projectPath) { + const projectResult = await window.electronAPI.openProjectFileAtPath( + smokeExportConfig.projectPath, + ); + if (!projectResult.success || !projectResult.project) { + setError( + `Smoke export failed to load project ${smokeExportConfig.projectPath}: ${ + projectResult.error || projectResult.message || "unknown error" + }`, + ); + return; + } + const restored = await applyLoadedProject( + projectResult.project, + projectResult.path ?? smokeExportConfig.projectPath, + ); + if (!restored) { + setError( + `Smoke export could not apply project ${smokeExportConfig.projectPath}`, + ); + return; + } + setError(null); + return; + } + if (smokeExportConfig.enabled) { if (!smokeExportConfig.inputPath) { setError("Smoke export input path is missing."); @@ -1884,6 +1930,7 @@ export default function VideoEditor() { initialEditorPreferences, smokeExportConfig.enabled, smokeExportConfig.inputPath, + smokeExportConfig.projectPath, smokeExportConfig.webcamInputPath, smokeExportConfig.webcamShadow, smokeExportConfig.webcamSize, @@ -3949,13 +3996,17 @@ export default function VideoEditor() { } } else { // MP4 Export - const quality = settings.quality ?? exportQuality; + const quality = smokeExportConfig.enabled + ? (smokeExportConfig.quality ?? settings.quality ?? exportQuality) + : (settings.quality ?? exportQuality); const encodingMode = smokeExportConfig.enabled ? (smokeExportConfig.encodingMode ?? settings.encodingMode ?? exportEncodingMode) : (settings.encodingMode ?? exportEncodingMode); - const selectedMp4FrameRate = settings.mp4FrameRate ?? mp4FrameRate; + const selectedMp4FrameRate = smokeExportConfig.enabled + ? (smokeExportConfig.fps ?? settings.mp4FrameRate ?? mp4FrameRate) + : (settings.mp4FrameRate ?? mp4FrameRate); const pipelineModel = smokeExportConfig.enabled ? (smokeExportConfig.pipelineModel ?? (smokeExportConfig.useNativeExport ? "modern" : "legacy")) From f05dc1f495461fec160aa5352af07edce08871c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E5=BD=AA?= Date: Fri, 24 Apr 2026 15:51:19 +0800 Subject: [PATCH 3/3] fix(export): address remaining PR #295 review threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Include `resolvedWebcamVideoUrl` in the dependency arrays for `captureProjectThumbnail` and `handleExport` so thumbnail capture and export settings stay in sync when the webcam URL refreshes. - Clear `VideoMuxer.streamSink` before awaiting `closeIpcExportStream` in `finalize()` to close the race where a concurrent `destroy()` → `abortStream()` could try to close the same stream ID a second time. --- src/components/video-editor/VideoEditor.tsx | 2 ++ src/lib/exporter/muxer.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 919ed14e..794dba45 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -946,6 +946,7 @@ export default function VideoEditor() { cursorTelemetry, clipRegions, padding, + resolvedWebcamVideoUrl, shadowIntensity, showCursor, speedRegions, @@ -4356,6 +4357,7 @@ export default function VideoEditor() { padding, cropRegion, webcam, + resolvedWebcamVideoUrl, annotationRegions, autoCaptions, autoCaptionSettings, diff --git a/src/lib/exporter/muxer.ts b/src/lib/exporter/muxer.ts index cfb78ca9..54e551c9 100644 --- a/src/lib/exporter/muxer.ts +++ b/src/lib/exporter/muxer.ts @@ -172,8 +172,11 @@ export class VideoMuxer { if (!sink) { throw new Error("Stream target closed before finalization"); } - const closeResult = await closeIpcExportStream(sink.streamId); + // Clear streamSink before awaiting close so a concurrent destroy()→ + // abortStream() short-circuits on `!this.streamSink` instead of racing + // us for the same streamId. this.streamSink = null; + const closeResult = await closeIpcExportStream(sink.streamId); return { mode: "stream", tempFilePath: closeResult.tempPath,