mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
Merge pull request #284 from meiiie/research/export-pipeline-profiling
chore(export): expand finalization profiling
This commit is contained in:
Vendored
+15
-4
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+368
-330
@@ -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<Writable, null, Readable>
|
||||
// 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<Writable, null, Readable>;
|
||||
// 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<number>(),
|
||||
completionPromise: new Promise<void>((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<number>(),
|
||||
completionPromise: new Promise<void>((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),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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<T>(
|
||||
stage: keyof ExportFinalizationStageMetrics,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const startedAt = this.getNowMs();
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
this.finalizationStageMs[stage] = this.getNowMs() - startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeEncoder(): Promise<SupportedMp4EncoderPath> {
|
||||
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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T>(
|
||||
stage: keyof ExportFinalizationStageMetrics,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user