chore(export): add ffmpeg mux timing breakdown

This commit is contained in:
wiiiii123
2026-04-20 20:09:54 +07:00
parent 8bc79ea1cd
commit b7e5a4ab79
8 changed files with 137 additions and 20 deletions
+12 -1
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,
+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),
+10
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;
}
+13 -5
View File
@@ -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 {
+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);
+6
View File
@@ -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 {
+11
View File
@@ -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 {
+6
View File
@@ -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 {