diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 622a4914..957c216c 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -39,6 +39,7 @@ import { type DecodedVideoInfo, StreamingVideoDecoder } from "./streamingDecoder import type { ExportConfig, ExportEncodeBackend, + ExportFinalizationStageMetrics, ExportMetrics, ExportProgress, ExportRenderBackend, @@ -155,6 +156,8 @@ export class ModernVideoExporter { private nativeCaptureTimeMs = 0; private nativeWriteTimeMs = 0; private finalizationTimeMs = 0; + private finalizationStageMs: ExportFinalizationStageMetrics = {}; + private effectiveDurationSec = 0; private processedFrameCount = 0; private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null; private lastFinalizationRenderProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress; @@ -172,6 +175,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 +418,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 +428,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 +476,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 +983,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,20 +1010,23 @@ 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", + ), ); this.nativeExportSessionId = null; @@ -1052,37 +1072,42 @@ 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.success || !result.data) { @@ -1269,6 +1294,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 +1316,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 +1396,18 @@ export class ModernVideoExporter { return Date.now(); } + private async measureFinalizationStage( + stage: keyof ExportFinalizationStageMetrics, + task: () => Promise, + ): Promise { + const startedAt = this.getNowMs(); + try { + return await task(); + } finally { + this.finalizationStageMs[stage] = this.getNowMs() - startedAt; + } + } + private async initializeEncoder(): Promise { this.encodeQueue = 0; this.webCodecsEncodeQueueLimit = @@ -1614,6 +1654,8 @@ 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 = diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index cdda5ea7..82a3176f 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -32,6 +32,17 @@ 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; +} + export interface ExportMetrics { totalElapsedMs: number; metadataLoadMs?: number; @@ -57,6 +68,8 @@ export interface ExportMetrics { averageEncodeWaitMs?: number; averageNativeCaptureMs?: number; averageNativeWriteMs?: number; + effectiveDurationSec?: number; + finalizationStageMs?: ExportFinalizationStageMetrics; } export interface ExportResult { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index c5c53c80..f355a3aa 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -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,51 @@ 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.success || !result.data) { return { success: false, error: result.error || "Failed to finalize native video export", + metrics: this.buildExportMetrics(), }; } @@ -740,6 +796,7 @@ export class VideoExporter { return { success: true, blob: new Blob([blobData.buffer], { type: "video/mp4" }), + metrics: this.buildExportMetrics(), }; } @@ -763,43 +820,49 @@ 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.success || !result.data) { return { success: false, error: result.error || "Failed to mux exported audio with FFmpeg", + metrics: this.buildExportMetrics(), }; } @@ -808,6 +871,7 @@ export class VideoExporter { return { success: true, blob: new Blob([blobData.buffer], { type: "video/mp4" }), + metrics: this.buildExportMetrics(), }; } @@ -952,6 +1016,32 @@ export class VideoExporter { return typeof performance !== "undefined" ? performance.now() : Date.now(); } + private async measureFinalizationStage( + stage: keyof ExportFinalizationStageMetrics, + task: () => Promise, + ): Promise { + 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 { this.encodeQueue = 0; this.pendingMuxing = Promise.resolve(); @@ -1163,6 +1253,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; }