From dc60a8e6e5c1f707bb956a91502ae304b78f3ba3 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Mon, 20 Apr 2026 02:37:21 +0700 Subject: [PATCH] fix(export): scale audio finalization timeouts --- src/lib/exporter/finalizationTimeout.test.ts | 54 ++++++++++++++++ src/lib/exporter/finalizationTimeout.ts | 30 +++++++++ src/lib/exporter/modernVideoExporter.ts | 67 ++++++++++++++------ src/lib/exporter/videoExporter.ts | 48 ++++++++++---- 4 files changed, 167 insertions(+), 32 deletions(-) create mode 100644 src/lib/exporter/finalizationTimeout.test.ts create mode 100644 src/lib/exporter/finalizationTimeout.ts diff --git a/src/lib/exporter/finalizationTimeout.test.ts b/src/lib/exporter/finalizationTimeout.test.ts new file mode 100644 index 00000000..fae630f9 --- /dev/null +++ b/src/lib/exporter/finalizationTimeout.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { getExportFinalizationTimeoutMs } from "./finalizationTimeout"; + +describe("finalizationTimeout", () => { + it("keeps non-audio finalization on the existing 10 minute timeout", () => { + expect(getExportFinalizationTimeoutMs({ workload: "default" })).toBe(600_000); + expect( + getExportFinalizationTimeoutMs({ + workload: "default", + effectiveDurationSec: 7_200, + }), + ).toBe(600_000); + }); + + it("gives audio finalization more headroom on longer exports", () => { + expect( + getExportFinalizationTimeoutMs({ + workload: "audio", + effectiveDurationSec: 1_200, + }), + ).toBe(1_200_000); + expect( + getExportFinalizationTimeoutMs({ + workload: "audio", + effectiveDurationSec: 2_700, + }), + ).toBe(1_950_000); + }); + + it("caps adaptive audio timeout growth", () => { + expect( + getExportFinalizationTimeoutMs({ + workload: "audio", + effectiveDurationSec: 10_800, + }), + ).toBe(2_700_000); + }); + + it("falls back to the base timeout for invalid audio durations", () => { + expect( + getExportFinalizationTimeoutMs({ + workload: "audio", + effectiveDurationSec: 0, + }), + ).toBe(600_000); + expect( + getExportFinalizationTimeoutMs({ + workload: "audio", + effectiveDurationSec: Number.NaN, + }), + ).toBe(600_000); + }); +}); diff --git a/src/lib/exporter/finalizationTimeout.ts b/src/lib/exporter/finalizationTimeout.ts new file mode 100644 index 00000000..804af0f4 --- /dev/null +++ b/src/lib/exporter/finalizationTimeout.ts @@ -0,0 +1,30 @@ +export type FinalizationTimeoutWorkload = "default" | "audio"; + +const BASE_FINALIZATION_TIMEOUT_MS = 10 * 60_000; +const AUDIO_TIMEOUT_HEADROOM_PER_OUTPUT_SECOND_MS = 500; +const MAX_AUDIO_FINALIZATION_TIMEOUT_MS = 45 * 60_000; + +export function getExportFinalizationTimeoutMs({ + effectiveDurationSec, + workload = "default", +}: { + effectiveDurationSec?: number | null; + workload?: FinalizationTimeoutWorkload; +}): number { + if (workload !== "audio") { + return BASE_FINALIZATION_TIMEOUT_MS; + } + + if (!Number.isFinite(effectiveDurationSec) || (effectiveDurationSec ?? 0) <= 0) { + return BASE_FINALIZATION_TIMEOUT_MS; + } + + // Audio finalization work scales with the output timeline, so long exports need + // more headroom without making unrelated finalization hangs wait longer. + const safeEffectiveDurationSec = Math.max(0, effectiveDurationSec ?? 0); + const adaptiveTimeoutMs = + BASE_FINALIZATION_TIMEOUT_MS + + safeEffectiveDurationSec * AUDIO_TIMEOUT_HEADROOM_PER_OUTPUT_SECOND_MS; + + return Math.min(adaptiveTimeoutMs, MAX_AUDIO_FINALIZATION_TIMEOUT_MS); +} diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index e2cf013e..76f6331d 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -22,6 +22,10 @@ import { getWebCodecsEncodeQueueLimit, getWebCodecsKeyFrameInterval, } from "./exportTuning"; +import { + type FinalizationTimeoutWorkload, + getExportFinalizationTimeoutMs, +} from "./finalizationTimeout"; import { FrameRenderer as ModernFrameRenderer } from "./modernFrameRenderer"; import { getOrderedSupportedMp4EncoderCandidates, @@ -132,7 +136,7 @@ export class ModernVideoExporter { private lastNativeExportError: string | null = null; private nativeH264Encoder: VideoEncoder | null = null; private nativeEncoderError: Error | null = null; - private readonly FINALIZATION_TIMEOUT_MS = 600_000; + private effectiveDurationSec = 0; private totalExportStartTimeMs = 0; private metadataLoadTimeMs = 0; private rendererInitTimeMs = 0; @@ -161,7 +165,7 @@ export class ModernVideoExporter { this.cleanup(); this.cancelled = false; this.encoderError = null; - this.nativeEncoderError = null; + this.nativeEncoderError = null; const backendPreference = this.config.backendPreference ?? "auto"; let useNativeEncoder = false; this.lastNativeExportError = null; @@ -256,13 +260,14 @@ export class ModernVideoExporter { this.metadataLoadTimeMs = this.getNowMs() - stageStartedAt; const nativeAudioPlan = this.buildNativeAudioPlan(videoInfo); const shouldUseFfmpegAudioFallback = - !useNativeEncoder - && nativeAudioPlan.audioMode !== "none" - && !(await isAacAudioEncodingSupported()); + !useNativeEncoder && + nativeAudioPlan.audioMode !== "none" && + !(await isAacAudioEncodingSupported()); const effectiveDuration = this.streamingDecoder.getEffectiveDuration( this.config.trimRegions, this.config.speedRegions, ); + this.effectiveDurationSec = effectiveDuration; const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); stageStartedAt = this.getNowMs(); @@ -439,7 +444,11 @@ export class ModernVideoExporter { throw this.encoderError; } - if (nativeAudioPlan.audioMode !== "none" && !shouldUseFfmpegAudioFallback && !this.cancelled) { + if ( + nativeAudioPlan.audioMode !== "none" && + !shouldUseFfmpegAudioFallback && + !this.cancelled + ) { const demuxer = this.streamingDecoder.getDemuxer(); if ( demuxer || @@ -463,6 +472,7 @@ export class ModernVideoExporter { this.config.sourceAudioFallbackPaths, ), "audio processing", + "audio", ); } } @@ -471,6 +481,9 @@ export class ModernVideoExporter { const blob = await this.awaitWithFinalizationTimeout( this.muxer!.finalize(), "muxer finalization", + nativeAudioPlan.audioMode !== "none" && !shouldUseFfmpegAudioFallback + ? "audio" + : "default", ); this.finalizationTimeMs = this.getNowMs() - stageStartedAt; @@ -628,8 +641,16 @@ export class ModernVideoExporter { return lines.join("\n"); } - private async awaitWithFinalizationTimeout(promise: Promise, stage: string): Promise { + private async awaitWithFinalizationTimeout( + promise: Promise, + stage: string, + workload: FinalizationTimeoutWorkload = "default", + ): Promise { let timeoutId: ReturnType | null = null; + const timeoutMs = getExportFinalizationTimeoutMs({ + effectiveDurationSec: this.effectiveDurationSec, + workload, + }); try { return await Promise.race([ @@ -638,10 +659,10 @@ export class ModernVideoExporter { timeoutId = setTimeout(() => { reject( new Error( - `Export timed out during ${stage} after ${Math.round(this.FINALIZATION_TIMEOUT_MS / 60_000)} minutes`, + `Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`, ), ); - }, this.FINALIZATION_TIMEOUT_MS); + }, timeoutMs); }), ]); } finally { @@ -782,7 +803,10 @@ export class ModernVideoExporter { return false; } - if (typeof VideoEncoder === "undefined" || typeof VideoEncoder.isConfigSupported !== "function") { + if ( + typeof VideoEncoder === "undefined" || + typeof VideoEncoder.isConfigSupported !== "function" + ) { this.lastNativeExportError = `${NATIVE_EXPORT_ENGINE_NAME} export requires WebCodecs VideoEncoder support.`; return false; } @@ -804,8 +828,7 @@ export class ModernVideoExporter { return false; } } catch (error) { - this.lastNativeExportError = - error instanceof Error ? error.message : String(error); + this.lastNativeExportError = error instanceof Error ? error.message : String(error); console.warn( `[VideoExporter] ${NATIVE_EXPORT_ENGINE_NAME} encoder support check failed`, error, @@ -852,7 +875,8 @@ export class ModernVideoExporter { .then((writeResult) => { if (!writeResult.success && !this.cancelled) { throw new Error( - writeResult.error || "Failed to write H.264 chunk to native encoder", + writeResult.error || + "Failed to write H.264 chunk to native encoder", ); } }) @@ -880,8 +904,7 @@ export class ModernVideoExporter { try { encoder.configure(encoderConfig); } catch (error) { - this.lastNativeExportError = - error instanceof Error ? error.message : String(error); + this.lastNativeExportError = error instanceof Error ? error.message : String(error); try { encoder.close(); } catch (closeError) { @@ -923,8 +946,7 @@ export class ModernVideoExporter { if (this.nativeEncoderError) throw this.nativeEncoderError; } while ( - this.nativeH264Encoder.encodeQueueSize >= - ModernVideoExporter.NATIVE_ENCODER_QUEUE_LIMIT + this.nativeH264Encoder.encodeQueueSize >= ModernVideoExporter.NATIVE_ENCODER_QUEUE_LIMIT ) { await new Promise((r) => setTimeout(r, 2)); if (this.cancelled) return; @@ -961,6 +983,7 @@ export class ModernVideoExporter { this.config.sourceAudioFallbackPaths, ), `${NATIVE_EXPORT_ENGINE_NAME} edited audio rendering`, + "audio", ); editedAudioBuffer = await audioBlob.arrayBuffer(); editedAudioMimeType = audioBlob.type || null; @@ -989,6 +1012,7 @@ export class ModernVideoExporter { editedAudioMimeType, }), `${NATIVE_EXPORT_ENGINE_NAME} export finalization`, + audioPlan.audioMode === "none" ? "default" : "audio", ); if (!result.success) { @@ -1042,6 +1066,7 @@ export class ModernVideoExporter { this.config.sourceAudioFallbackPaths, ), "FFmpeg edited audio rendering", + "audio", ); editedAudioBuffer = await audioBlob.arrayBuffer(); editedAudioMimeType = audioBlob.type || null; @@ -1055,11 +1080,13 @@ export class ModernVideoExporter { audioPlan.audioMode === "copy-source" || audioPlan.audioMode === "trim-source" ? audioPlan.audioSourcePath : null, - trimSegments: audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined, + trimSegments: + audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined, editedAudioData: editedAudioBuffer, editedAudioMimeType, }), "FFmpeg audio muxing", + "audio", ); if (!result.success || !result.data) { @@ -1404,7 +1431,8 @@ export class ModernVideoExporter { } } catch (error) { console.error("Muxing error:", error); - const muxingError = error instanceof Error ? error : new Error(String(error)); + const muxingError = + error instanceof Error ? error : new Error(String(error)); if (!this.encoderError) { this.encoderError = muxingError; } @@ -1578,6 +1606,7 @@ export class ModernVideoExporter { this.nativeWriteTimeMs = 0; this.finalizationTimeMs = 0; this.processedFrameCount = 0; + this.effectiveDurationSec = 0; this.lastProgressSampleTimeMs = 0; this.lastProgressSampleFrame = 0; this.nativeWritePromises = new Set(); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 43d345c3..754407d5 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -13,6 +13,10 @@ import type { ZoomTransitionEasing, } from "@/components/video-editor/types"; import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder"; +import { + type FinalizationTimeoutWorkload, + getExportFinalizationTimeoutMs, +} from "./finalizationTimeout"; import { FrameRenderer } from "./frameRenderer"; import type { SupportedMp4EncoderPath } from "./mp4Support"; import { VideoMuxer } from "./muxer"; @@ -95,7 +99,7 @@ export class VideoExporter { private videoColorSpace: VideoColorSpaceInit | undefined; private pendingMuxing: Promise = Promise.resolve(); private chunkCount = 0; - private readonly FINALIZATION_TIMEOUT_MS = 600_000; + private effectiveDurationSec = 0; private exportStartTimeMs = 0; private progressSampleStartTimeMs = 0; private progressSampleStartFrame = 0; @@ -142,9 +146,9 @@ export class VideoExporter { ? await this.tryStartNativeVideoExport() : false; const shouldUseFfmpegAudioFallback = - !useNativeEncoder - && audioPlan.audioMode !== "none" - && !(await isAacAudioEncodingSupported()); + !useNativeEncoder && + audioPlan.audioMode !== "none" && + !(await isAacAudioEncodingSupported()); if (!useNativeEncoder) { await this.initializeEncoder(); @@ -211,6 +215,7 @@ export class VideoExporter { this.config.trimRegions, this.config.speedRegions, ); + this.effectiveDurationSec = effectiveDuration; const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); console.log("[VideoExporter] Original duration:", videoInfo.duration, "s"); @@ -318,6 +323,7 @@ export class VideoExporter { this.config.sourceAudioFallbackPaths, ), "audio processing", + "audio", ); } } @@ -327,6 +333,7 @@ export class VideoExporter { const blob = await this.awaitWithFinalizationTimeout( this.muxer!.finalize(), "muxer finalization", + hasAudio && !shouldUseFfmpegAudioFallback ? "audio" : "default", ); if (shouldUseFfmpegAudioFallback) { @@ -366,8 +373,16 @@ export class VideoExporter { ); } - private async awaitWithFinalizationTimeout(promise: Promise, stage: string): Promise { + private async awaitWithFinalizationTimeout( + promise: Promise, + stage: string, + workload: FinalizationTimeoutWorkload = "default", + ): Promise { let timeoutId: ReturnType | null = null; + const timeoutMs = getExportFinalizationTimeoutMs({ + effectiveDurationSec: this.effectiveDurationSec, + workload, + }); try { return await Promise.race([ @@ -376,10 +391,10 @@ export class VideoExporter { timeoutId = setTimeout(() => { reject( new Error( - `Export timed out during ${stage} after ${Math.round(this.FINALIZATION_TIMEOUT_MS / 60_000)} minutes`, + `Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`, ), ); - }, this.FINALIZATION_TIMEOUT_MS); + }, timeoutMs); }), ]); } finally { @@ -578,7 +593,8 @@ export class VideoExporter { ); if (!writeResult.success && !this.cancelled) { throw new Error( - writeResult.error || "Failed to write H.264 chunk to native encoder", + writeResult.error || + "Failed to write H.264 chunk to native encoder", ); } }) @@ -603,8 +619,7 @@ export class VideoExporter { try { encoder.configure(encoderConfig); } catch (error) { - this.nativeEncoderError = - error instanceof Error ? error : new Error(String(error)); + this.nativeEncoderError = error instanceof Error ? error : new Error(String(error)); try { encoder.close(); } catch (closeError) { @@ -644,7 +659,7 @@ export class VideoExporter { // Apply backpressure: don't queue too far ahead of FFmpeg's stdin pipe while ( this.nativeH264Encoder.encodeQueueSize >= - Math.max(1, Math.floor(this.config.maxEncodeQueue ?? DEFAULT_MAX_ENCODE_QUEUE)) + Math.max(1, Math.floor(this.config.maxEncodeQueue ?? DEFAULT_MAX_ENCODE_QUEUE)) ) { await new Promise((r) => setTimeout(r, 2)); if (this.cancelled) return; @@ -693,6 +708,7 @@ export class VideoExporter { this.config.sourceAudioFallbackPaths, ), "native edited audio rendering", + "audio", ); editedAudioBuffer = await audioBlob.arrayBuffer(); editedAudioMimeType = audioBlob.type || null; @@ -714,6 +730,7 @@ export class VideoExporter { editedAudioMimeType, }), "native export finalization", + audioPlan.audioMode === "none" ? "default" : "audio", ); if (!result.success || !result.data) { @@ -761,6 +778,7 @@ export class VideoExporter { this.config.sourceAudioFallbackPaths, ), "ffmpeg edited audio rendering", + "audio", ); editedAudioBuffer = await audioBlob.arrayBuffer(); editedAudioMimeType = audioBlob.type || null; @@ -774,11 +792,13 @@ export class VideoExporter { audioPlan.audioMode === "copy-source" || audioPlan.audioMode === "trim-source" ? audioPlan.audioSourcePath : null, - trimSegments: audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined, + trimSegments: + audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined, editedAudioData: editedAudioBuffer, editedAudioMimeType, }), "ffmpeg audio muxing", + "audio", ); if (!result.success || !result.data) { @@ -984,7 +1004,8 @@ export class VideoExporter { } } catch (error) { console.error("Muxing error:", error); - const muxingError = error instanceof Error ? error : new Error(String(error)); + const muxingError = + error instanceof Error ? error : new Error(String(error)); if (!this.encoderError) { this.encoderError = muxingError; } @@ -1127,6 +1148,7 @@ export class VideoExporter { this.pendingMuxing = Promise.resolve(); this.nativePendingWrite = Promise.resolve(); this.chunkCount = 0; + this.effectiveDurationSec = 0; this.encoderError = null; this.videoDescription = undefined; this.videoColorSpace = undefined;