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 {