mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
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:
@@ -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 };
|
||||
|
||||
@@ -435,7 +435,7 @@ export class ModernVideoExporter {
|
||||
}
|
||||
const finishResult = await this.finishNativeVideoExport(nativeAudioPlan);
|
||||
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
|
||||
if (!finishResult.success || !finishResult.blob) {
|
||||
if (!finishResult.success || (!finishResult.tempFilePath && !finishResult.blob)) {
|
||||
return {
|
||||
success: false,
|
||||
error: finishResult.error || `${NATIVE_EXPORT_ENGINE_NAME} export failed`,
|
||||
@@ -445,6 +445,7 @@ export class ModernVideoExporter {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tempFilePath: finishResult.tempFilePath,
|
||||
blob: finishResult.blob,
|
||||
metrics: this.buildExportMetrics(),
|
||||
};
|
||||
@@ -509,7 +510,7 @@ export class ModernVideoExporter {
|
||||
}
|
||||
|
||||
this.reportFinalizingProgress(totalFrames, 99);
|
||||
const blob = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
|
||||
const muxerResult = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
|
||||
this.awaitWithFinalizationTimeout(
|
||||
this.muxer!.finalize(),
|
||||
"muxer finalization",
|
||||
@@ -523,9 +524,12 @@ export class ModernVideoExporter {
|
||||
console.warn(
|
||||
`[VideoExporter] Browser AAC encoding is unavailable; falling back to FFmpeg audio muxing.`,
|
||||
);
|
||||
const muxedResult = await this.finalizeExportWithFfmpegAudio(blob, nativeAudioPlan);
|
||||
const muxedResult = await this.finalizeExportWithFfmpegAudio(
|
||||
muxerResult,
|
||||
nativeAudioPlan,
|
||||
);
|
||||
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
|
||||
if (!muxedResult.success || !muxedResult.blob) {
|
||||
if (!muxedResult.success || (!muxedResult.blob && !muxedResult.tempFilePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: muxedResult.error || "Failed to mux audio with FFmpeg",
|
||||
@@ -536,12 +540,24 @@ export class ModernVideoExporter {
|
||||
return {
|
||||
success: true,
|
||||
blob: muxedResult.blob,
|
||||
metrics: this.buildExportMetrics(),
|
||||
tempFilePath: muxedResult.tempFilePath,
|
||||
metrics: muxedResult.metrics ?? this.buildExportMetrics(),
|
||||
};
|
||||
}
|
||||
|
||||
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
|
||||
return { success: true, blob, metrics: this.buildExportMetrics() };
|
||||
if (muxerResult.mode === "stream") {
|
||||
return {
|
||||
success: true,
|
||||
tempFilePath: muxerResult.tempFilePath,
|
||||
metrics: this.buildExportMetrics(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
blob: muxerResult.blob,
|
||||
metrics: this.buildExportMetrics(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (this.cancelled && !this.encoderError) {
|
||||
return {
|
||||
@@ -1100,26 +1116,24 @@ export class ModernVideoExporter {
|
||||
}
|
||||
|
||||
this.encoderName = result.encoderName ?? this.encoderName;
|
||||
if (!result.data) {
|
||||
if (!result.tempPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${NATIVE_EXPORT_ENGINE_NAME} export did not return video data`,
|
||||
error: `${NATIVE_EXPORT_ENGINE_NAME} export did not return a temp path`,
|
||||
};
|
||||
}
|
||||
|
||||
const videoBytes = result.data.slice();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
blob: new Blob([videoBytes.buffer], { type: "video/mp4" }),
|
||||
tempFilePath: result.tempPath,
|
||||
};
|
||||
}
|
||||
|
||||
private async finalizeExportWithFfmpegAudio(
|
||||
videoBlob: Blob,
|
||||
videoSource: import("./muxer").MuxerFinalizeResult,
|
||||
audioPlan: NativeAudioPlan,
|
||||
): Promise<ExportResult> {
|
||||
if (typeof window === "undefined" || !window.electronAPI?.muxExportedVideoAudio) {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
success: false,
|
||||
error: "FFmpeg audio fallback is unavailable in this environment.",
|
||||
@@ -1156,35 +1170,72 @@ export class ModernVideoExporter {
|
||||
editedAudioMimeType = audioBlob.type || null;
|
||||
}
|
||||
|
||||
const videoBuffer = await videoBlob.arrayBuffer();
|
||||
const muxOptions = {
|
||||
audioMode: audioPlan.audioMode,
|
||||
audioSourcePath:
|
||||
audioPlan.audioMode === "copy-source" ||
|
||||
audioPlan.audioMode === "trim-source" ||
|
||||
(audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path")
|
||||
? audioPlan.audioSourcePath
|
||||
: null,
|
||||
trimSegments:
|
||||
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
|
||||
editedTrackStrategy:
|
||||
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
|
||||
editedTrackSegments:
|
||||
audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path"
|
||||
? audioPlan.editedTrackSegments
|
||||
: undefined,
|
||||
audioSourceSampleRate:
|
||||
audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path"
|
||||
? audioPlan.audioSourceSampleRate
|
||||
: undefined,
|
||||
editedAudioData: editedAudioBuffer,
|
||||
editedAudioMimeType,
|
||||
};
|
||||
|
||||
if (videoSource.mode === "stream") {
|
||||
if (!window.electronAPI?.muxExportedVideoAudioFromPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: "FFmpeg audio fallback via temp path is unavailable in this environment.",
|
||||
};
|
||||
}
|
||||
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
|
||||
this.awaitWithFinalizationTimeout(
|
||||
window.electronAPI.muxExportedVideoAudioFromPath(
|
||||
videoSource.tempFilePath,
|
||||
muxOptions,
|
||||
),
|
||||
"FFmpeg audio muxing",
|
||||
"audio",
|
||||
),
|
||||
);
|
||||
if (result.metrics) {
|
||||
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
|
||||
}
|
||||
if (!result.success || !result.tempPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || "Failed to mux exported audio with FFmpeg",
|
||||
};
|
||||
}
|
||||
return { success: true, tempFilePath: result.tempPath };
|
||||
}
|
||||
|
||||
if (!window.electronAPI?.muxExportedVideoAudio) {
|
||||
return {
|
||||
success: false,
|
||||
error: "FFmpeg audio fallback is unavailable in this environment.",
|
||||
};
|
||||
}
|
||||
const videoBuffer = await videoSource.blob.arrayBuffer();
|
||||
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
|
||||
this.awaitWithFinalizationTimeout(
|
||||
window.electronAPI.muxExportedVideoAudio(videoBuffer, {
|
||||
audioMode: audioPlan.audioMode,
|
||||
audioSourcePath:
|
||||
audioPlan.audioMode === "copy-source" ||
|
||||
audioPlan.audioMode === "trim-source" ||
|
||||
(audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path")
|
||||
? audioPlan.audioSourcePath
|
||||
: null,
|
||||
trimSegments:
|
||||
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
|
||||
editedTrackStrategy:
|
||||
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
|
||||
editedTrackSegments:
|
||||
audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path"
|
||||
? audioPlan.editedTrackSegments
|
||||
: undefined,
|
||||
audioSourceSampleRate:
|
||||
audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path"
|
||||
? audioPlan.audioSourceSampleRate
|
||||
: undefined,
|
||||
editedAudioData: editedAudioBuffer,
|
||||
editedAudioMimeType,
|
||||
}),
|
||||
window.electronAPI.muxExportedVideoAudio(videoBuffer, muxOptions),
|
||||
"FFmpeg audio muxing",
|
||||
"audio",
|
||||
),
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { type MuxerTargetMode, VideoMuxer } from "./muxer";
|
||||
import type { ExportConfig } from "./types";
|
||||
|
||||
vi.mock("mediabunny", () => {
|
||||
class FakeBufferTarget {
|
||||
onwrite: ((start: number, end: number) => unknown) | null = null;
|
||||
buffer: ArrayBuffer | null = null;
|
||||
}
|
||||
class FakeStreamTarget {
|
||||
onwrite: ((start: number, end: number) => unknown) | null = null;
|
||||
readonly writable: WritableStream<{
|
||||
type: "write";
|
||||
data: Uint8Array;
|
||||
position: number;
|
||||
}>;
|
||||
constructor(
|
||||
writable: WritableStream<{ type: "write"; data: Uint8Array; position: number }>,
|
||||
) {
|
||||
this.writable = writable;
|
||||
}
|
||||
}
|
||||
|
||||
const addedVideoTracks: unknown[] = [];
|
||||
const addedAudioTracks: unknown[] = [];
|
||||
const startedOutputs: unknown[] = [];
|
||||
|
||||
class FakeOutput {
|
||||
readonly target: FakeBufferTarget | FakeStreamTarget;
|
||||
constructor(options: {
|
||||
format: unknown;
|
||||
target: FakeBufferTarget | FakeStreamTarget;
|
||||
}) {
|
||||
this.target = options.target;
|
||||
}
|
||||
addVideoTrack(source: unknown, opts: unknown) {
|
||||
addedVideoTracks.push({ source, opts });
|
||||
}
|
||||
addAudioTrack(source: unknown) {
|
||||
addedAudioTracks.push(source);
|
||||
}
|
||||
async start() {
|
||||
startedOutputs.push(this);
|
||||
}
|
||||
async finalize() {
|
||||
if (this.target instanceof FakeBufferTarget) {
|
||||
this.target.buffer = new ArrayBuffer(4);
|
||||
return;
|
||||
}
|
||||
const writer = (this.target as FakeStreamTarget).writable.getWriter();
|
||||
await writer.write({
|
||||
type: "write",
|
||||
data: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]),
|
||||
position: 0,
|
||||
});
|
||||
await writer.close();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMp4OutputFormat {
|
||||
constructor(public options: unknown) {}
|
||||
}
|
||||
|
||||
class FakeEncodedVideoPacketSource {
|
||||
constructor(public codec: string) {}
|
||||
async add() {}
|
||||
}
|
||||
|
||||
class FakeEncodedAudioPacketSource {
|
||||
constructor(public codec: string) {}
|
||||
async add() {}
|
||||
}
|
||||
|
||||
const EncodedPacket = {
|
||||
fromEncodedChunk: (chunk: unknown) => ({ chunk }),
|
||||
};
|
||||
|
||||
return {
|
||||
BufferTarget: FakeBufferTarget,
|
||||
StreamTarget: FakeStreamTarget,
|
||||
Output: FakeOutput,
|
||||
Mp4OutputFormat: FakeMp4OutputFormat,
|
||||
EncodedVideoPacketSource: FakeEncodedVideoPacketSource,
|
||||
EncodedAudioPacketSource: FakeEncodedAudioPacketSource,
|
||||
EncodedPacket,
|
||||
};
|
||||
});
|
||||
|
||||
const baseConfig: ExportConfig = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 30,
|
||||
bitrate: 10_000_000,
|
||||
};
|
||||
|
||||
describe("VideoMuxer target selection", () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
afterEach(() => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: restoring the test globals.
|
||||
(globalThis as any).window = originalWindow;
|
||||
});
|
||||
|
||||
it("defaults to BufferTarget when no electronAPI stream IPC is available", async () => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: isolating the muxer from Electron.
|
||||
(globalThis as any).window = undefined;
|
||||
const muxer = new VideoMuxer(baseConfig);
|
||||
expect(muxer.getTargetMode()).toBe<MuxerTargetMode>("buffer");
|
||||
await muxer.initialize();
|
||||
const result = await muxer.finalize();
|
||||
expect(result.mode).toBe("buffer");
|
||||
if (result.mode === "buffer") {
|
||||
expect(result.blob.type).toBe("video/mp4");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses StreamTarget and routes chunks through the renderer IPC when available", async () => {
|
||||
const chunkCalls: Array<{
|
||||
streamId: string;
|
||||
position: number;
|
||||
bytes: number;
|
||||
}> = [];
|
||||
const fakeApi = {
|
||||
openExportStream: vi.fn(async () => ({
|
||||
success: true,
|
||||
streamId: "stream-1",
|
||||
tempPath: "/tmp/muxer-stream.mp4",
|
||||
})),
|
||||
writeExportStreamChunk: vi.fn(
|
||||
async (streamId: string, position: number, chunk: Uint8Array) => {
|
||||
chunkCalls.push({ streamId, position, bytes: chunk.byteLength });
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
closeExportStream: vi.fn(async (streamId: string) => ({
|
||||
success: true,
|
||||
tempPath: "/tmp/muxer-stream.mp4",
|
||||
bytesWritten: 8,
|
||||
})),
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocking the Electron bridge.
|
||||
(globalThis as any).window = { electronAPI: fakeApi } as any;
|
||||
|
||||
const muxer = new VideoMuxer(baseConfig);
|
||||
expect(muxer.getTargetMode()).toBe<MuxerTargetMode>("stream");
|
||||
|
||||
await muxer.initialize();
|
||||
const result = await muxer.finalize();
|
||||
|
||||
expect(fakeApi.openExportStream).toHaveBeenCalledTimes(1);
|
||||
expect(fakeApi.writeExportStreamChunk).toHaveBeenCalledTimes(1);
|
||||
expect(fakeApi.closeExportStream).toHaveBeenCalledWith("stream-1", undefined);
|
||||
expect(chunkCalls).toEqual([{ streamId: "stream-1", position: 0, bytes: 8 }]);
|
||||
expect(result.mode).toBe("stream");
|
||||
if (result.mode === "stream") {
|
||||
expect(result.tempFilePath).toBe("/tmp/muxer-stream.mp4");
|
||||
expect(result.bytesWritten).toBe(8);
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts the stream session when the muxer is destroyed mid-flight", async () => {
|
||||
const closeSpy = vi.fn(async () => ({
|
||||
success: true,
|
||||
tempPath: "/tmp/abort.mp4",
|
||||
bytesWritten: 0,
|
||||
}));
|
||||
const fakeApi = {
|
||||
openExportStream: vi.fn(async () => ({
|
||||
success: true,
|
||||
streamId: "stream-abort",
|
||||
tempPath: "/tmp/abort.mp4",
|
||||
})),
|
||||
writeExportStreamChunk: vi.fn(async () => ({ success: true })),
|
||||
closeExportStream: closeSpy,
|
||||
};
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocking the Electron bridge.
|
||||
(globalThis as any).window = { electronAPI: fakeApi } as any;
|
||||
|
||||
const muxer = new VideoMuxer(baseConfig);
|
||||
await muxer.initialize();
|
||||
await muxer.abortStream();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledWith("stream-abort", { abort: true });
|
||||
});
|
||||
});
|
||||
+137
-16
@@ -3,27 +3,124 @@ import {
|
||||
EncodedAudioPacketSource,
|
||||
EncodedPacket,
|
||||
EncodedVideoPacketSource,
|
||||
type Target as MediabunnyTarget,
|
||||
Mp4OutputFormat,
|
||||
Output,
|
||||
StreamTarget,
|
||||
} from "mediabunny";
|
||||
import type { ExportConfig } from "./types";
|
||||
|
||||
/**
|
||||
* Chunk boundary used by both the mediabunny StreamTarget and the IPC writer.
|
||||
* 16 MiB is well below Electron's IPC size limits and keeps per-chunk overhead
|
||||
* low — large enough that a 35-minute 1080p export only produces a few hundred
|
||||
* writes across the renderer/main boundary.
|
||||
*/
|
||||
const EXPORT_STREAM_CHUNK_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
type IpcStreamSink = {
|
||||
readonly streamId: string;
|
||||
readonly tempPath: string;
|
||||
};
|
||||
|
||||
async function openIpcExportStream(): Promise<IpcStreamSink> {
|
||||
if (typeof window === "undefined" || !window.electronAPI?.openExportStream) {
|
||||
throw new Error("openExportStream IPC is unavailable in this environment");
|
||||
}
|
||||
const result = await window.electronAPI.openExportStream({ extension: "mp4" });
|
||||
if (!result.success || !result.streamId || !result.tempPath) {
|
||||
throw new Error(result.error || "Failed to open export stream");
|
||||
}
|
||||
return { streamId: result.streamId, tempPath: result.tempPath };
|
||||
}
|
||||
|
||||
async function writeIpcExportStream(
|
||||
streamId: string,
|
||||
position: number,
|
||||
chunk: Uint8Array,
|
||||
): Promise<void> {
|
||||
// Mediabunny owns the underlying buffer for the lifetime of the call, so we
|
||||
// copy the bytes before crossing the IPC boundary — ipcRenderer.invoke uses
|
||||
// a structured clone under the hood and the original buffer can be reused
|
||||
// by the muxer immediately after await returns.
|
||||
const copy = new Uint8Array(chunk.byteLength);
|
||||
copy.set(chunk);
|
||||
const result = await window.electronAPI!.writeExportStreamChunk(streamId, position, copy);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to write export chunk");
|
||||
}
|
||||
}
|
||||
|
||||
async function closeIpcExportStream(
|
||||
streamId: string,
|
||||
options?: { abort?: boolean },
|
||||
): Promise<{ tempPath: string; bytesWritten: number }> {
|
||||
const result = await window.electronAPI!.closeExportStream(streamId, options);
|
||||
if (!result.success || !result.tempPath) {
|
||||
throw new Error(result.error || "Failed to close export stream");
|
||||
}
|
||||
return { tempPath: result.tempPath, bytesWritten: result.bytesWritten ?? 0 };
|
||||
}
|
||||
|
||||
export type MuxerTargetMode = "stream" | "buffer";
|
||||
|
||||
export type MuxerFinalizeResult =
|
||||
| { mode: "stream"; tempFilePath: string; bytesWritten: number }
|
||||
| { mode: "buffer"; blob: Blob };
|
||||
|
||||
function shouldUseStreamTarget(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.electronAPI?.openExportStream === "function" &&
|
||||
typeof window.electronAPI?.writeExportStreamChunk === "function" &&
|
||||
typeof window.electronAPI?.closeExportStream === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export class VideoMuxer {
|
||||
private output: Output | null = null;
|
||||
private videoSource: EncodedVideoPacketSource | null = null;
|
||||
private audioSource: EncodedAudioPacketSource | null = null;
|
||||
private hasAudio: boolean;
|
||||
private target: BufferTarget | null = null;
|
||||
private target: MediabunnyTarget | null = null;
|
||||
private config: ExportConfig;
|
||||
private mode: MuxerTargetMode;
|
||||
private streamSink: IpcStreamSink | null = null;
|
||||
|
||||
constructor(config: ExportConfig, hasAudio = false) {
|
||||
constructor(config: ExportConfig, hasAudio = false, mode?: MuxerTargetMode) {
|
||||
this.config = config;
|
||||
this.hasAudio = hasAudio;
|
||||
this.mode = mode ?? (shouldUseStreamTarget() ? "stream" : "buffer");
|
||||
}
|
||||
|
||||
getTargetMode(): MuxerTargetMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Create the buffer target
|
||||
this.target = new BufferTarget();
|
||||
if (this.mode === "stream") {
|
||||
const sink = await openIpcExportStream();
|
||||
this.streamSink = sink;
|
||||
const streamId = sink.streamId;
|
||||
const writableStream = new WritableStream<{
|
||||
type: "write";
|
||||
data: Uint8Array;
|
||||
position: number;
|
||||
}>({
|
||||
async write(chunk) {
|
||||
if (chunk.type !== "write") {
|
||||
return;
|
||||
}
|
||||
await writeIpcExportStream(streamId, chunk.position, chunk.data);
|
||||
},
|
||||
});
|
||||
this.target = new StreamTarget(writableStream, {
|
||||
chunked: true,
|
||||
chunkSize: EXPORT_STREAM_CHUNK_BYTES,
|
||||
});
|
||||
} else {
|
||||
this.target = new BufferTarget();
|
||||
}
|
||||
|
||||
this.output = new Output({
|
||||
format: new Mp4OutputFormat({
|
||||
@@ -32,19 +129,16 @@ export class VideoMuxer {
|
||||
target: this.target,
|
||||
});
|
||||
|
||||
// Create video source - codec will be deduced from metadata
|
||||
this.videoSource = new EncodedVideoPacketSource("avc");
|
||||
this.output.addVideoTrack(this.videoSource, {
|
||||
frameRate: this.config.frameRate,
|
||||
});
|
||||
|
||||
// Create audio source if needed
|
||||
if (this.hasAudio) {
|
||||
this.audioSource = new EncodedAudioPacketSource("aac");
|
||||
this.output.addAudioTrack(this.audioSource);
|
||||
}
|
||||
|
||||
// Start the output to begin accepting media data
|
||||
await this.output.start();
|
||||
}
|
||||
|
||||
@@ -53,10 +147,7 @@ export class VideoMuxer {
|
||||
throw new Error("Muxer not initialized");
|
||||
}
|
||||
|
||||
// Convert WebCodecs chunk to Mediabunny packet
|
||||
const packet = EncodedPacket.fromEncodedChunk(chunk);
|
||||
|
||||
// Add metadata with the first chunk
|
||||
await this.videoSource.add(packet, meta);
|
||||
}
|
||||
|
||||
@@ -65,26 +156,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,18 @@ export interface ExportMetrics {
|
||||
|
||||
export interface ExportResult {
|
||||
success: boolean;
|
||||
/**
|
||||
* Absolute path to a main-process temp file containing the finished export.
|
||||
* Preferred for MP4 output because it avoids loading multi-gigabyte files
|
||||
* into the renderer's ArrayBuffer heap. The renderer should move the temp
|
||||
* file to its final destination via `finalize-exported-video`.
|
||||
*/
|
||||
tempFilePath?: string;
|
||||
/**
|
||||
* In-renderer Blob for exports that fit in memory (GIF, smoke tests, legacy
|
||||
* fallback). Mutually exclusive with `tempFilePath` — consumers should
|
||||
* prefer the temp path when both are set.
|
||||
*/
|
||||
blob?: Blob;
|
||||
filePath?: string;
|
||||
error?: string;
|
||||
|
||||
@@ -380,9 +380,9 @@ export class VideoExporter {
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize muxer and get output blob
|
||||
// Finalize muxer and get output (temp path for streaming, blob for legacy)
|
||||
this.reportFinalizingProgress(totalFrames, 99);
|
||||
const blob = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
|
||||
const muxerResult = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
|
||||
this.awaitWithFinalizationTimeout(
|
||||
this.muxer!.finalize(),
|
||||
"muxer finalization",
|
||||
@@ -395,7 +395,7 @@ export class VideoExporter {
|
||||
"[VideoExporter] Browser AAC encoding is unavailable; falling back to FFmpeg audio muxing.",
|
||||
);
|
||||
const result = await this.finalizeExportWithFfmpegAudio(
|
||||
blob,
|
||||
muxerResult,
|
||||
audioPlan,
|
||||
totalFrames,
|
||||
);
|
||||
@@ -407,7 +407,14 @@ export class VideoExporter {
|
||||
}
|
||||
|
||||
this.finalizationTimeMs = this.getNowMs() - finalizationStartedAt;
|
||||
return { success: true, blob, metrics: this.buildExportMetrics() };
|
||||
if (muxerResult.mode === "stream") {
|
||||
return {
|
||||
success: true,
|
||||
tempFilePath: muxerResult.tempFilePath,
|
||||
metrics: this.buildExportMetrics(),
|
||||
};
|
||||
}
|
||||
return { success: true, blob: muxerResult.blob, metrics: this.buildExportMetrics() };
|
||||
} catch (error) {
|
||||
if (this.cancelled && !this.encoderError) {
|
||||
return {
|
||||
@@ -843,7 +850,7 @@ export class VideoExporter {
|
||||
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
|
||||
}
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
if (!result.success || !result.tempPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || "Failed to finalize native video export",
|
||||
@@ -851,22 +858,19 @@ export class VideoExporter {
|
||||
};
|
||||
}
|
||||
|
||||
const blobData = new Uint8Array(result.data.byteLength);
|
||||
blobData.set(result.data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
blob: new Blob([blobData.buffer], { type: "video/mp4" }),
|
||||
tempFilePath: result.tempPath,
|
||||
metrics: this.buildExportMetrics(),
|
||||
};
|
||||
}
|
||||
|
||||
private async finalizeExportWithFfmpegAudio(
|
||||
videoBlob: Blob,
|
||||
videoSource: import("./muxer").MuxerFinalizeResult,
|
||||
audioPlan: NativeAudioPlan,
|
||||
totalFrames: number,
|
||||
): Promise<ExportResult> {
|
||||
if (typeof window === "undefined" || !window.electronAPI?.muxExportedVideoAudio) {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
success: false,
|
||||
error: "FFmpeg audio fallback is unavailable in this environment.",
|
||||
@@ -903,35 +907,72 @@ export class VideoExporter {
|
||||
editedAudioMimeType = audioBlob.type || null;
|
||||
}
|
||||
|
||||
const videoBuffer = await videoBlob.arrayBuffer();
|
||||
const muxOptions = {
|
||||
audioMode: audioPlan.audioMode,
|
||||
audioSourcePath:
|
||||
audioPlan.audioMode === "copy-source" ||
|
||||
audioPlan.audioMode === "trim-source" ||
|
||||
(audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path")
|
||||
? audioPlan.audioSourcePath
|
||||
: null,
|
||||
trimSegments:
|
||||
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
|
||||
editedTrackStrategy:
|
||||
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
|
||||
editedTrackSegments:
|
||||
audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path"
|
||||
? audioPlan.editedTrackSegments
|
||||
: undefined,
|
||||
audioSourceSampleRate:
|
||||
audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path"
|
||||
? audioPlan.audioSourceSampleRate
|
||||
: undefined,
|
||||
editedAudioData: editedAudioBuffer,
|
||||
editedAudioMimeType,
|
||||
};
|
||||
|
||||
if (videoSource.mode === "stream") {
|
||||
if (!window.electronAPI?.muxExportedVideoAudioFromPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: "FFmpeg audio fallback via temp path is unavailable in this environment.",
|
||||
};
|
||||
}
|
||||
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
|
||||
this.awaitWithFinalizationTimeout(
|
||||
window.electronAPI.muxExportedVideoAudioFromPath(
|
||||
videoSource.tempFilePath,
|
||||
muxOptions,
|
||||
),
|
||||
"ffmpeg audio muxing",
|
||||
"audio",
|
||||
),
|
||||
);
|
||||
if (result.metrics) {
|
||||
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
|
||||
}
|
||||
if (!result.success || !result.tempPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || "Failed to mux exported audio with FFmpeg",
|
||||
};
|
||||
}
|
||||
return { success: true, tempFilePath: result.tempPath };
|
||||
}
|
||||
|
||||
if (!window.electronAPI?.muxExportedVideoAudio) {
|
||||
return {
|
||||
success: false,
|
||||
error: "FFmpeg audio fallback is unavailable in this environment.",
|
||||
};
|
||||
}
|
||||
const videoBuffer = await videoSource.blob.arrayBuffer();
|
||||
const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () =>
|
||||
this.awaitWithFinalizationTimeout(
|
||||
window.electronAPI.muxExportedVideoAudio(videoBuffer, {
|
||||
audioMode: audioPlan.audioMode,
|
||||
audioSourcePath:
|
||||
audioPlan.audioMode === "copy-source" ||
|
||||
audioPlan.audioMode === "trim-source" ||
|
||||
(audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path")
|
||||
? audioPlan.audioSourcePath
|
||||
: null,
|
||||
trimSegments:
|
||||
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
|
||||
editedTrackStrategy:
|
||||
audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined,
|
||||
editedTrackSegments:
|
||||
audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path"
|
||||
? audioPlan.editedTrackSegments
|
||||
: undefined,
|
||||
audioSourceSampleRate:
|
||||
audioPlan.audioMode === "edited-track" &&
|
||||
audioPlan.strategy === "filtergraph-fast-path"
|
||||
? audioPlan.audioSourceSampleRate
|
||||
: undefined,
|
||||
editedAudioData: editedAudioBuffer,
|
||||
editedAudioMimeType,
|
||||
}),
|
||||
window.electronAPI.muxExportedVideoAudio(videoBuffer, muxOptions),
|
||||
"ffmpeg audio muxing",
|
||||
"audio",
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user