Merge pull request #295 from BillLucky/fix/export-large-files-upstream

fix(export): stream MP4 output to disk to unblock >2 GiB exports
This commit is contained in:
webadderall
2026-04-25 00:45:44 +10:00
committed by GitHub
15 changed files with 1367 additions and 129 deletions
+173 -19
View File
@@ -57,6 +57,7 @@ import {
GifExporter,
type GifFrameRate,
type GifSizePreset,
isValidMp4FrameRate,
ModernVideoExporter,
probeSupportedMp4Dimensions,
type SupportedMp4Dimensions,
@@ -192,7 +193,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 = {
@@ -214,6 +220,9 @@ type SmokeExportConfig = {
maxEncodeQueue?: number;
maxDecodeQueue?: number;
maxPendingFrames?: number;
projectPath?: string | null;
quality?: ExportQuality;
fps?: ExportMp4FrameRate;
};
async function writeSmokeExportReport(
@@ -285,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";
@@ -335,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,
};
}
@@ -561,6 +586,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 +739,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 () => {
@@ -909,6 +946,7 @@ export default function VideoEditor() {
cursorTelemetry,
clipRegions,
padding,
resolvedWebcamVideoUrl,
shadowIntensity,
showCursor,
speedRegions,
@@ -947,7 +985,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;
@@ -1750,6 +1792,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.");
@@ -1863,6 +1931,7 @@ export default function VideoEditor() {
initialEditorPreferences,
smokeExportConfig.enabled,
smokeExportConfig.inputPath,
smokeExportConfig.projectPath,
smokeExportConfig.webcamInputPath,
smokeExportConfig.webcamShadow,
smokeExportConfig.webcamSize,
@@ -2429,6 +2498,7 @@ export default function VideoEditor() {
if (!videoPath || !videoSourcePath) {
if (mounted) {
setCursorTelemetry([]);
setCursorTelemetrySourcePath(null);
}
return;
}
@@ -2438,6 +2508,7 @@ export default function VideoEditor() {
if (mounted) {
const samples = result.success ? result.samples : [];
setCursorTelemetry(samples);
setCursorTelemetrySourcePath(videoSourcePath);
const shouldRetryFreshRecordingTelemetry =
pendingFreshRecordingAutoZoomPathRef.current === videoPath &&
@@ -2458,6 +2529,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 &&
@@ -3925,13 +3997,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"))
@@ -4057,19 +4133,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 +4195,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 +4247,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;
@@ -4240,6 +4357,7 @@ export default function VideoEditor() {
padding,
cropRegion,
webcam,
resolvedWebcamVideoUrl,
annotationRegions,
autoCaptions,
autoCaptionSettings,
@@ -4282,6 +4400,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 +4419,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 +4530,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 +4559,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 +4574,7 @@ export default function VideoEditor() {
const errorMessage = saveResult.message || "Failed to save video";
setExportError(errorMessage);
toast.error(errorMessage);
}, [clearPendingExportSave, showExportSuccessToast]);
}, [showExportSuccessToast]);
const handleOpenCropEditor = useCallback(() => {
cropSnapshotRef.current = { ...cropRegion };
+91 -40
View File
@@ -435,7 +435,7 @@ export class ModernVideoExporter {
}
const finishResult = await this.finishNativeVideoExport(nativeAudioPlan);
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
if (!finishResult.success || !finishResult.blob) {
if (!finishResult.success || (!finishResult.tempFilePath && !finishResult.blob)) {
return {
success: false,
error: finishResult.error || `${NATIVE_EXPORT_ENGINE_NAME} export failed`,
@@ -445,6 +445,7 @@ export class ModernVideoExporter {
return {
success: true,
tempFilePath: finishResult.tempFilePath,
blob: finishResult.blob,
metrics: this.buildExportMetrics(),
};
@@ -509,7 +510,7 @@ export class ModernVideoExporter {
}
this.reportFinalizingProgress(totalFrames, 99);
const blob = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
const muxerResult = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
this.awaitWithFinalizationTimeout(
this.muxer!.finalize(),
"muxer finalization",
@@ -523,9 +524,12 @@ export class ModernVideoExporter {
console.warn(
`[VideoExporter] Browser AAC encoding is unavailable; falling back to FFmpeg audio muxing.`,
);
const muxedResult = await this.finalizeExportWithFfmpegAudio(blob, nativeAudioPlan);
const muxedResult = await this.finalizeExportWithFfmpegAudio(
muxerResult,
nativeAudioPlan,
);
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
if (!muxedResult.success || !muxedResult.blob) {
if (!muxedResult.success || (!muxedResult.blob && !muxedResult.tempFilePath)) {
return {
success: false,
error: muxedResult.error || "Failed to mux audio with FFmpeg",
@@ -536,12 +540,24 @@ export class ModernVideoExporter {
return {
success: true,
blob: muxedResult.blob,
metrics: this.buildExportMetrics(),
tempFilePath: muxedResult.tempFilePath,
metrics: muxedResult.metrics ?? this.buildExportMetrics(),
};
}
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
return { success: true, blob, metrics: this.buildExportMetrics() };
if (muxerResult.mode === "stream") {
return {
success: true,
tempFilePath: muxerResult.tempFilePath,
metrics: this.buildExportMetrics(),
};
}
return {
success: true,
blob: muxerResult.blob,
metrics: this.buildExportMetrics(),
};
} catch (error) {
if (this.cancelled && !this.encoderError) {
return {
@@ -1100,26 +1116,24 @@ export class ModernVideoExporter {
}
this.encoderName = result.encoderName ?? this.encoderName;
if (!result.data) {
if (!result.tempPath) {
return {
success: false,
error: `${NATIVE_EXPORT_ENGINE_NAME} export did not return video data`,
error: `${NATIVE_EXPORT_ENGINE_NAME} export did not return a temp path`,
};
}
const videoBytes = result.data.slice();
return {
success: true,
blob: new Blob([videoBytes.buffer], { type: "video/mp4" }),
tempFilePath: result.tempPath,
};
}
private async finalizeExportWithFfmpegAudio(
videoBlob: Blob,
videoSource: import("./muxer").MuxerFinalizeResult,
audioPlan: NativeAudioPlan,
): Promise<ExportResult> {
if (typeof window === "undefined" || !window.electronAPI?.muxExportedVideoAudio) {
if (typeof window === "undefined") {
return {
success: false,
error: "FFmpeg audio fallback is unavailable in this environment.",
@@ -1156,35 +1170,72 @@ export class ModernVideoExporter {
editedAudioMimeType = audioBlob.type || null;
}
const videoBuffer = await videoBlob.arrayBuffer();
const muxOptions = {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" ||
audioPlan.audioMode === "trim-source" ||
(audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path")
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedTrackStrategy:
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
editedTrackSegments:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.editedTrackSegments
: undefined,
audioSourceSampleRate:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.audioSourceSampleRate
: undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
};
if (videoSource.mode === "stream") {
if (!window.electronAPI?.muxExportedVideoAudioFromPath) {
return {
success: false,
error: "FFmpeg audio fallback via temp path is unavailable in this environment.",
};
}
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
this.awaitWithFinalizationTimeout(
window.electronAPI.muxExportedVideoAudioFromPath(
videoSource.tempFilePath,
muxOptions,
),
"FFmpeg audio muxing",
"audio",
),
);
if (result.metrics) {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
if (!result.success || !result.tempPath) {
return {
success: false,
error: result.error || "Failed to mux exported audio with FFmpeg",
};
}
return { success: true, tempFilePath: result.tempPath };
}
if (!window.electronAPI?.muxExportedVideoAudio) {
return {
success: false,
error: "FFmpeg audio fallback is unavailable in this environment.",
};
}
const videoBuffer = await videoSource.blob.arrayBuffer();
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
this.awaitWithFinalizationTimeout(
window.electronAPI.muxExportedVideoAudio(videoBuffer, {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" ||
audioPlan.audioMode === "trim-source" ||
(audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path")
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedTrackStrategy:
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
editedTrackSegments:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.editedTrackSegments
: undefined,
audioSourceSampleRate:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.audioSourceSampleRate
: undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
window.electronAPI.muxExportedVideoAudio(videoBuffer, muxOptions),
"FFmpeg audio muxing",
"audio",
),
+186
View File
@@ -0,0 +1,186 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { type MuxerTargetMode, VideoMuxer } from "./muxer";
import type { ExportConfig } from "./types";
vi.mock("mediabunny", () => {
class FakeBufferTarget {
onwrite: ((start: number, end: number) => unknown) | null = null;
buffer: ArrayBuffer | null = null;
}
class FakeStreamTarget {
onwrite: ((start: number, end: number) => unknown) | null = null;
readonly writable: WritableStream<{
type: "write";
data: Uint8Array;
position: number;
}>;
constructor(
writable: WritableStream<{ type: "write"; data: Uint8Array; position: number }>,
) {
this.writable = writable;
}
}
const addedVideoTracks: unknown[] = [];
const addedAudioTracks: unknown[] = [];
const startedOutputs: unknown[] = [];
class FakeOutput {
readonly target: FakeBufferTarget | FakeStreamTarget;
constructor(options: {
format: unknown;
target: FakeBufferTarget | FakeStreamTarget;
}) {
this.target = options.target;
}
addVideoTrack(source: unknown, opts: unknown) {
addedVideoTracks.push({ source, opts });
}
addAudioTrack(source: unknown) {
addedAudioTracks.push(source);
}
async start() {
startedOutputs.push(this);
}
async finalize() {
if (this.target instanceof FakeBufferTarget) {
this.target.buffer = new ArrayBuffer(4);
return;
}
const writer = (this.target as FakeStreamTarget).writable.getWriter();
await writer.write({
type: "write",
data: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]),
position: 0,
});
await writer.close();
}
}
class FakeMp4OutputFormat {
constructor(public options: unknown) {}
}
class FakeEncodedVideoPacketSource {
constructor(public codec: string) {}
async add() {}
}
class FakeEncodedAudioPacketSource {
constructor(public codec: string) {}
async add() {}
}
const EncodedPacket = {
fromEncodedChunk: (chunk: unknown) => ({ chunk }),
};
return {
BufferTarget: FakeBufferTarget,
StreamTarget: FakeStreamTarget,
Output: FakeOutput,
Mp4OutputFormat: FakeMp4OutputFormat,
EncodedVideoPacketSource: FakeEncodedVideoPacketSource,
EncodedAudioPacketSource: FakeEncodedAudioPacketSource,
EncodedPacket,
};
});
const baseConfig: ExportConfig = {
width: 1920,
height: 1080,
frameRate: 30,
bitrate: 10_000_000,
};
describe("VideoMuxer target selection", () => {
const originalWindow = globalThis.window;
afterEach(() => {
// biome-ignore lint/suspicious/noExplicitAny: restoring the test globals.
(globalThis as any).window = originalWindow;
});
it("defaults to BufferTarget when no electronAPI stream IPC is available", async () => {
// biome-ignore lint/suspicious/noExplicitAny: isolating the muxer from Electron.
(globalThis as any).window = undefined;
const muxer = new VideoMuxer(baseConfig);
expect(muxer.getTargetMode()).toBe<MuxerTargetMode>("buffer");
await muxer.initialize();
const result = await muxer.finalize();
expect(result.mode).toBe("buffer");
if (result.mode === "buffer") {
expect(result.blob.type).toBe("video/mp4");
}
});
it("uses StreamTarget and routes chunks through the renderer IPC when available", async () => {
const chunkCalls: Array<{
streamId: string;
position: number;
bytes: number;
}> = [];
const fakeApi = {
openExportStream: vi.fn(async () => ({
success: true,
streamId: "stream-1",
tempPath: "/tmp/muxer-stream.mp4",
})),
writeExportStreamChunk: vi.fn(
async (streamId: string, position: number, chunk: Uint8Array) => {
chunkCalls.push({ streamId, position, bytes: chunk.byteLength });
return { success: true };
},
),
closeExportStream: vi.fn(async (streamId: string) => ({
success: true,
tempPath: "/tmp/muxer-stream.mp4",
bytesWritten: 8,
})),
};
// biome-ignore lint/suspicious/noExplicitAny: mocking the Electron bridge.
(globalThis as any).window = { electronAPI: fakeApi } as any;
const muxer = new VideoMuxer(baseConfig);
expect(muxer.getTargetMode()).toBe<MuxerTargetMode>("stream");
await muxer.initialize();
const result = await muxer.finalize();
expect(fakeApi.openExportStream).toHaveBeenCalledTimes(1);
expect(fakeApi.writeExportStreamChunk).toHaveBeenCalledTimes(1);
expect(fakeApi.closeExportStream).toHaveBeenCalledWith("stream-1", undefined);
expect(chunkCalls).toEqual([{ streamId: "stream-1", position: 0, bytes: 8 }]);
expect(result.mode).toBe("stream");
if (result.mode === "stream") {
expect(result.tempFilePath).toBe("/tmp/muxer-stream.mp4");
expect(result.bytesWritten).toBe(8);
}
});
it("aborts the stream session when the muxer is destroyed mid-flight", async () => {
const closeSpy = vi.fn(async () => ({
success: true,
tempPath: "/tmp/abort.mp4",
bytesWritten: 0,
}));
const fakeApi = {
openExportStream: vi.fn(async () => ({
success: true,
streamId: "stream-abort",
tempPath: "/tmp/abort.mp4",
})),
writeExportStreamChunk: vi.fn(async () => ({ success: true })),
closeExportStream: closeSpy,
};
// biome-ignore lint/suspicious/noExplicitAny: mocking the Electron bridge.
(globalThis as any).window = { electronAPI: fakeApi } as any;
const muxer = new VideoMuxer(baseConfig);
await muxer.initialize();
await muxer.abortStream();
expect(closeSpy).toHaveBeenCalledWith("stream-abort", { abort: true });
});
});
+137 -16
View File
@@ -3,27 +3,124 @@ import {
EncodedAudioPacketSource,
EncodedPacket,
EncodedVideoPacketSource,
type Target as MediabunnyTarget,
Mp4OutputFormat,
Output,
StreamTarget,
} from "mediabunny";
import type { ExportConfig } from "./types";
/**
* Chunk boundary used by both the mediabunny StreamTarget and the IPC writer.
* 16 MiB is well below Electron's IPC size limits and keeps per-chunk overhead
* low — large enough that a 35-minute 1080p export only produces a few hundred
* writes across the renderer/main boundary.
*/
const EXPORT_STREAM_CHUNK_BYTES = 16 * 1024 * 1024;
type IpcStreamSink = {
readonly streamId: string;
readonly tempPath: string;
};
async function openIpcExportStream(): Promise<IpcStreamSink> {
if (typeof window === "undefined" || !window.electronAPI?.openExportStream) {
throw new Error("openExportStream IPC is unavailable in this environment");
}
const result = await window.electronAPI.openExportStream({ extension: "mp4" });
if (!result.success || !result.streamId || !result.tempPath) {
throw new Error(result.error || "Failed to open export stream");
}
return { streamId: result.streamId, tempPath: result.tempPath };
}
async function writeIpcExportStream(
streamId: string,
position: number,
chunk: Uint8Array,
): Promise<void> {
// Mediabunny owns the underlying buffer for the lifetime of the call, so we
// copy the bytes before crossing the IPC boundary — ipcRenderer.invoke uses
// a structured clone under the hood and the original buffer can be reused
// by the muxer immediately after await returns.
const copy = new Uint8Array(chunk.byteLength);
copy.set(chunk);
const result = await window.electronAPI!.writeExportStreamChunk(streamId, position, copy);
if (!result.success) {
throw new Error(result.error || "Failed to write export chunk");
}
}
async function closeIpcExportStream(
streamId: string,
options?: { abort?: boolean },
): Promise<{ tempPath: string; bytesWritten: number }> {
const result = await window.electronAPI!.closeExportStream(streamId, options);
if (!result.success || !result.tempPath) {
throw new Error(result.error || "Failed to close export stream");
}
return { tempPath: result.tempPath, bytesWritten: result.bytesWritten ?? 0 };
}
export type MuxerTargetMode = "stream" | "buffer";
export type MuxerFinalizeResult =
| { mode: "stream"; tempFilePath: string; bytesWritten: number }
| { mode: "buffer"; blob: Blob };
function shouldUseStreamTarget(): boolean {
return (
typeof window !== "undefined" &&
typeof window.electronAPI?.openExportStream === "function" &&
typeof window.electronAPI?.writeExportStreamChunk === "function" &&
typeof window.electronAPI?.closeExportStream === "function"
);
}
export class VideoMuxer {
private output: Output | null = null;
private videoSource: EncodedVideoPacketSource | null = null;
private audioSource: EncodedAudioPacketSource | null = null;
private hasAudio: boolean;
private target: BufferTarget | null = null;
private target: MediabunnyTarget | null = null;
private config: ExportConfig;
private mode: MuxerTargetMode;
private streamSink: IpcStreamSink | null = null;
constructor(config: ExportConfig, hasAudio = false) {
constructor(config: ExportConfig, hasAudio = false, mode?: MuxerTargetMode) {
this.config = config;
this.hasAudio = hasAudio;
this.mode = mode ?? (shouldUseStreamTarget() ? "stream" : "buffer");
}
getTargetMode(): MuxerTargetMode {
return this.mode;
}
async initialize(): Promise<void> {
// Create the buffer target
this.target = new BufferTarget();
if (this.mode === "stream") {
const sink = await openIpcExportStream();
this.streamSink = sink;
const streamId = sink.streamId;
const writableStream = new WritableStream<{
type: "write";
data: Uint8Array;
position: number;
}>({
async write(chunk) {
if (chunk.type !== "write") {
return;
}
await writeIpcExportStream(streamId, chunk.position, chunk.data);
},
});
this.target = new StreamTarget(writableStream, {
chunked: true,
chunkSize: EXPORT_STREAM_CHUNK_BYTES,
});
} else {
this.target = new BufferTarget();
}
this.output = new Output({
format: new Mp4OutputFormat({
@@ -32,19 +129,16 @@ export class VideoMuxer {
target: this.target,
});
// Create video source - codec will be deduced from metadata
this.videoSource = new EncodedVideoPacketSource("avc");
this.output.addVideoTrack(this.videoSource, {
frameRate: this.config.frameRate,
});
// Create audio source if needed
if (this.hasAudio) {
this.audioSource = new EncodedAudioPacketSource("aac");
this.output.addAudioTrack(this.audioSource);
}
// Start the output to begin accepting media data
await this.output.start();
}
@@ -53,10 +147,7 @@ export class VideoMuxer {
throw new Error("Muxer not initialized");
}
// Convert WebCodecs chunk to Mediabunny packet
const packet = EncodedPacket.fromEncodedChunk(chunk);
// Add metadata with the first chunk
await this.videoSource.add(packet, meta);
}
@@ -65,26 +156,53 @@ 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");
}
// 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,
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 +210,8 @@ export class VideoMuxer {
this.videoSource = null;
this.audioSource = null;
this.target = null;
if (this.streamSink) {
void this.abortStream();
}
}
}
+12
View File
@@ -86,6 +86,18 @@ export interface ExportMetrics {
export interface ExportResult {
success: boolean;
/**
* Absolute path to a main-process temp file containing the finished export.
* Preferred for MP4 output because it avoids loading multi-gigabyte files
* into the renderer's ArrayBuffer heap. The renderer should move the temp
* file to its final destination via `finalize-exported-video`.
*/
tempFilePath?: string;
/**
* In-renderer Blob for exports that fit in memory (GIF, smoke tests, legacy
* fallback). Mutually exclusive with `tempFilePath` — consumers should
* prefer the temp path when both are set.
*/
blob?: Blob;
filePath?: string;
error?: string;
+79 -38
View File
@@ -380,9 +380,9 @@ export class VideoExporter {
}
}
// Finalize muxer and get output blob
// Finalize muxer and get output (temp path for streaming, blob for legacy)
this.reportFinalizingProgress(totalFrames, 99);
const blob = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
const muxerResult = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
this.awaitWithFinalizationTimeout(
this.muxer!.finalize(),
"muxer finalization",
@@ -395,7 +395,7 @@ export class VideoExporter {
"[VideoExporter] Browser AAC encoding is unavailable; falling back to FFmpeg audio muxing.",
);
const result = await this.finalizeExportWithFfmpegAudio(
blob,
muxerResult,
audioPlan,
totalFrames,
);
@@ -407,7 +407,14 @@ export class VideoExporter {
}
this.finalizationTimeMs = this.getNowMs() - finalizationStartedAt;
return { success: true, blob, metrics: this.buildExportMetrics() };
if (muxerResult.mode === "stream") {
return {
success: true,
tempFilePath: muxerResult.tempFilePath,
metrics: this.buildExportMetrics(),
};
}
return { success: true, blob: muxerResult.blob, metrics: this.buildExportMetrics() };
} catch (error) {
if (this.cancelled && !this.encoderError) {
return {
@@ -843,7 +850,7 @@ export class VideoExporter {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
if (!result.success || !result.data) {
if (!result.success || !result.tempPath) {
return {
success: false,
error: result.error || "Failed to finalize native video export",
@@ -851,22 +858,19 @@ export class VideoExporter {
};
}
const blobData = new Uint8Array(result.data.byteLength);
blobData.set(result.data);
return {
success: true,
blob: new Blob([blobData.buffer], { type: "video/mp4" }),
tempFilePath: result.tempPath,
metrics: this.buildExportMetrics(),
};
}
private async finalizeExportWithFfmpegAudio(
videoBlob: Blob,
videoSource: import("./muxer").MuxerFinalizeResult,
audioPlan: NativeAudioPlan,
totalFrames: number,
): Promise<ExportResult> {
if (typeof window === "undefined" || !window.electronAPI?.muxExportedVideoAudio) {
if (typeof window === "undefined") {
return {
success: false,
error: "FFmpeg audio fallback is unavailable in this environment.",
@@ -903,35 +907,72 @@ export class VideoExporter {
editedAudioMimeType = audioBlob.type || null;
}
const videoBuffer = await videoBlob.arrayBuffer();
const muxOptions = {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" ||
audioPlan.audioMode === "trim-source" ||
(audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path")
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedTrackStrategy:
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
editedTrackSegments:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.editedTrackSegments
: undefined,
audioSourceSampleRate:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.audioSourceSampleRate
: undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
};
if (videoSource.mode === "stream") {
if (!window.electronAPI?.muxExportedVideoAudioFromPath) {
return {
success: false,
error: "FFmpeg audio fallback via temp path is unavailable in this environment.",
};
}
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
this.awaitWithFinalizationTimeout(
window.electronAPI.muxExportedVideoAudioFromPath(
videoSource.tempFilePath,
muxOptions,
),
"ffmpeg audio muxing",
"audio",
),
);
if (result.metrics) {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
if (!result.success || !result.tempPath) {
return {
success: false,
error: result.error || "Failed to mux exported audio with FFmpeg",
};
}
return { success: true, tempFilePath: result.tempPath };
}
if (!window.electronAPI?.muxExportedVideoAudio) {
return {
success: false,
error: "FFmpeg audio fallback is unavailable in this environment.",
};
}
const videoBuffer = await videoSource.blob.arrayBuffer();
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
this.awaitWithFinalizationTimeout(
window.electronAPI.muxExportedVideoAudio(videoBuffer, {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" ||
audioPlan.audioMode === "trim-source" ||
(audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path")
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedTrackStrategy:
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
editedTrackSegments:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.editedTrackSegments
: undefined,
audioSourceSampleRate:
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? audioPlan.audioSourceSampleRate
: undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
window.electronAPI.muxExportedVideoAudio(videoBuffer, muxOptions),
"ffmpeg audio muxing",
"audio",
),