mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
chore(export): add finalization stage profiling
This commit is contained in:
@@ -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<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,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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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 +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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user