diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index c24386a0..f37eae2b 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -1,6 +1,5 @@
///
-// 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,
diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts
index 9b2472bf..77552cbb 100644
--- a/electron/ipc/export/native-video.ts
+++ b/electron/ipc/export/native-video.ts
@@ -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;
@@ -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),
diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts
index 1861bce2..58dbf5cc 100644
--- a/electron/ipc/nativeVideoExport.ts
+++ b/electron/ipc/nativeVideoExport.ts
@@ -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 {
diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts
index da7387f7..9583a748 100644
--- a/electron/ipc/register/export.ts
+++ b/electron/ipc/register/export.ts
@@ -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
- // 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;
+ // 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(),
- completionPromise: new Promise((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(),
+ completionPromise: new Promise((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),
+ };
+ }
+ },
+ );
}
diff --git a/electron/preload.ts b/electron/preload.ts
index fe226ba0..98562522 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -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);
diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts
index 622a4914..fea3219a 100644
--- a/src/lib/exporter/modernVideoExporter.ts
+++ b/src/lib/exporter/modernVideoExporter.ts
@@ -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(
+ stage: keyof ExportFinalizationStageMetrics,
+ task: () => Promise,
+ ): Promise {
+ const startedAt = this.getNowMs();
+ try {
+ return await task();
+ } finally {
+ this.finalizationStageMs[stage] = this.getNowMs() - startedAt;
+ }
+ }
+
private async initializeEncoder(): Promise {
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();
diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts
index cdda5ea7..85df6690 100644
--- a/src/lib/exporter/types.ts
+++ b/src/lib/exporter/types.ts
@@ -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 {
diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts
index c5c53c80..87f7bcc3 100644
--- a/src/lib/exporter/videoExporter.ts
+++ b/src/lib/exporter/videoExporter.ts
@@ -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(
+ stage: keyof ExportFinalizationStageMetrics,
+ task: () => Promise,
+ ): Promise {
+ 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 {
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;
}