mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
fix(export): scale audio finalization timeouts
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user