mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
chore(export): add ffmpeg mux timing breakdown
This commit is contained in:
Vendored
+12
-1
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user