From 8bc79ea1cd218785cc08f6d37455e38f47c8fbb3 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Mon, 20 Apr 2026 09:40:14 +0700 Subject: [PATCH 1/3] chore(export): add finalization stage profiling --- src/lib/exporter/modernVideoExporter.ts | 202 +++++++++++------- src/lib/exporter/types.ts | 13 ++ src/lib/exporter/videoExporter.ts | 264 ++++++++++++++++-------- 3 files changed, 314 insertions(+), 165 deletions(-) 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; } From b7e5a4ab79c726737c717cd99e7a216172a4e22e Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Mon, 20 Apr 2026 19:35:56 +0700 Subject: [PATCH 2/3] chore(export): add ffmpeg mux timing breakdown --- electron/electron-env.d.ts | 13 ++++- electron/ipc/export/native-video.ts | 69 ++++++++++++++++++++----- electron/ipc/nativeVideoExport.ts | 10 ++++ electron/ipc/register/export.ts | 18 +++++-- electron/preload.ts | 24 ++++++++- src/lib/exporter/modernVideoExporter.ts | 6 +++ src/lib/exporter/types.ts | 11 ++++ src/lib/exporter/videoExporter.ts | 6 +++ 8 files changed, 137 insertions(+), 20 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4eff6ece..f178a715 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -1,6 +1,5 @@ /// -// biome-ignore lint/style/noNamespace: NodeJS.ProcessEnv augmentation requires a namespace declaration. declare namespace NodeJS { interface ProcessEnv { /** @@ -69,6 +68,16 @@ type RendererMarketplaceReviewStatus = type RendererMarketplaceSearchResult = import("./extensions/extensionTypes").MarketplaceSearchResult; +interface RendererFfmpegAudioMuxMetrics { + tempVideoWriteMs?: number; + tempEditedAudioWriteMs?: number; + ffmpegExecMs?: number; + muxedVideoReadMs?: number; + tempVideoBytes?: number; + tempEditedAudioBytes?: number; + muxedVideoBytes?: number; +} + interface Window { electronAPI: { hudOverlaySetIgnoreMouse: (ignore: boolean) => void; @@ -194,6 +203,7 @@ interface Window { data?: Uint8Array; encoderName?: string; error?: string; + metrics?: RendererFfmpegAudioMuxMetrics; }>; nativeVideoExportCancel: ( sessionId: string, @@ -211,6 +221,7 @@ interface Window { success: boolean; data?: Uint8Array; error?: string; + metrics?: RendererFfmpegAudioMuxMetrics; }>; getVideoAudioFallbackPaths: ( videoPath: string, diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index 9b2472bf..77552cbb 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -2,16 +2,29 @@ import type { ChildProcessByStdio } from "node:child_process"; import { execFile, spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; -import { promisify } from "node:util"; +import { performance } from "node:perf_hooks"; import type { Readable, Writable } from "node:stream"; -import { app } from "electron"; +import { promisify } from "node:util"; import type { WebContents } from "electron"; +import { app } from "electron"; import { getFfmpegBinaryPath } from "../ffmpeg/binary"; -import { buildTrimmedSourceAudioFilter, getEditedAudioExtension, getNativeVideoInputByteSize, getPreferredNativeVideoEncoders, buildNativeVideoExportArgs, parseAvailableFfmpegEncoders } from "../nativeVideoExport"; -import type { NativeExportEncodingMode, NativeVideoExportFinishOptions } from "../nativeVideoExport"; +import type { + NativeExportEncodingMode, + NativeVideoAudioMuxMetrics, + NativeVideoExportFinishOptions, +} from "../nativeVideoExport"; +import { + buildNativeVideoExportArgs, + buildTrimmedSourceAudioFilter, + getEditedAudioExtension, + getNativeVideoInputByteSize, + getPreferredNativeVideoEncoders, + parseAvailableFfmpegEncoders, +} from "../nativeVideoExport"; import { cachedNativeVideoEncoder, setCachedNativeVideoEncoder } from "../state"; const execFileAsync = promisify(execFile); +const getNowMs = () => performance.now(); export type NativeVideoExportSession = { ffmpegProcess: ChildProcessByStdio; @@ -72,7 +85,10 @@ export async function removeTemporaryExportFile(filePath: string | null | undefi } } -export function getNativeVideoExportSessionError(session: NativeVideoExportSession, fallback: string) { +export function getNativeVideoExportSessionError( + session: NativeVideoExportSession, + fallback: string, +) { return ( session.stdinError?.message || session.processError?.message || @@ -199,7 +215,10 @@ export async function writeNativeVideoExportFrame( session: NativeVideoExportSession, frameData: Uint8Array | ArrayBuffer, ) { - if (session.inputMode !== "h264-stream" && getNativeVideoExportFrameLength(frameData) !== session.inputByteSize) { + if ( + session.inputMode !== "h264-stream" && + getNativeVideoExportFrameLength(frameData) !== session.inputByteSize + ) { throw new Error( `Native video export expected ${session.inputByteSize} bytes per frame but received ${getNativeVideoExportFrameLength(frameData)}`, ); @@ -358,10 +377,14 @@ export async function muxNativeVideoExportAudio( ) { const audioMode = options.audioMode ?? "none"; if (audioMode === "none") { - return videoPath; + return { + outputPath: videoPath, + metrics: {} as NativeVideoAudioMuxMetrics, + }; } const ffmpegPath = getFfmpegBinaryPath(); + const metrics: NativeVideoAudioMuxMetrics = {}; const tempArtifacts: string[] = []; let audioInputPath = options.audioSourcePath ?? null; @@ -375,12 +398,18 @@ export async function muxNativeVideoExportAudio( app.getPath("temp"), `recordly-export-audio-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${extension}`, ); + const tempAudioWriteStartedAt = getNowMs(); await fs.writeFile(audioInputPath, Buffer.from(options.editedAudioData)); + metrics.tempEditedAudioWriteMs = getNowMs() - tempAudioWriteStartedAt; + metrics.tempEditedAudioBytes = options.editedAudioData.byteLength; tempArtifacts.push(audioInputPath); } if (!audioInputPath) { - return videoPath; + return { + outputPath: videoPath, + metrics, + }; } const outputPath = path.join( @@ -424,12 +453,17 @@ export async function muxNativeVideoExportAudio( ); try { + const ffmpegExecStartedAt = getNowMs(); await execFileAsync(ffmpegPath, args, { timeout: 15 * 60 * 1000, maxBuffer: 20 * 1024 * 1024, }); + metrics.ffmpegExecMs = getNowMs() - ffmpegExecStartedAt; await removeTemporaryExportFile(videoPath); - return outputPath; + return { + outputPath, + metrics, + }; } finally { await Promise.allSettled( tempArtifacts.map((artifactPath) => removeTemporaryExportFile(artifactPath)), @@ -445,12 +479,23 @@ export async function muxExportedVideoAudioBuffer( app.getPath("temp"), `recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`, ); + const metrics: NativeVideoAudioMuxMetrics = {}; try { + const tempVideoWriteStartedAt = getNowMs(); await fs.writeFile(tempVideoPath, Buffer.from(videoData)); - const finalizedPath = await muxNativeVideoExportAudio(tempVideoPath, options); - const muxedData = await fs.readFile(finalizedPath); - return new Uint8Array(muxedData); + metrics.tempVideoWriteMs = getNowMs() - tempVideoWriteStartedAt; + metrics.tempVideoBytes = videoData.byteLength; + const finalized = await muxNativeVideoExportAudio(tempVideoPath, options); + Object.assign(metrics, finalized.metrics); + const muxedVideoReadStartedAt = getNowMs(); + const muxedData = await fs.readFile(finalized.outputPath); + metrics.muxedVideoReadMs = getNowMs() - muxedVideoReadStartedAt; + metrics.muxedVideoBytes = muxedData.byteLength; + return { + data: new Uint8Array(muxedData), + metrics, + }; } finally { await Promise.allSettled([ removeTemporaryExportFile(tempVideoPath), diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts index 1861bce2..db503beb 100644 --- a/electron/ipc/nativeVideoExport.ts +++ b/electron/ipc/nativeVideoExport.ts @@ -26,6 +26,16 @@ export interface NativeVideoExportFinishOptions { editedAudioMimeType?: string | null; } +export interface NativeVideoAudioMuxMetrics { + tempVideoWriteMs?: number; + tempEditedAudioWriteMs?: number; + ffmpegExecMs?: number; + muxedVideoReadMs?: number; + tempVideoBytes?: number; + tempEditedAudioBytes?: number; + muxedVideoBytes?: number; +} + export function getNativeVideoInputByteSize(width: number, height: number): number { return width * height * NATIVE_EXPORT_INPUT_BYTES_PER_PIXEL; } diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index da7387f7..a12ac2ec 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -2,6 +2,7 @@ import type { ChildProcessByStdio } from "node:child_process"; import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; +import { performance } from "node:perf_hooks"; import type { Readable, Writable } from "node:stream"; import type { SaveDialogOptions } from "electron"; import { app, BrowserWindow, dialog, ipcMain } from "electron"; @@ -235,15 +236,21 @@ export function registerExportHandlers() { } await session.completionPromise - const finalizedPath = await muxNativeVideoExportAudio(session.outputPath, options ?? {}) - const data = await fs.readFile(finalizedPath) + const finalized = await muxNativeVideoExportAudio(session.outputPath, options ?? {}) + const muxedVideoReadStartedAt = performance.now() + const data = await fs.readFile(finalized.outputPath) nativeVideoExportSessions.delete(sessionId) - await removeTemporaryExportFile(finalizedPath) + await removeTemporaryExportFile(finalized.outputPath) return { success: true, data: new Uint8Array(data), encoderName: session.encoderName, + metrics: { + ...finalized.metrics, + muxedVideoReadMs: performance.now() - muxedVideoReadStartedAt, + muxedVideoBytes: data.byteLength, + }, } } catch (error) { flushNativeVideoExportPendingWriteRequests( @@ -267,10 +274,11 @@ export function registerExportHandlers() { 'mux-exported-video-audio', async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => { try { - const data = await muxExportedVideoAudioBuffer(videoData, options ?? {}) + const result = await muxExportedVideoAudioBuffer(videoData, options ?? {}) return { success: true, - data, + data: result.data, + metrics: result.metrics, } } catch (error) { return { diff --git a/electron/preload.ts b/electron/preload.ts index 4ae5d8bf..a70fdc72 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,6 +1,15 @@ import { contextBridge, ipcRenderer } from "electron"; type NativeVideoExportWriteResult = { success: boolean; error?: string }; +type NativeVideoAudioMuxMetrics = { + tempVideoWriteMs?: number; + tempEditedAudioWriteMs?: number; + ffmpegExecMs?: number; + muxedVideoReadMs?: number; + tempVideoBytes?: number; + tempEditedAudioBytes?: number; + muxedVideoBytes?: number; +}; const nativeVideoExportWriteRequests = new Map< number, @@ -156,7 +165,13 @@ contextBridge.exposeInMainWorld("electronAPI", { ); return result; - }); + }) as Promise<{ + success: boolean; + data?: Uint8Array; + encoderName?: string; + error?: string; + metrics?: NativeVideoAudioMuxMetrics; + }>; }, nativeVideoExportCancel: (sessionId: string) => { return ipcRenderer.invoke("native-video-export-cancel", sessionId).finally(() => { @@ -176,7 +191,12 @@ contextBridge.exposeInMainWorld("electronAPI", { editedAudioMimeType?: string | null; }, ) => { - return ipcRenderer.invoke("mux-exported-video-audio", videoData, options); + return ipcRenderer.invoke("mux-exported-video-audio", videoData, options) as Promise<{ + success: boolean; + data?: Uint8Array; + error?: string; + metrics?: NativeVideoAudioMuxMetrics; + }>; }, getVideoAudioFallbackPaths: (videoPath: string) => { return ipcRenderer.invoke("get-video-audio-fallback-paths", videoPath); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 957c216c..4b9766c1 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -1028,6 +1028,9 @@ export class ModernVideoExporter { audioPlan.audioMode === "none" ? "default" : "audio", ), ); + if (result.metrics) { + this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; + } this.nativeExportSessionId = null; if (!result.success) { @@ -1109,6 +1112,9 @@ export class ModernVideoExporter { "audio", ), ); + if (result.metrics) { + this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; + } if (!result.success || !result.data) { return { diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index 82a3176f..85df6690 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -41,6 +41,17 @@ export interface ExportFinalizationStageMetrics { ffmpegAudioMuxMs?: number; nativeExportFinalizeMs?: number; nativeEncoderFlushMs?: number; + ffmpegAudioMuxBreakdown?: ExportFfmpegAudioMuxBreakdown; +} + +export interface ExportFfmpegAudioMuxBreakdown { + tempVideoWriteMs?: number; + tempEditedAudioWriteMs?: number; + ffmpegExecMs?: number; + muxedVideoReadMs?: number; + tempVideoBytes?: number; + tempEditedAudioBytes?: number; + muxedVideoBytes?: number; } export interface ExportMetrics { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index f355a3aa..87f7bcc3 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -781,6 +781,9 @@ export class VideoExporter { audioPlan.audioMode === "none" ? "default" : "audio", ), ); + if (result.metrics) { + this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; + } if (!result.success || !result.data) { return { @@ -857,6 +860,9 @@ export class VideoExporter { "audio", ), ); + if (result.metrics) { + this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; + } if (!result.success || !result.data) { return { From ccc0e63d1b829cdc14e7a60a4394797f7ebd533c Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Mon, 20 Apr 2026 20:11:58 +0700 Subject: [PATCH 3/3] fix(export): clean telemetry profiling rebase --- electron/electron-env.d.ts | 6 +- electron/ipc/nativeVideoExport.ts | 37 +- electron/ipc/register/export.ts | 704 ++++++++++++------------ src/lib/exporter/modernVideoExporter.ts | 2 - 4 files changed, 391 insertions(+), 358 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f178a715..90249b39 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -341,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, diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts index db503beb..58dbf5cc 100644 --- a/electron/ipc/nativeVideoExport.ts +++ b/electron/ipc/nativeVideoExport.ts @@ -169,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 { diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index a12ac2ec..9583a748 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -6,6 +6,22 @@ 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, @@ -14,381 +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 - // 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; + // 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(), - completionPromise: new Promise((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(), + completionPromise: new Promise((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 finalized = await muxNativeVideoExportAudio(session.outputPath, options ?? {}) - const muxedVideoReadStartedAt = performance.now() - const data = await fs.readFile(finalized.outputPath) - nativeVideoExportSessions.delete(sessionId) - await removeTemporaryExportFile(finalized.outputPath) + 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, - 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), - } - } - }, - ) + 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 result = await muxExportedVideoAudioBuffer(videoData, options ?? {}) - return { - success: true, - data: result.data, - metrics: result.metrics, - } - } 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), + }; + } + }, + ); } diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 4b9766c1..fea3219a 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -157,7 +157,6 @@ export class ModernVideoExporter { 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; @@ -1667,7 +1666,6 @@ export class ModernVideoExporter { 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();