Merge pull request #284 from meiiie/research/export-pipeline-profiling

chore(export): expand finalization profiling
This commit is contained in:
webadderall
2026-04-21 18:26:03 +10:00
committed by GitHub
8 changed files with 829 additions and 530 deletions
+15 -4
View File
@@ -1,6 +1,5 @@
/// <reference types="vite-plugin-electron/electron-env" />
// biome-ignore lint/style/noNamespace: NodeJS.ProcessEnv augmentation requires a namespace declaration.
declare namespace NodeJS {
interface ProcessEnv {
/**
@@ -69,6 +68,16 @@ type RendererMarketplaceReviewStatus =
type RendererMarketplaceSearchResult =
import("./extensions/extensionTypes").MarketplaceSearchResult;
interface RendererFfmpegAudioMuxMetrics {
tempVideoWriteMs?: number;
tempEditedAudioWriteMs?: number;
ffmpegExecMs?: number;
muxedVideoReadMs?: number;
tempVideoBytes?: number;
tempEditedAudioBytes?: number;
muxedVideoBytes?: number;
}
interface Window {
electronAPI: {
hudOverlaySetIgnoreMouse: (ignore: boolean) => void;
@@ -194,6 +203,7 @@ interface Window {
data?: Uint8Array;
encoderName?: string;
error?: string;
metrics?: RendererFfmpegAudioMuxMetrics;
}>;
nativeVideoExportCancel: (
sessionId: string,
@@ -211,6 +221,7 @@ interface Window {
success: boolean;
data?: Uint8Array;
error?: string;
metrics?: RendererFfmpegAudioMuxMetrics;
}>;
getVideoAudioFallbackPaths: (
videoPath: string,
@@ -330,9 +341,9 @@ interface Window {
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
deleteRecordingFile: (filePath: string) => Promise<{ success: boolean; error?: string }>;
getLocalMediaUrl: (filePath: string) => Promise<
{ success: true; url: string } | { success: false }
>;
getLocalMediaUrl: (
filePath: string,
) => Promise<{ success: true; url: string } | { success: false }>;
saveProjectFile: (
projectData: unknown,
suggestedName?: string,
+57 -12
View File
@@ -2,16 +2,29 @@ import type { ChildProcessByStdio } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { performance } from "node:perf_hooks";
import type { Readable, Writable } from "node:stream";
import { app } from "electron";
import { promisify } from "node:util";
import type { WebContents } from "electron";
import { app } from "electron";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import { buildTrimmedSourceAudioFilter, getEditedAudioExtension, getNativeVideoInputByteSize, getPreferredNativeVideoEncoders, buildNativeVideoExportArgs, parseAvailableFfmpegEncoders } from "../nativeVideoExport";
import type { NativeExportEncodingMode, NativeVideoExportFinishOptions } from "../nativeVideoExport";
import type {
NativeExportEncodingMode,
NativeVideoAudioMuxMetrics,
NativeVideoExportFinishOptions,
} from "../nativeVideoExport";
import {
buildNativeVideoExportArgs,
buildTrimmedSourceAudioFilter,
getEditedAudioExtension,
getNativeVideoInputByteSize,
getPreferredNativeVideoEncoders,
parseAvailableFfmpegEncoders,
} from "../nativeVideoExport";
import { cachedNativeVideoEncoder, setCachedNativeVideoEncoder } from "../state";
const execFileAsync = promisify(execFile);
const getNowMs = () => performance.now();
export type NativeVideoExportSession = {
ffmpegProcess: ChildProcessByStdio<Writable, null, Readable>;
@@ -72,7 +85,10 @@ export async function removeTemporaryExportFile(filePath: string | null | undefi
}
}
export function getNativeVideoExportSessionError(session: NativeVideoExportSession, fallback: string) {
export function getNativeVideoExportSessionError(
session: NativeVideoExportSession,
fallback: string,
) {
return (
session.stdinError?.message ||
session.processError?.message ||
@@ -199,7 +215,10 @@ export async function writeNativeVideoExportFrame(
session: NativeVideoExportSession,
frameData: Uint8Array | ArrayBuffer,
) {
if (session.inputMode !== "h264-stream" && getNativeVideoExportFrameLength(frameData) !== session.inputByteSize) {
if (
session.inputMode !== "h264-stream" &&
getNativeVideoExportFrameLength(frameData) !== session.inputByteSize
) {
throw new Error(
`Native video export expected ${session.inputByteSize} bytes per frame but received ${getNativeVideoExportFrameLength(frameData)}`,
);
@@ -358,10 +377,14 @@ export async function muxNativeVideoExportAudio(
) {
const audioMode = options.audioMode ?? "none";
if (audioMode === "none") {
return videoPath;
return {
outputPath: videoPath,
metrics: {} as NativeVideoAudioMuxMetrics,
};
}
const ffmpegPath = getFfmpegBinaryPath();
const metrics: NativeVideoAudioMuxMetrics = {};
const tempArtifacts: string[] = [];
let audioInputPath = options.audioSourcePath ?? null;
@@ -375,12 +398,18 @@ export async function muxNativeVideoExportAudio(
app.getPath("temp"),
`recordly-export-audio-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${extension}`,
);
const tempAudioWriteStartedAt = getNowMs();
await fs.writeFile(audioInputPath, Buffer.from(options.editedAudioData));
metrics.tempEditedAudioWriteMs = getNowMs() - tempAudioWriteStartedAt;
metrics.tempEditedAudioBytes = options.editedAudioData.byteLength;
tempArtifacts.push(audioInputPath);
}
if (!audioInputPath) {
return videoPath;
return {
outputPath: videoPath,
metrics,
};
}
const outputPath = path.join(
@@ -424,12 +453,17 @@ export async function muxNativeVideoExportAudio(
);
try {
const ffmpegExecStartedAt = getNowMs();
await execFileAsync(ffmpegPath, args, {
timeout: 15 * 60 * 1000,
maxBuffer: 20 * 1024 * 1024,
});
metrics.ffmpegExecMs = getNowMs() - ffmpegExecStartedAt;
await removeTemporaryExportFile(videoPath);
return outputPath;
return {
outputPath,
metrics,
};
} finally {
await Promise.allSettled(
tempArtifacts.map((artifactPath) => removeTemporaryExportFile(artifactPath)),
@@ -445,12 +479,23 @@ export async function muxExportedVideoAudioBuffer(
app.getPath("temp"),
`recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
);
const metrics: NativeVideoAudioMuxMetrics = {};
try {
const tempVideoWriteStartedAt = getNowMs();
await fs.writeFile(tempVideoPath, Buffer.from(videoData));
const finalizedPath = await muxNativeVideoExportAudio(tempVideoPath, options);
const muxedData = await fs.readFile(finalizedPath);
return new Uint8Array(muxedData);
metrics.tempVideoWriteMs = getNowMs() - tempVideoWriteStartedAt;
metrics.tempVideoBytes = videoData.byteLength;
const finalized = await muxNativeVideoExportAudio(tempVideoPath, options);
Object.assign(metrics, finalized.metrics);
const muxedVideoReadStartedAt = getNowMs();
const muxedData = await fs.readFile(finalized.outputPath);
metrics.muxedVideoReadMs = getNowMs() - muxedVideoReadStartedAt;
metrics.muxedVideoBytes = muxedData.byteLength;
return {
data: new Uint8Array(muxedData),
metrics,
};
} finally {
await Promise.allSettled([
removeTemporaryExportFile(tempVideoPath),
+31 -16
View File
@@ -26,6 +26,16 @@ export interface NativeVideoExportFinishOptions {
editedAudioMimeType?: string | null;
}
export interface NativeVideoAudioMuxMetrics {
tempVideoWriteMs?: number;
tempEditedAudioWriteMs?: number;
ffmpegExecMs?: number;
muxedVideoReadMs?: number;
tempVideoBytes?: number;
tempEditedAudioBytes?: number;
muxedVideoBytes?: number;
}
export function getNativeVideoInputByteSize(width: number, height: number): number {
return width * height * NATIVE_EXPORT_INPUT_BYTES_PER_PIXEL;
}
@@ -159,23 +169,28 @@ export function buildTrimmedSourceAudioFilter(
* — no re-encoding step, no raw pixel IPC traffic.
*/
export function buildNativeH264StreamExportArgs(config: {
frameRate: number
outputPath: string
frameRate: number;
outputPath: string;
}): string[] {
return [
'-y',
'-hide_banner',
'-loglevel',
'error',
// Input 0: pre-encoded H.264 Annex B stream from browser VideoEncoder via stdin
'-f', 'h264',
'-r', String(config.frameRate),
'-i', 'pipe:0',
'-an', // audio handled separately by muxNativeVideoExportAudio
'-c:v', 'copy',
'-movflags', '+faststart',
config.outputPath,
]
return [
"-y",
"-hide_banner",
"-loglevel",
"error",
// Input 0: pre-encoded H.264 Annex B stream from browser VideoEncoder via stdin
"-f",
"h264",
"-r",
String(config.frameRate),
"-i",
"pipe:0",
"-an", // audio handled separately by muxNativeVideoExportAudio
"-c:v",
"copy",
"-movflags",
"+faststart",
config.outputPath,
];
}
export function getEditedAudioExtension(mimeType?: string | null): string {
+368 -330
View File
@@ -2,9 +2,26 @@ 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 {
enqueueNativeVideoExportFrameWrite,
flushNativeVideoExportPendingWriteRequests,
getNativeVideoExportMaxQueuedWriteBytes,
getNativeVideoExportSessionError,
isHardwareAcceleratedVideoEncoder,
isIgnorableNativeVideoExportStreamError,
muxExportedVideoAudioBuffer,
muxNativeVideoExportAudio,
type NativeVideoExportSession,
nativeVideoExportSessions,
removeTemporaryExportFile,
resolveNativeVideoEncoder,
sendNativeVideoExportWriteFrameResult,
settleNativeVideoExportWriteFrameRequest,
} from "../export/native-video";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import {
buildNativeH264StreamExportArgs,
@@ -13,374 +30,395 @@ import {
type NativeExportEncodingMode,
type NativeVideoExportFinishOptions,
} from "../nativeVideoExport";
import {
nativeVideoExportSessions,
getNativeVideoExportMaxQueuedWriteBytes,
isHardwareAcceleratedVideoEncoder,
removeTemporaryExportFile,
getNativeVideoExportSessionError,
sendNativeVideoExportWriteFrameResult,
settleNativeVideoExportWriteFrameRequest,
flushNativeVideoExportPendingWriteRequests,
isIgnorableNativeVideoExportStreamError,
enqueueNativeVideoExportFrameWrite,
resolveNativeVideoEncoder,
muxNativeVideoExportAudio,
muxExportedVideoAudioBuffer,
type NativeVideoExportSession,
} from "../export/native-video";
import { approveUserPath } from "../utils";
export function registerExportHandlers() {
ipcMain.handle(
'native-video-export-start',
async (
event,
options: {
width: number
height: number
frameRate: number
bitrate: number
encodingMode: NativeExportEncodingMode
inputMode?: 'rawvideo' | 'h264-stream'
},
) => {
try {
if (options.width % 2 !== 0 || options.height % 2 !== 0) {
throw new Error('Native export requires even output dimensions')
}
ipcMain.handle(
"native-video-export-start",
async (
event,
options: {
width: number;
height: number;
frameRate: number;
bitrate: number;
encodingMode: NativeExportEncodingMode;
inputMode?: "rawvideo" | "h264-stream";
},
) => {
try {
if (options.width % 2 !== 0 || options.height % 2 !== 0) {
throw new Error("Native export requires even output dimensions");
}
const ffmpegPath = getFfmpegBinaryPath()
const inputMode = options.inputMode ?? 'rawvideo'
const sessionId = `recordly-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const outputPath = path.join(app.getPath('temp'), `${sessionId}.mp4`)
const ffmpegPath = getFfmpegBinaryPath();
const inputMode = options.inputMode ?? "rawvideo";
const sessionId = `recordly-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const outputPath = path.join(app.getPath("temp"), `${sessionId}.mp4`);
let encoderName: string
let ffmpegArgs: string[]
let encoderName: string;
let ffmpegArgs: string[];
if (inputMode === 'h264-stream') {
// Pre-encoded H.264 Annex B from browser VideoEncoder — just stream-copy into MP4
encoderName = 'h264-stream-copy'
ffmpegArgs = buildNativeH264StreamExportArgs({ frameRate: options.frameRate, outputPath })
} else {
encoderName = await resolveNativeVideoEncoder(ffmpegPath, options.encodingMode)
ffmpegArgs = buildNativeVideoExportArgs(encoderName, options, outputPath)
}
if (inputMode === "h264-stream") {
// Pre-encoded H.264 Annex B from browser VideoEncoder — just stream-copy into MP4
encoderName = "h264-stream-copy";
ffmpegArgs = buildNativeH264StreamExportArgs({
frameRate: options.frameRate,
outputPath,
});
} else {
encoderName = await resolveNativeVideoEncoder(ffmpegPath, options.encodingMode);
ffmpegArgs = buildNativeVideoExportArgs(encoderName, options, outputPath);
}
const ffmpegProcess = spawn(ffmpegPath, ffmpegArgs, {
stdio: ['pipe', 'ignore', 'pipe'],
}) as ChildProcessByStdio<Writable, null, Readable>
// For rawvideo, frames are a fixed RGBA size. For h264-stream, chunks are variable.
const inputByteSize = inputMode === 'rawvideo' ? getNativeVideoInputByteSize(options.width, options.height) : 0
const ffmpegProcess = spawn(ffmpegPath, ffmpegArgs, {
stdio: ["pipe", "ignore", "pipe"],
}) as ChildProcessByStdio<Writable, null, Readable>;
// For rawvideo, frames are a fixed RGBA size. For h264-stream, chunks are variable.
const inputByteSize =
inputMode === "rawvideo"
? getNativeVideoInputByteSize(options.width, options.height)
: 0;
const session: NativeVideoExportSession = {
ffmpegProcess,
outputPath,
inputByteSize,
inputMode,
maxQueuedWriteBytes: inputMode === 'h264-stream' ? 8 * 1024 * 1024 : getNativeVideoExportMaxQueuedWriteBytes(inputByteSize),
stderrOutput: '',
encoderName,
processError: null,
stdinError: null,
terminating: false,
writeSequence: Promise.resolve(),
sender: event.sender,
pendingWriteRequestIds: new Set<number>(),
completionPromise: new Promise<void>((resolve, reject) => {
ffmpegProcess.once('error', (error) => {
const processError = error instanceof Error ? error : new Error(String(error))
if (session.terminating) {
resolve()
return
}
const session: NativeVideoExportSession = {
ffmpegProcess,
outputPath,
inputByteSize,
inputMode,
maxQueuedWriteBytes:
inputMode === "h264-stream"
? 8 * 1024 * 1024
: getNativeVideoExportMaxQueuedWriteBytes(inputByteSize),
stderrOutput: "",
encoderName,
processError: null,
stdinError: null,
terminating: false,
writeSequence: Promise.resolve(),
sender: event.sender,
pendingWriteRequestIds: new Set<number>(),
completionPromise: new Promise<void>((resolve, reject) => {
ffmpegProcess.once("error", (error) => {
const processError =
error instanceof Error ? error : new Error(String(error));
if (session.terminating) {
resolve();
return;
}
session.processError = processError
reject(processError)
})
ffmpegProcess.stdin.once('error', (error) => {
const stdinError = error instanceof Error ? error : new Error(String(error))
if (session.terminating && isIgnorableNativeVideoExportStreamError(stdinError)) {
return
}
session.processError = processError;
reject(processError);
});
ffmpegProcess.stdin.once("error", (error) => {
const stdinError =
error instanceof Error ? error : new Error(String(error));
if (
session.terminating &&
isIgnorableNativeVideoExportStreamError(stdinError)
) {
return;
}
session.stdinError = stdinError
})
ffmpegProcess.once('close', (code, signal) => {
if (session.terminating) {
resolve()
return
}
session.stdinError = stdinError;
});
ffmpegProcess.once("close", (code, signal) => {
if (session.terminating) {
resolve();
return;
}
if (code === 0) {
resolve()
return
}
if (code === 0) {
resolve();
return;
}
reject(
new Error(
getNativeVideoExportSessionError(
session,
`FFmpeg exited with code ${code ?? 'unknown'}${signal ? ` (signal ${signal})` : ''}`,
),
),
)
})
}),
}
void session.completionPromise.catch(() => undefined)
reject(
new Error(
getNativeVideoExportSessionError(
session,
`FFmpeg exited with code ${code ?? "unknown"}${signal ? ` (signal ${signal})` : ""}`,
),
),
);
});
}),
};
void session.completionPromise.catch(() => undefined);
ffmpegProcess.stderr.on('data', (chunk: Buffer) => {
session.stderrOutput += chunk.toString()
})
ffmpegProcess.stderr.on("data", (chunk: Buffer) => {
session.stderrOutput += chunk.toString();
});
nativeVideoExportSessions.set(sessionId, session)
nativeVideoExportSessions.set(sessionId, session);
console.log(
`[native-export] Started ${isHardwareAcceleratedVideoEncoder(encoderName) ? 'hardware' : 'software'} session ${sessionId} with ${encoderName}`,
)
console.log(
`[native-export] Started ${isHardwareAcceleratedVideoEncoder(encoderName) ? "hardware" : "software"} session ${sessionId} with ${encoderName}`,
);
return {
success: true,
sessionId,
encoderName,
}
} catch (error) {
console.error('[native-export] Failed to start native video export session:', error)
return {
success: false,
error: String(error),
}
}
},
)
return {
success: true,
sessionId,
encoderName,
};
} catch (error) {
console.error(
"[native-export] Failed to start native video export session:",
error,
);
return {
success: false,
error: String(error),
};
}
},
);
ipcMain.on(
'native-video-export-write-frame-async',
(
event,
payload: {
sessionId: string
requestId: number
frameData: Uint8Array
},
) => {
const sessionId = payload?.sessionId
const requestId = payload?.requestId
const frameData = payload?.frameData
ipcMain.on(
"native-video-export-write-frame-async",
(
event,
payload: {
sessionId: string;
requestId: number;
frameData: Uint8Array;
},
) => {
const sessionId = payload?.sessionId;
const requestId = payload?.requestId;
const frameData = payload?.frameData;
if (typeof sessionId !== 'string' || typeof requestId !== 'number' || !frameData) {
return
}
if (typeof sessionId !== "string" || typeof requestId !== "number" || !frameData) {
return;
}
const session = nativeVideoExportSessions.get(sessionId)
if (!session) {
sendNativeVideoExportWriteFrameResult(event.sender, sessionId, requestId, {
success: false,
error: 'Invalid native export session',
})
return
}
const session = nativeVideoExportSessions.get(sessionId);
if (!session) {
sendNativeVideoExportWriteFrameResult(event.sender, sessionId, requestId, {
success: false,
error: "Invalid native export session",
});
return;
}
session.sender = event.sender
session.pendingWriteRequestIds.add(requestId)
session.sender = event.sender;
session.pendingWriteRequestIds.add(requestId);
if (session.terminating) {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: 'Native video export session was cancelled',
})
return
}
if (session.terminating) {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: "Native video export session was cancelled",
});
return;
}
if (session.inputMode !== 'h264-stream' && frameData.byteLength !== session.inputByteSize) {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: `Native video export expected ${session.inputByteSize} bytes per frame but received ${frameData.byteLength}`,
})
return
}
if (
session.inputMode !== "h264-stream" &&
frameData.byteLength !== session.inputByteSize
) {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: `Native video export expected ${session.inputByteSize} bytes per frame but received ${frameData.byteLength}`,
});
return;
}
void enqueueNativeVideoExportFrameWrite(session, frameData)
.then(() => {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: true,
})
})
.catch((error) => {
session.stdinError = error instanceof Error ? error : new Error(String(error))
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: getNativeVideoExportSessionError(
session,
session.stdinError.message,
),
})
})
},
)
void enqueueNativeVideoExportFrameWrite(session, frameData)
.then(() => {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: true,
});
})
.catch((error) => {
session.stdinError = error instanceof Error ? error : new Error(String(error));
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: getNativeVideoExportSessionError(
session,
session.stdinError.message,
),
});
});
},
);
ipcMain.handle(
'native-video-export-finish',
async (_, sessionId: string, options?: NativeVideoExportFinishOptions) => {
const session = nativeVideoExportSessions.get(sessionId)
if (!session) {
return { success: false, error: 'Invalid native export session' }
}
ipcMain.handle(
"native-video-export-finish",
async (_, sessionId: string, options?: NativeVideoExportFinishOptions) => {
const session = nativeVideoExportSessions.get(sessionId);
if (!session) {
return { success: false, error: "Invalid native export session" };
}
try {
await session.writeSequence
if (!session.ffmpegProcess.stdin.destroyed && !session.ffmpegProcess.stdin.writableEnded) {
session.ffmpegProcess.stdin.end()
}
await session.completionPromise
try {
await session.writeSequence;
if (
!session.ffmpegProcess.stdin.destroyed &&
!session.ffmpegProcess.stdin.writableEnded
) {
session.ffmpegProcess.stdin.end();
}
await session.completionPromise;
const finalizedPath = await muxNativeVideoExportAudio(session.outputPath, options ?? {})
const data = await fs.readFile(finalizedPath)
nativeVideoExportSessions.delete(sessionId)
await removeTemporaryExportFile(finalizedPath)
const finalized = await muxNativeVideoExportAudio(
session.outputPath,
options ?? {},
);
const muxedVideoReadStartedAt = performance.now();
const data = await fs.readFile(finalized.outputPath);
nativeVideoExportSessions.delete(sessionId);
await removeTemporaryExportFile(finalized.outputPath);
return {
success: true,
data: new Uint8Array(data),
encoderName: session.encoderName,
}
} catch (error) {
flushNativeVideoExportPendingWriteRequests(
sessionId,
session,
String(error),
)
nativeVideoExportSessions.delete(sessionId)
await removeTemporaryExportFile(session.outputPath)
const finalizedSuffix = session.outputPath.replace(/\.mp4$/, '-final.mp4')
await removeTemporaryExportFile(finalizedSuffix)
return {
success: false,
error: String(error),
}
}
},
)
return {
success: true,
data: new Uint8Array(data),
encoderName: session.encoderName,
metrics: {
...finalized.metrics,
muxedVideoReadMs: performance.now() - muxedVideoReadStartedAt,
muxedVideoBytes: data.byteLength,
},
};
} catch (error) {
flushNativeVideoExportPendingWriteRequests(sessionId, session, String(error));
nativeVideoExportSessions.delete(sessionId);
await removeTemporaryExportFile(session.outputPath);
const finalizedSuffix = session.outputPath.replace(/\.mp4$/, "-final.mp4");
await removeTemporaryExportFile(finalizedSuffix);
return {
success: false,
error: String(error),
};
}
},
);
ipcMain.handle(
'mux-exported-video-audio',
async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => {
try {
const data = await muxExportedVideoAudioBuffer(videoData, options ?? {})
return {
success: true,
data,
}
} catch (error) {
return {
success: false,
error: String(error),
}
}
},
)
ipcMain.handle(
"mux-exported-video-audio",
async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => {
try {
const result = await muxExportedVideoAudioBuffer(videoData, options ?? {});
return {
success: true,
data: result.data,
metrics: result.metrics,
};
} catch (error) {
return {
success: false,
error: String(error),
};
}
},
);
ipcMain.handle('native-video-export-cancel', async (_, sessionId: string) => {
const session = nativeVideoExportSessions.get(sessionId)
if (!session) {
return { success: true }
}
ipcMain.handle("native-video-export-cancel", async (_, sessionId: string) => {
const session = nativeVideoExportSessions.get(sessionId);
if (!session) {
return { success: true };
}
session.terminating = true
nativeVideoExportSessions.delete(sessionId)
flushNativeVideoExportPendingWriteRequests(
sessionId,
session,
'Native video export session was cancelled',
)
session.terminating = true;
nativeVideoExportSessions.delete(sessionId);
flushNativeVideoExportPendingWriteRequests(
sessionId,
session,
"Native video export session was cancelled",
);
try {
if (!session.ffmpegProcess.stdin.destroyed && !session.ffmpegProcess.stdin.writableEnded) {
session.ffmpegProcess.stdin.destroy()
}
} catch {
// Stream may already be closed.
}
try {
if (
!session.ffmpegProcess.stdin.destroyed &&
!session.ffmpegProcess.stdin.writableEnded
) {
session.ffmpegProcess.stdin.destroy();
}
} catch {
// Stream may already be closed.
}
try {
session.ffmpegProcess.kill('SIGKILL')
} catch {
// Process may already be closed.
}
try {
session.ffmpegProcess.kill("SIGKILL");
} catch {
// Process may already be closed.
}
await session.completionPromise.catch(() => undefined)
await removeTemporaryExportFile(session.outputPath)
return { success: true }
})
await session.completionPromise.catch(() => undefined);
await removeTemporaryExportFile(session.outputPath);
return { success: true };
});
ipcMain.handle('save-exported-video', async (event, videoData: ArrayBuffer, fileName: string) => {
try {
// Determine file type from extension
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'],
}
ipcMain.handle(
"save-exported-video",
async (event, videoData: ArrayBuffer, fileName: string) => {
try {
// Determine file type from extension
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)
const result = parentWindow
? await dialog.showSaveDialog(parentWindow, saveDialogOptions)
: await dialog.showSaveDialog(saveDialogOptions);
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: 'Export canceled'
};
}
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: "Export canceled",
};
}
await fs.writeFile(result.filePath, Buffer.from(videoData));
approveUserPath(result.filePath);
await fs.writeFile(result.filePath, Buffer.from(videoData));
approveUserPath(result.filePath);
return {
success: true,
path: result.filePath,
message: 'Video exported successfully'
};
} catch (error) {
console.error('Failed to save exported video:', error)
return {
success: false,
message: 'Failed to save exported video',
error: String(error)
}
}
})
return {
success: true,
path: result.filePath,
message: "Video exported successfully",
};
} catch (error) {
console.error("Failed to save exported video:", error);
return {
success: false,
message: "Failed to save exported video",
error: String(error),
};
}
},
);
ipcMain.handle('write-exported-video-to-path', async (_event, videoData: ArrayBuffer, outputPath: string) => {
try {
const resolvedPath = path.resolve(outputPath)
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(resolvedPath, Buffer.from(videoData));
approveUserPath(resolvedPath);
return {
success: true,
path: resolvedPath,
message: 'Video exported successfully',
canceled: false,
};
} catch (error) {
console.error('Failed to write exported video to path:', error)
return {
success: false,
message: 'Failed to write exported video',
canceled: false,
error: String(error)
}
}
})
ipcMain.handle(
"write-exported-video-to-path",
async (_event, videoData: ArrayBuffer, outputPath: string) => {
try {
const resolvedPath = path.resolve(outputPath);
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(resolvedPath, Buffer.from(videoData));
approveUserPath(resolvedPath);
return {
success: true,
path: resolvedPath,
message: "Video exported successfully",
canceled: false,
};
} catch (error) {
console.error("Failed to write exported video to path:", error);
return {
success: false,
message: "Failed to write exported video",
canceled: false,
error: String(error),
};
}
},
);
}
+22 -2
View File
@@ -1,6 +1,15 @@
import { contextBridge, ipcRenderer } from "electron";
type NativeVideoExportWriteResult = { success: boolean; error?: string };
type NativeVideoAudioMuxMetrics = {
tempVideoWriteMs?: number;
tempEditedAudioWriteMs?: number;
ffmpegExecMs?: number;
muxedVideoReadMs?: number;
tempVideoBytes?: number;
tempEditedAudioBytes?: number;
muxedVideoBytes?: number;
};
const nativeVideoExportWriteRequests = new Map<
number,
@@ -156,7 +165,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
);
return result;
});
}) as Promise<{
success: boolean;
data?: Uint8Array;
encoderName?: string;
error?: string;
metrics?: NativeVideoAudioMuxMetrics;
}>;
},
nativeVideoExportCancel: (sessionId: string) => {
return ipcRenderer.invoke("native-video-export-cancel", sessionId).finally(() => {
@@ -176,7 +191,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
editedAudioMimeType?: string | null;
},
) => {
return ipcRenderer.invoke("mux-exported-video-audio", videoData, options);
return ipcRenderer.invoke("mux-exported-video-audio", videoData, options) as Promise<{
success: boolean;
data?: Uint8Array;
error?: string;
metrics?: NativeVideoAudioMuxMetrics;
}>;
},
getVideoAudioFallbackPaths: (videoPath: string) => {
return ipcRenderer.invoke("get-video-audio-fallback-paths", videoPath);
+127 -81
View File
@@ -39,6 +39,7 @@ import { type DecodedVideoInfo, StreamingVideoDecoder } from "./streamingDecoder
import type {
ExportConfig,
ExportEncodeBackend,
ExportFinalizationStageMetrics,
ExportMetrics,
ExportProgress,
ExportRenderBackend,
@@ -155,6 +156,7 @@ export class ModernVideoExporter {
private nativeCaptureTimeMs = 0;
private nativeWriteTimeMs = 0;
private finalizationTimeMs = 0;
private finalizationStageMs: ExportFinalizationStageMetrics = {};
private processedFrameCount = 0;
private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null;
private lastFinalizationRenderProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress;
@@ -172,6 +174,7 @@ export class ModernVideoExporter {
this.cancelled = false;
this.encoderError = null;
this.nativeEncoderError = null;
this.totalExportStartTimeMs = this.getNowMs();
const backendPreference = this.config.backendPreference ?? "auto";
let useNativeEncoder = false;
this.lastNativeExportError = null;
@@ -414,7 +417,9 @@ export class ModernVideoExporter {
stageStartedAt = this.getNowMs();
this.reportFinalizingProgress(totalFrames, 99);
if (this.nativeH264Encoder) {
await this.nativeH264Encoder.flush();
await this.measureFinalizationStage("nativeEncoderFlushMs", async () => {
await this.nativeH264Encoder!.flush();
});
}
const finishResult = await this.finishNativeVideoExport(nativeAudioPlan);
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
@@ -422,28 +427,32 @@ export class ModernVideoExporter {
return {
success: false,
error: finishResult.error || `${NATIVE_EXPORT_ENGINE_NAME} export failed`,
metrics: finishResult.metrics ?? this.buildExportMetrics(),
metrics: this.buildExportMetrics(),
};
}
return {
success: true,
blob: finishResult.blob,
metrics: finishResult.metrics ?? this.buildExportMetrics(),
metrics: this.buildExportMetrics(),
};
}
stageStartedAt = this.getNowMs();
if (this.encoder && this.encoder.state === "configured") {
this.reportFinalizingProgress(totalFrames, 97);
await this.awaitWithFinalizationTimeout(this.encoder.flush(), "encoder flush");
await this.measureFinalizationStage("encoderFlushMs", async () => {
await this.awaitWithFinalizationTimeout(this.encoder!.flush(), "encoder flush");
});
}
this.reportFinalizingProgress(totalFrames, 98);
await this.awaitWithFinalizationTimeout(
this.pendingMuxing,
"muxing queued video chunks",
);
await this.measureFinalizationStage("queuedMuxingMs", async () => {
await this.awaitWithFinalizationTimeout(
this.pendingMuxing,
"muxing queued video chunks",
);
});
// Surface muxing errors before proceeding with finalization
if (this.encoderError) {
@@ -466,54 +475,59 @@ export class ModernVideoExporter {
this.reportFinalizingProgress(totalFrames, 99, progress);
});
this.reportFinalizingProgress(totalFrames, 99);
await this.awaitWithFinalizationTimeout(
this.audioProcessor.process(
demuxer,
this.muxer!,
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
undefined,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
),
"audio processing",
"audio",
true,
);
await this.measureFinalizationStage("audioProcessingMs", async () => {
await this.awaitWithFinalizationTimeout(
this.audioProcessor!.process(
demuxer,
this.muxer!,
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
undefined,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
),
"audio processing",
"audio",
true,
);
});
}
}
this.reportFinalizingProgress(totalFrames, 99);
const blob = await this.awaitWithFinalizationTimeout(
this.muxer!.finalize(),
"muxer finalization",
nativeAudioPlan.audioMode !== "none" && !shouldUseFfmpegAudioFallback
? "audio"
: "default",
const blob = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
this.awaitWithFinalizationTimeout(
this.muxer!.finalize(),
"muxer finalization",
nativeAudioPlan.audioMode !== "none" && !shouldUseFfmpegAudioFallback
? "audio"
: "default",
),
);
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
if (shouldUseFfmpegAudioFallback) {
console.warn(
`[VideoExporter] Browser AAC encoding is unavailable; falling back to FFmpeg audio muxing.`,
);
const muxedResult = await this.finalizeExportWithFfmpegAudio(blob, nativeAudioPlan);
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
if (!muxedResult.success || !muxedResult.blob) {
return {
success: false,
error: muxedResult.error || "Failed to mux audio with FFmpeg",
metrics: muxedResult.metrics ?? this.buildExportMetrics(),
metrics: this.buildExportMetrics(),
};
}
return {
success: true,
blob: muxedResult.blob,
metrics: muxedResult.metrics ?? this.buildExportMetrics(),
metrics: this.buildExportMetrics(),
};
}
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
return { success: true, blob, metrics: this.buildExportMetrics() };
} catch (error) {
if (this.cancelled && !this.encoderError) {
@@ -968,17 +982,19 @@ export class ModernVideoExporter {
this.audioProcessor.setOnProgress((progress) => {
this.reportFinalizingProgress(this.processedFrameCount, 99, progress);
});
const audioBlob = await this.awaitWithFinalizationTimeout(
this.audioProcessor.renderEditedAudioTrack(
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
const audioBlob = await this.measureFinalizationStage("editedAudioRenderMs", async () =>
this.awaitWithFinalizationTimeout(
this.audioProcessor!.renderEditedAudioTrack(
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
),
`${NATIVE_EXPORT_ENGINE_NAME} edited audio rendering`,
"audio",
true,
),
`${NATIVE_EXPORT_ENGINE_NAME} edited audio rendering`,
"audio",
true,
);
editedAudioBuffer = await audioBlob.arrayBuffer();
editedAudioMimeType = audioBlob.type || null;
@@ -993,21 +1009,27 @@ export class ModernVideoExporter {
await this.awaitPendingNativeWrites();
const result = await this.awaitWithFinalizationTimeout(
window.electronAPI.nativeVideoExportFinish(sessionId, {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" || audioPlan.audioMode === "trim-source"
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
`${NATIVE_EXPORT_ENGINE_NAME} export finalization`,
audioPlan.audioMode === "none" ? "default" : "audio",
const result = await this.measureFinalizationStage("nativeExportFinalizeMs", async () =>
this.awaitWithFinalizationTimeout(
window.electronAPI.nativeVideoExportFinish(sessionId, {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" ||
audioPlan.audioMode === "trim-source"
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
`${NATIVE_EXPORT_ENGINE_NAME} export finalization`,
audioPlan.audioMode === "none" ? "default" : "audio",
),
);
if (result.metrics) {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
this.nativeExportSessionId = null;
if (!result.success) {
@@ -1052,38 +1074,46 @@ export class ModernVideoExporter {
this.audioProcessor.setOnProgress((progress) => {
this.reportFinalizingProgress(this.processedFrameCount, 99, progress);
});
const audioBlob = await this.awaitWithFinalizationTimeout(
this.audioProcessor.renderEditedAudioTrack(
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
const audioBlob = await this.measureFinalizationStage("editedAudioRenderMs", async () =>
this.awaitWithFinalizationTimeout(
this.audioProcessor!.renderEditedAudioTrack(
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
),
"FFmpeg edited audio rendering",
"audio",
true,
),
"FFmpeg edited audio rendering",
"audio",
true,
);
editedAudioBuffer = await audioBlob.arrayBuffer();
editedAudioMimeType = audioBlob.type || null;
}
const videoBuffer = await videoBlob.arrayBuffer();
const result = await this.awaitWithFinalizationTimeout(
window.electronAPI.muxExportedVideoAudio(videoBuffer, {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" || audioPlan.audioMode === "trim-source"
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
"FFmpeg audio muxing",
"audio",
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.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
"FFmpeg audio muxing",
"audio",
),
);
if (result.metrics) {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
if (!result.success || !result.data) {
return {
@@ -1269,6 +1299,7 @@ export class ModernVideoExporter {
const totalElapsedMs =
this.totalExportStartTimeMs > 0 ? this.getNowMs() - this.totalExportStartTimeMs : 0;
const safeFrameCount = Math.max(this.processedFrameCount, 1);
const hasFinalizationStageMetrics = Object.keys(this.finalizationStageMs).length > 0;
return {
totalElapsedMs,
@@ -1290,6 +1321,8 @@ export class ModernVideoExporter {
encodeBackend: this.encodeBackend ?? undefined,
encoderName: this.encoderName ?? undefined,
backpressureProfile: this.backpressureProfile?.name,
effectiveDurationSec: this.effectiveDurationSec || undefined,
finalizationStageMs: hasFinalizationStageMetrics ? this.finalizationStageMs : undefined,
averageFrameCallbackMs:
this.processedFrameCount > 0
? this.frameCallbackTimeMs / safeFrameCount
@@ -1368,6 +1401,18 @@ export class ModernVideoExporter {
return Date.now();
}
private async measureFinalizationStage<T>(
stage: keyof ExportFinalizationStageMetrics,
task: () => Promise<T>,
): Promise<T> {
const startedAt = this.getNowMs();
try {
return await task();
} finally {
this.finalizationStageMs[stage] = this.getNowMs() - startedAt;
}
}
private async initializeEncoder(): Promise<SupportedMp4EncoderPath> {
this.encodeQueue = 0;
this.webCodecsEncodeQueueLimit =
@@ -1614,12 +1659,13 @@ export class ModernVideoExporter {
this.nativeCaptureTimeMs = 0;
this.nativeWriteTimeMs = 0;
this.finalizationTimeMs = 0;
this.finalizationStageMs = {};
this.effectiveDurationSec = 0;
this.processedFrameCount = 0;
this.activeFinalizationProgressWatchdog = null;
this.lastFinalizationRenderProgress =
INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress;
this.lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress;
this.effectiveDurationSec = 0;
this.lastProgressSampleTimeMs = 0;
this.lastProgressSampleFrame = 0;
this.nativeWritePromises = new Set();
+24
View File
@@ -32,6 +32,28 @@ export interface ExportProgress {
audioProgress?: number; // 0-1, progress of real-time audio rendering (speed/audio regions)
}
export interface ExportFinalizationStageMetrics {
encoderFlushMs?: number;
queuedMuxingMs?: number;
audioProcessingMs?: number;
muxerFinalizeMs?: number;
editedAudioRenderMs?: number;
ffmpegAudioMuxMs?: number;
nativeExportFinalizeMs?: number;
nativeEncoderFlushMs?: number;
ffmpegAudioMuxBreakdown?: ExportFfmpegAudioMuxBreakdown;
}
export interface ExportFfmpegAudioMuxBreakdown {
tempVideoWriteMs?: number;
tempEditedAudioWriteMs?: number;
ffmpegExecMs?: number;
muxedVideoReadMs?: number;
tempVideoBytes?: number;
tempEditedAudioBytes?: number;
muxedVideoBytes?: number;
}
export interface ExportMetrics {
totalElapsedMs: number;
metadataLoadMs?: number;
@@ -57,6 +79,8 @@ export interface ExportMetrics {
averageEncodeWaitMs?: number;
averageNativeCaptureMs?: number;
averageNativeWriteMs?: number;
effectiveDurationSec?: number;
finalizationStageMs?: ExportFinalizationStageMetrics;
}
export interface ExportResult {
+185 -85
View File
@@ -24,7 +24,13 @@ import { FrameRenderer } from "./frameRenderer";
import type { SupportedMp4EncoderPath } from "./mp4Support";
import { VideoMuxer } from "./muxer";
import { type DecodedVideoInfo, StreamingVideoDecoder } from "./streamingDecoder";
import type { ExportConfig, ExportProgress, ExportResult } from "./types";
import type {
ExportConfig,
ExportFinalizationStageMetrics,
ExportMetrics,
ExportProgress,
ExportResult,
} from "./types";
const DEFAULT_MAX_ENCODE_QUEUE = 240;
const PROGRESS_SAMPLE_WINDOW_MS = 1_000;
@@ -117,6 +123,9 @@ export class VideoExporter {
private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null;
private lastFinalizationRenderProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress;
private lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress;
private finalizationTimeMs = 0;
private finalizationStageMs: ExportFinalizationStageMetrics = {};
private processedFrameCount = 0;
constructor(config: VideoExporterConfig) {
this.config = config;
@@ -262,6 +271,7 @@ export class VideoExporter {
await this.encodeRenderedFrame(timestamp, frameDuration, frameIndex);
}
frameIndex++;
this.processedFrameCount = frameIndex;
this.reportProgress(frameIndex, totalFrames);
},
);
@@ -269,40 +279,60 @@ export class VideoExporter {
if (this.cancelled) {
const encoderError = this.encoderError as Error | null;
if (encoderError) {
return { success: false, error: encoderError.message };
return {
success: false,
error: encoderError.message,
metrics: this.buildExportMetrics(),
};
}
return { success: false, error: "Export cancelled" };
return {
success: false,
error: "Export cancelled",
metrics: this.buildExportMetrics(),
};
}
this.reportFinalizingProgress(totalFrames, 96);
const finalizationStartedAt = this.getNowMs();
if (useNativeEncoder && nativeAudioPlan) {
if (this.nativeH264Encoder) {
await this.nativeH264Encoder.flush();
await this.awaitPendingNativeWrites();
if (this.nativeEncoderError) {
throw this.nativeEncoderError;
}
await this.measureFinalizationStage("nativeEncoderFlushMs", async () => {
await this.nativeH264Encoder!.flush();
await this.awaitPendingNativeWrites();
if (this.nativeEncoderError) {
throw this.nativeEncoderError;
}
});
this.nativeH264Encoder.close();
this.nativeH264Encoder = null;
}
this.reportFinalizingProgress(totalFrames, 99, 0);
return await this.finishNativeVideoExport(nativeAudioPlan, totalFrames);
const result = await this.finishNativeVideoExport(nativeAudioPlan, totalFrames);
this.finalizationTimeMs = this.getNowMs() - finalizationStartedAt;
return {
...result,
metrics: this.buildExportMetrics(),
};
}
// Finalize encoding
if (this.encoder && this.encoder.state === "configured") {
this.reportFinalizingProgress(totalFrames, 97);
await this.awaitWithFinalizationTimeout(this.encoder.flush(), "encoder flush");
await this.measureFinalizationStage("encoderFlushMs", async () => {
await this.awaitWithFinalizationTimeout(this.encoder!.flush(), "encoder flush");
});
}
// Wait for queued muxing operations to complete
this.reportFinalizingProgress(totalFrames, 98);
await this.awaitWithFinalizationTimeout(
this.pendingMuxing,
"muxing queued video chunks",
);
await this.measureFinalizationStage("queuedMuxingMs", async () => {
await this.awaitWithFinalizationTimeout(
this.pendingMuxing,
"muxing queued video chunks",
);
});
// Surface muxing errors before proceeding with finalization
if (this.encoderError) {
@@ -317,43 +347,61 @@ export class VideoExporter {
this.reportFinalizingProgress(totalFrames, 99, progress);
});
this.reportFinalizingProgress(totalFrames, 99, 0);
await this.awaitWithFinalizationTimeout(
this.audioProcessor.process(
demuxer,
this.muxer!,
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
undefined,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
),
"audio processing",
"audio",
true,
);
await this.measureFinalizationStage("audioProcessingMs", async () => {
await this.awaitWithFinalizationTimeout(
this.audioProcessor!.process(
demuxer,
this.muxer!,
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
undefined,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
),
"audio processing",
"audio",
true,
);
});
}
}
// Finalize muxer and get output blob
this.reportFinalizingProgress(totalFrames, 99);
const blob = await this.awaitWithFinalizationTimeout(
this.muxer!.finalize(),
"muxer finalization",
hasAudio && !shouldUseFfmpegAudioFallback ? "audio" : "default",
const blob = await this.measureFinalizationStage("muxerFinalizeMs", async () =>
this.awaitWithFinalizationTimeout(
this.muxer!.finalize(),
"muxer finalization",
hasAudio && !shouldUseFfmpegAudioFallback ? "audio" : "default",
),
);
if (shouldUseFfmpegAudioFallback) {
console.warn(
"[VideoExporter] Browser AAC encoding is unavailable; falling back to FFmpeg audio muxing.",
);
return await this.finalizeExportWithFfmpegAudio(blob, audioPlan, totalFrames);
const result = await this.finalizeExportWithFfmpegAudio(
blob,
audioPlan,
totalFrames,
);
this.finalizationTimeMs = this.getNowMs() - finalizationStartedAt;
return {
...result,
metrics: this.buildExportMetrics(),
};
}
return { success: true, blob };
this.finalizationTimeMs = this.getNowMs() - finalizationStartedAt;
return { success: true, blob, metrics: this.buildExportMetrics() };
} catch (error) {
if (this.cancelled && !this.encoderError) {
return { success: false, error: "Export cancelled" };
return {
success: false,
error: "Export cancelled",
metrics: this.buildExportMetrics(),
};
}
const resolvedError = this.encoderError ?? error;
@@ -362,6 +410,7 @@ export class VideoExporter {
success: false,
error:
resolvedError instanceof Error ? resolvedError.message : String(resolvedError),
metrics: this.buildExportMetrics(),
};
} finally {
this.cleanup();
@@ -693,44 +742,54 @@ export class VideoExporter {
this.audioProcessor.setOnProgress((progress) => {
this.reportFinalizingProgress(totalFrames, 99, progress);
});
const audioBlob = await this.awaitWithFinalizationTimeout(
this.audioProcessor.renderEditedAudioTrack(
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
const audioBlob = await this.measureFinalizationStage("editedAudioRenderMs", async () =>
this.awaitWithFinalizationTimeout(
this.audioProcessor!.renderEditedAudioTrack(
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
),
"native edited audio rendering",
"audio",
true,
),
"native edited audio rendering",
"audio",
true,
);
editedAudioBuffer = await audioBlob.arrayBuffer();
editedAudioMimeType = audioBlob.type || null;
}
const sessionId = this.nativeExportSessionId;
const result = await this.awaitWithFinalizationTimeout(
window.electronAPI.nativeVideoExportFinish(sessionId, {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" || audioPlan.audioMode === "trim-source"
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
"native export finalization",
audioPlan.audioMode === "none" ? "default" : "audio",
);
this.nativeExportSessionId = null;
const result = await this.measureFinalizationStage("nativeExportFinalizeMs", async () =>
this.awaitWithFinalizationTimeout(
window.electronAPI.nativeVideoExportFinish(sessionId, {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" ||
audioPlan.audioMode === "trim-source"
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
"native export finalization",
audioPlan.audioMode === "none" ? "default" : "audio",
),
);
if (result.metrics) {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
if (!result.success || !result.data) {
return {
success: false,
error: result.error || "Failed to finalize native video export",
metrics: this.buildExportMetrics(),
};
}
@@ -740,6 +799,7 @@ export class VideoExporter {
return {
success: true,
blob: new Blob([blobData.buffer], { type: "video/mp4" }),
metrics: this.buildExportMetrics(),
};
}
@@ -763,43 +823,52 @@ export class VideoExporter {
this.audioProcessor.setOnProgress((progress) => {
this.reportFinalizingProgress(totalFrames, 99, progress);
});
const audioBlob = await this.awaitWithFinalizationTimeout(
this.audioProcessor.renderEditedAudioTrack(
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
const audioBlob = await this.measureFinalizationStage("editedAudioRenderMs", async () =>
this.awaitWithFinalizationTimeout(
this.audioProcessor!.renderEditedAudioTrack(
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
this.config.audioRegions,
this.config.sourceAudioFallbackPaths,
),
"ffmpeg edited audio rendering",
"audio",
true,
),
"ffmpeg edited audio rendering",
"audio",
true,
);
editedAudioBuffer = await audioBlob.arrayBuffer();
editedAudioMimeType = audioBlob.type || null;
}
const videoBuffer = await videoBlob.arrayBuffer();
const result = await this.awaitWithFinalizationTimeout(
window.electronAPI.muxExportedVideoAudio(videoBuffer, {
audioMode: audioPlan.audioMode,
audioSourcePath:
audioPlan.audioMode === "copy-source" || audioPlan.audioMode === "trim-source"
? audioPlan.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
"ffmpeg audio muxing",
"audio",
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.audioSourcePath
: null,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
"ffmpeg audio muxing",
"audio",
),
);
if (result.metrics) {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
if (!result.success || !result.data) {
return {
success: false,
error: result.error || "Failed to mux exported audio with FFmpeg",
metrics: this.buildExportMetrics(),
};
}
@@ -808,6 +877,7 @@ export class VideoExporter {
return {
success: true,
blob: new Blob([blobData.buffer], { type: "video/mp4" }),
metrics: this.buildExportMetrics(),
};
}
@@ -952,6 +1022,32 @@ export class VideoExporter {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
private async measureFinalizationStage<T>(
stage: keyof ExportFinalizationStageMetrics,
task: () => Promise<T>,
): Promise<T> {
const startedAt = this.getNowMs();
try {
return await task();
} finally {
this.finalizationStageMs[stage] = this.getNowMs() - startedAt;
}
}
private buildExportMetrics(): ExportMetrics {
const totalElapsedMs =
this.exportStartTimeMs > 0 ? this.getNowMs() - this.exportStartTimeMs : 0;
const hasFinalizationStageMetrics = Object.keys(this.finalizationStageMs).length > 0;
return {
totalElapsedMs,
finalizationMs: this.finalizationTimeMs || undefined,
frameCount: this.processedFrameCount || undefined,
effectiveDurationSec: this.effectiveDurationSec || undefined,
finalizationStageMs: hasFinalizationStageMetrics ? this.finalizationStageMs : undefined,
};
}
private async initializeEncoder(): Promise<void> {
this.encodeQueue = 0;
this.pendingMuxing = Promise.resolve();
@@ -1163,6 +1259,10 @@ export class VideoExporter {
this.chunkCount = 0;
this.effectiveDurationSec = 0;
this.encoderError = null;
this.finalizationTimeMs = 0;
this.finalizationStageMs = {};
this.effectiveDurationSec = 0;
this.processedFrameCount = 0;
this.videoDescription = undefined;
this.videoColorSpace = undefined;
}