fix(export): scale audio finalization timeouts

This commit is contained in:
wiiiii123
2026-04-20 02:37:21 +07:00
parent daf99c0387
commit dc60a8e6e5
4 changed files with 167 additions and 32 deletions
@@ -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);
});
});
+30
View File
@@ -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);
}
+48 -19
View File
@@ -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<T>(promise: Promise<T>, stage: string): Promise<T> {
private async awaitWithFinalizationTimeout<T>(
promise: Promise<T>,
stage: string,
workload: FinalizationTimeoutWorkload = "default",
): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | 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<void>((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();
+35 -13
View File
@@ -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<void> = 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<T>(promise: Promise<T>, stage: string): Promise<T> {
private async awaitWithFinalizationTimeout<T>(
promise: Promise<T>,
stage: string,
workload: FinalizationTimeoutWorkload = "default",
): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | 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<void>((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;