diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index 4eff6ece..f178a715 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,
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..db503beb 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;
}
diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts
index da7387f7..a12ac2ec 100644
--- a/electron/ipc/register/export.ts
+++ b/electron/ipc/register/export.ts
@@ -2,6 +2,7 @@ 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";
@@ -235,15 +236,21 @@ export function registerExportHandlers() {
}
await session.completionPromise
- const finalizedPath = await muxNativeVideoExportAudio(session.outputPath, options ?? {})
- const data = await fs.readFile(finalizedPath)
+ const finalized = await muxNativeVideoExportAudio(session.outputPath, options ?? {})
+ const muxedVideoReadStartedAt = performance.now()
+ const data = await fs.readFile(finalized.outputPath)
nativeVideoExportSessions.delete(sessionId)
- await removeTemporaryExportFile(finalizedPath)
+ await removeTemporaryExportFile(finalized.outputPath)
return {
success: true,
data: new Uint8Array(data),
encoderName: session.encoderName,
+ metrics: {
+ ...finalized.metrics,
+ muxedVideoReadMs: performance.now() - muxedVideoReadStartedAt,
+ muxedVideoBytes: data.byteLength,
+ },
}
} catch (error) {
flushNativeVideoExportPendingWriteRequests(
@@ -267,10 +274,11 @@ export function registerExportHandlers() {
'mux-exported-video-audio',
async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => {
try {
- const data = await muxExportedVideoAudioBuffer(videoData, options ?? {})
+ const result = await muxExportedVideoAudioBuffer(videoData, options ?? {})
return {
success: true,
- data,
+ data: result.data,
+ metrics: result.metrics,
}
} catch (error) {
return {
diff --git a/electron/preload.ts b/electron/preload.ts
index 4ae5d8bf..a70fdc72 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 957c216c..4b9766c1 100644
--- a/src/lib/exporter/modernVideoExporter.ts
+++ b/src/lib/exporter/modernVideoExporter.ts
@@ -1028,6 +1028,9 @@ export class ModernVideoExporter {
audioPlan.audioMode === "none" ? "default" : "audio",
),
);
+ if (result.metrics) {
+ this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
+ }
this.nativeExportSessionId = null;
if (!result.success) {
@@ -1109,6 +1112,9 @@ export class ModernVideoExporter {
"audio",
),
);
+ if (result.metrics) {
+ this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
+ }
if (!result.success || !result.data) {
return {
diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts
index 82a3176f..85df6690 100644
--- a/src/lib/exporter/types.ts
+++ b/src/lib/exporter/types.ts
@@ -41,6 +41,17 @@ export interface ExportFinalizationStageMetrics {
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 {
diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts
index f355a3aa..87f7bcc3 100644
--- a/src/lib/exporter/videoExporter.ts
+++ b/src/lib/exporter/videoExporter.ts
@@ -781,6 +781,9 @@ export class VideoExporter {
audioPlan.audioMode === "none" ? "default" : "audio",
),
);
+ if (result.metrics) {
+ this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
+ }
if (!result.success || !result.data) {
return {
@@ -857,6 +860,9 @@ export class VideoExporter {
"audio",
),
);
+ if (result.metrics) {
+ this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
+ }
if (!result.success || !result.data) {
return {