From dc60a8e6e5c1f707bb956a91502ae304b78f3ba3 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Mon, 20 Apr 2026 02:37:21 +0700 Subject: [PATCH 1/5] 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; From 271dddcc3292ff69ce03675818fd872fad69ed95 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Mon, 20 Apr 2026 10:01:42 +0700 Subject: [PATCH 2/5] fix(export): make audio timeouts progress-aware --- src/lib/exporter/finalizationTimeout.test.ts | 25 +++++++++- src/lib/exporter/finalizationTimeout.ts | 29 ++++++++++- src/lib/exporter/modernVideoExporter.ts | 51 ++++++++++++++++++-- src/lib/exporter/videoExporter.ts | 51 ++++++++++++++++++-- 4 files changed, 145 insertions(+), 11 deletions(-) diff --git a/src/lib/exporter/finalizationTimeout.test.ts b/src/lib/exporter/finalizationTimeout.test.ts index fae630f9..107d709f 100644 --- a/src/lib/exporter/finalizationTimeout.test.ts +++ b/src/lib/exporter/finalizationTimeout.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getExportFinalizationTimeoutMs } from "./finalizationTimeout"; +import { + getExportFinalizationIdleTimeoutMs, + getExportFinalizationTimeoutMs, +} from "./finalizationTimeout"; describe("finalizationTimeout", () => { it("keeps non-audio finalization on the existing 10 minute timeout", () => { @@ -51,4 +54,24 @@ describe("finalizationTimeout", () => { }), ).toBe(600_000); }); + + it("derives a bounded idle watchdog window from the total timeout", () => { + expect( + getExportFinalizationIdleTimeoutMs({ + workload: "default", + }), + ).toBe(150_000); + expect( + getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: 1_200, + }), + ).toBe(300_000); + expect( + getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: 2_700, + }), + ).toBe(300_000); + }); }); diff --git a/src/lib/exporter/finalizationTimeout.ts b/src/lib/exporter/finalizationTimeout.ts index 804af0f4..69ed6d0d 100644 --- a/src/lib/exporter/finalizationTimeout.ts +++ b/src/lib/exporter/finalizationTimeout.ts @@ -3,6 +3,9 @@ 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; +const MIN_PROGRESS_IDLE_TIMEOUT_MS = 90_000; +const MAX_PROGRESS_IDLE_TIMEOUT_MS = 5 * 60_000; +const PROGRESS_IDLE_TIMEOUT_FRACTION = 0.25; export function getExportFinalizationTimeoutMs({ effectiveDurationSec, @@ -15,16 +18,38 @@ export function getExportFinalizationTimeoutMs({ return BASE_FINALIZATION_TIMEOUT_MS; } - if (!Number.isFinite(effectiveDurationSec) || (effectiveDurationSec ?? 0) <= 0) { + const safeEffectiveDurationSec = + typeof effectiveDurationSec === "number" ? effectiveDurationSec : Number.NaN; + if (!Number.isFinite(safeEffectiveDurationSec) || safeEffectiveDurationSec <= 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); } + +export function getExportFinalizationIdleTimeoutMs({ + effectiveDurationSec, + workload = "default", +}: { + effectiveDurationSec?: number | null; + workload?: FinalizationTimeoutWorkload; +}): number { + const totalTimeoutMs = getExportFinalizationTimeoutMs({ + effectiveDurationSec, + workload, + }); + + return Math.min( + Math.max( + Math.floor(totalTimeoutMs * PROGRESS_IDLE_TIMEOUT_FRACTION), + MIN_PROGRESS_IDLE_TIMEOUT_MS, + ), + MAX_PROGRESS_IDLE_TIMEOUT_MS, + ); +} diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 76f6331d..330cefcb 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -24,6 +24,7 @@ import { } from "./exportTuning"; import { type FinalizationTimeoutWorkload, + getExportFinalizationIdleTimeoutMs, getExportFinalizationTimeoutMs, } from "./finalizationTimeout"; import { FrameRenderer as ModernFrameRenderer } from "./modernFrameRenderer"; @@ -153,6 +154,7 @@ export class ModernVideoExporter { private nativeWriteTimeMs = 0; private finalizationTimeMs = 0; private processedFrameCount = 0; + private activeFinalizationProgressWatchdog: { refreshProgress: () => void } | null = null; private lastProgressSampleTimeMs = 0; private lastProgressSampleFrame = 0; @@ -473,6 +475,7 @@ export class ModernVideoExporter { ), "audio processing", "audio", + true, ); } } @@ -645,22 +648,52 @@ export class ModernVideoExporter { promise: Promise, stage: string, workload: FinalizationTimeoutWorkload = "default", + progressAware = false, ): Promise { let timeoutId: ReturnType | null = null; + let idleTimeoutId: ReturnType | null = null; const timeoutMs = getExportFinalizationTimeoutMs({ effectiveDurationSec: this.effectiveDurationSec, workload, }); + const idleTimeoutMs = progressAware + ? getExportFinalizationIdleTimeoutMs({ + effectiveDurationSec: this.effectiveDurationSec, + workload, + }) + : null; + const watchdog: { refreshProgress: () => void } | null = + progressAware && idleTimeoutMs + ? { + refreshProgress: () => undefined, + } + : null; try { return await Promise.race([ promise, new Promise((_, reject) => { + const rejectWithMessage = (message: string) => { + reject(new Error(message)); + }; + if (watchdog) { + this.activeFinalizationProgressWatchdog = watchdog; + const refreshProgress = () => { + if (idleTimeoutId) { + clearTimeout(idleTimeoutId); + } + idleTimeoutId = setTimeout(() => { + rejectWithMessage( + `Export timed out during ${stage} after ${Math.ceil(idleTimeoutMs! / 1000)} seconds without observable progress`, + ); + }, idleTimeoutMs!); + }; + watchdog.refreshProgress = refreshProgress; + refreshProgress(); + } timeoutId = setTimeout(() => { - reject( - new Error( - `Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`, - ), + rejectWithMessage( + `Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`, ); }, timeoutMs); }), @@ -669,6 +702,12 @@ export class ModernVideoExporter { if (timeoutId) { clearTimeout(timeoutId); } + if (idleTimeoutId) { + clearTimeout(idleTimeoutId); + } + if (this.activeFinalizationProgressWatchdog === watchdog) { + this.activeFinalizationProgressWatchdog = null; + } } } @@ -984,6 +1023,7 @@ export class ModernVideoExporter { ), `${NATIVE_EXPORT_ENGINE_NAME} edited audio rendering`, "audio", + true, ); editedAudioBuffer = await audioBlob.arrayBuffer(); editedAudioMimeType = audioBlob.type || null; @@ -1067,6 +1107,7 @@ export class ModernVideoExporter { ), "FFmpeg edited audio rendering", "audio", + true, ); editedAudioBuffer = await audioBlob.arrayBuffer(); editedAudioMimeType = audioBlob.type || null; @@ -1164,6 +1205,7 @@ export class ModernVideoExporter { renderProgress: number, audioProgress?: number, ) { + this.activeFinalizationProgressWatchdog?.refreshProgress(); this.reportProgress(totalFrames, totalFrames, "finalizing", renderProgress, audioProgress); } @@ -1606,6 +1648,7 @@ export class ModernVideoExporter { this.nativeWriteTimeMs = 0; this.finalizationTimeMs = 0; this.processedFrameCount = 0; + this.activeFinalizationProgressWatchdog = null; this.effectiveDurationSec = 0; this.lastProgressSampleTimeMs = 0; this.lastProgressSampleFrame = 0; diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 754407d5..ba015c3c 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -15,6 +15,7 @@ import type { import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder"; import { type FinalizationTimeoutWorkload, + getExportFinalizationIdleTimeoutMs, getExportFinalizationTimeoutMs, } from "./finalizationTimeout"; import { FrameRenderer } from "./frameRenderer"; @@ -111,6 +112,7 @@ export class VideoExporter { private nativeWriteError: Error | null = null; private maxNativeWriteInFlight = 1; private nativeEncoderError: Error | null = null; + private activeFinalizationProgressWatchdog: { refreshProgress: () => void } | null = null; constructor(config: VideoExporterConfig) { this.config = config; @@ -324,6 +326,7 @@ export class VideoExporter { ), "audio processing", "audio", + true, ); } } @@ -377,22 +380,52 @@ export class VideoExporter { promise: Promise, stage: string, workload: FinalizationTimeoutWorkload = "default", + progressAware = false, ): Promise { let timeoutId: ReturnType | null = null; + let idleTimeoutId: ReturnType | null = null; const timeoutMs = getExportFinalizationTimeoutMs({ effectiveDurationSec: this.effectiveDurationSec, workload, }); + const idleTimeoutMs = progressAware + ? getExportFinalizationIdleTimeoutMs({ + effectiveDurationSec: this.effectiveDurationSec, + workload, + }) + : null; + const watchdog: { refreshProgress: () => void } | null = + progressAware && idleTimeoutMs + ? { + refreshProgress: () => undefined, + } + : null; try { return await Promise.race([ promise, new Promise((_, reject) => { + const rejectWithMessage = (message: string) => { + reject(new Error(message)); + }; + if (watchdog) { + this.activeFinalizationProgressWatchdog = watchdog; + const refreshProgress = () => { + if (idleTimeoutId) { + clearTimeout(idleTimeoutId); + } + idleTimeoutId = setTimeout(() => { + rejectWithMessage( + `Export timed out during ${stage} after ${Math.ceil(idleTimeoutMs! / 1000)} seconds without observable progress`, + ); + }, idleTimeoutMs!); + }; + watchdog.refreshProgress = refreshProgress; + refreshProgress(); + } timeoutId = setTimeout(() => { - reject( - new Error( - `Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`, - ), + rejectWithMessage( + `Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`, ); }, timeoutMs); }), @@ -401,6 +434,12 @@ export class VideoExporter { if (timeoutId) { clearTimeout(timeoutId); } + if (idleTimeoutId) { + clearTimeout(idleTimeoutId); + } + if (this.activeFinalizationProgressWatchdog === watchdog) { + this.activeFinalizationProgressWatchdog = null; + } } } @@ -709,6 +748,7 @@ export class VideoExporter { ), "native edited audio rendering", "audio", + true, ); editedAudioBuffer = await audioBlob.arrayBuffer(); editedAudioMimeType = audioBlob.type || null; @@ -779,6 +819,7 @@ export class VideoExporter { ), "ffmpeg edited audio rendering", "audio", + true, ); editedAudioBuffer = await audioBlob.arrayBuffer(); editedAudioMimeType = audioBlob.type || null; @@ -890,6 +931,7 @@ export class VideoExporter { renderProgress: number, audioProgress?: number, ) { + this.activeFinalizationProgressWatchdog?.refreshProgress(); this.reportProgress(totalFrames, totalFrames, "finalizing", renderProgress, audioProgress); } @@ -1144,6 +1186,7 @@ export class VideoExporter { this.muxer = null; this.audioProcessor = null; + this.activeFinalizationProgressWatchdog = null; this.encodeQueue = 0; this.pendingMuxing = Promise.resolve(); this.nativePendingWrite = Promise.resolve(); From 6783eeb55c9da73e4307d3cbb6b1d1d85635c5a2 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Mon, 20 Apr 2026 10:19:25 +0700 Subject: [PATCH 3/5] test(export): share finalization watchdog logic --- src/lib/exporter/finalizationTimeout.test.ts | 88 +++++++++++++++++++- src/lib/exporter/finalizationTimeout.ts | 77 +++++++++++++++++ src/lib/exporter/modernVideoExporter.ts | 69 +++------------ src/lib/exporter/videoExporter.ts | 69 +++------------ 4 files changed, 184 insertions(+), 119 deletions(-) diff --git a/src/lib/exporter/finalizationTimeout.test.ts b/src/lib/exporter/finalizationTimeout.test.ts index 107d709f..ccc53ffe 100644 --- a/src/lib/exporter/finalizationTimeout.test.ts +++ b/src/lib/exporter/finalizationTimeout.test.ts @@ -1,8 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + type FinalizationProgressWatchdog, getExportFinalizationIdleTimeoutMs, getExportFinalizationTimeoutMs, + withFinalizationTimeout, } from "./finalizationTimeout"; describe("finalizationTimeout", () => { @@ -73,5 +75,89 @@ describe("finalizationTimeout", () => { effectiveDurationSec: 2_700, }), ).toBe(300_000); + expect( + getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: 0, + }), + ).toBe(150_000); + expect( + getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: Number.NaN, + }), + ).toBe(150_000); + }); + + it("rejects when a progress-aware finalization stage stops reporting progress", async () => { + vi.useFakeTimers(); + + try { + const idleTimeoutMs = getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: 1_200, + }); + + const pendingStage = withFinalizationTimeout({ + promise: new Promise(() => {}), + stage: "audio processing", + workload: "audio", + effectiveDurationSec: 1_200, + progressAware: true, + }); + + const rejection = pendingStage.then( + () => null, + (error) => (error instanceof Error ? error.message : String(error)), + ); + + await vi.advanceTimersByTimeAsync(idleTimeoutMs + 1); + + await expect(rejection).resolves.toContain("without observable progress"); + } finally { + vi.useRealTimers(); + } + }); + + it("resets the idle watchdog when finalization progress continues", async () => { + vi.useFakeTimers(); + + try { + const idleTimeoutMs = getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: 1_200, + }); + let watchdog: FinalizationProgressWatchdog | null = null; + const pendingStage = withFinalizationTimeout({ + promise: new Promise(() => {}), + stage: "audio processing", + workload: "audio", + effectiveDurationSec: 1_200, + progressAware: true, + onWatchdogChanged: (nextWatchdog) => { + watchdog = nextWatchdog; + }, + }); + const rejection = pendingStage.then( + () => null, + (error) => (error instanceof Error ? error.message : String(error)), + ); + + await vi.advanceTimersByTimeAsync(idleTimeoutMs - 1_000); + expect(watchdog).not.toBeNull(); + + watchdog?.refreshProgress(); + await vi.advanceTimersByTimeAsync(idleTimeoutMs - 1_000); + + const pendingSentinel = Symbol("pending"); + await expect(Promise.race([rejection, Promise.resolve(pendingSentinel)])).resolves.toBe( + pendingSentinel, + ); + + await vi.advanceTimersByTimeAsync(1_001); + await expect(rejection).resolves.toContain("without observable progress"); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/src/lib/exporter/finalizationTimeout.ts b/src/lib/exporter/finalizationTimeout.ts index 69ed6d0d..b098e411 100644 --- a/src/lib/exporter/finalizationTimeout.ts +++ b/src/lib/exporter/finalizationTimeout.ts @@ -1,4 +1,7 @@ export type FinalizationTimeoutWorkload = "default" | "audio"; +export type FinalizationProgressWatchdog = { + refreshProgress: () => void; +}; const BASE_FINALIZATION_TIMEOUT_MS = 10 * 60_000; const AUDIO_TIMEOUT_HEADROOM_PER_OUTPUT_SECOND_MS = 500; @@ -53,3 +56,77 @@ export function getExportFinalizationIdleTimeoutMs({ MAX_PROGRESS_IDLE_TIMEOUT_MS, ); } + +export async function withFinalizationTimeout({ + promise, + stage, + effectiveDurationSec, + workload = "default", + progressAware = false, + onWatchdogChanged, +}: { + promise: Promise; + stage: string; + effectiveDurationSec?: number | null; + workload?: FinalizationTimeoutWorkload; + progressAware?: boolean; + onWatchdogChanged?: (watchdog: FinalizationProgressWatchdog | null) => void; +}): Promise { + let timeoutId: ReturnType | null = null; + let idleTimeoutId: ReturnType | null = null; + const timeoutMs = getExportFinalizationTimeoutMs({ + effectiveDurationSec, + workload, + }); + const idleTimeoutMs = progressAware + ? getExportFinalizationIdleTimeoutMs({ + effectiveDurationSec, + workload, + }) + : null; + const watchdog: FinalizationProgressWatchdog | null = + progressAware && idleTimeoutMs !== null && idleTimeoutMs !== undefined + ? { + refreshProgress: () => undefined, + } + : null; + + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + const rejectWithMessage = (message: string) => { + reject(new Error(message)); + }; + if (watchdog && idleTimeoutMs !== null) { + const refreshProgress = () => { + if (idleTimeoutId) { + clearTimeout(idleTimeoutId); + } + idleTimeoutId = setTimeout(() => { + rejectWithMessage( + `Export timed out during ${stage} after ${Math.ceil(idleTimeoutMs / 1000)} seconds without observable progress`, + ); + }, idleTimeoutMs); + }; + watchdog.refreshProgress = refreshProgress; + onWatchdogChanged?.(watchdog); + refreshProgress(); + } + timeoutId = setTimeout(() => { + rejectWithMessage( + `Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`, + ); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (idleTimeoutId) { + clearTimeout(idleTimeoutId); + } + onWatchdogChanged?.(null); + } +} diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 330cefcb..bc76be17 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -23,9 +23,9 @@ import { getWebCodecsKeyFrameInterval, } from "./exportTuning"; import { + type FinalizationProgressWatchdog, type FinalizationTimeoutWorkload, - getExportFinalizationIdleTimeoutMs, - getExportFinalizationTimeoutMs, + withFinalizationTimeout, } from "./finalizationTimeout"; import { FrameRenderer as ModernFrameRenderer } from "./modernFrameRenderer"; import { @@ -154,7 +154,7 @@ export class ModernVideoExporter { private nativeWriteTimeMs = 0; private finalizationTimeMs = 0; private processedFrameCount = 0; - private activeFinalizationProgressWatchdog: { refreshProgress: () => void } | null = null; + private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null; private lastProgressSampleTimeMs = 0; private lastProgressSampleFrame = 0; @@ -650,65 +650,16 @@ export class ModernVideoExporter { workload: FinalizationTimeoutWorkload = "default", progressAware = false, ): Promise { - let timeoutId: ReturnType | null = null; - let idleTimeoutId: ReturnType | null = null; - const timeoutMs = getExportFinalizationTimeoutMs({ + return withFinalizationTimeout({ + promise, + stage, effectiveDurationSec: this.effectiveDurationSec, workload, + progressAware, + onWatchdogChanged: (watchdog) => { + this.activeFinalizationProgressWatchdog = watchdog; + }, }); - const idleTimeoutMs = progressAware - ? getExportFinalizationIdleTimeoutMs({ - effectiveDurationSec: this.effectiveDurationSec, - workload, - }) - : null; - const watchdog: { refreshProgress: () => void } | null = - progressAware && idleTimeoutMs - ? { - refreshProgress: () => undefined, - } - : null; - - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - const rejectWithMessage = (message: string) => { - reject(new Error(message)); - }; - if (watchdog) { - this.activeFinalizationProgressWatchdog = watchdog; - const refreshProgress = () => { - if (idleTimeoutId) { - clearTimeout(idleTimeoutId); - } - idleTimeoutId = setTimeout(() => { - rejectWithMessage( - `Export timed out during ${stage} after ${Math.ceil(idleTimeoutMs! / 1000)} seconds without observable progress`, - ); - }, idleTimeoutMs!); - }; - watchdog.refreshProgress = refreshProgress; - refreshProgress(); - } - timeoutId = setTimeout(() => { - rejectWithMessage( - `Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`, - ); - }, timeoutMs); - }), - ]); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - if (idleTimeoutId) { - clearTimeout(idleTimeoutId); - } - if (this.activeFinalizationProgressWatchdog === watchdog) { - this.activeFinalizationProgressWatchdog = null; - } - } } private getNativeVideoSourcePath(): string | null { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index ba015c3c..e213e83b 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -14,9 +14,9 @@ import type { } from "@/components/video-editor/types"; import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder"; import { + type FinalizationProgressWatchdog, type FinalizationTimeoutWorkload, - getExportFinalizationIdleTimeoutMs, - getExportFinalizationTimeoutMs, + withFinalizationTimeout, } from "./finalizationTimeout"; import { FrameRenderer } from "./frameRenderer"; import type { SupportedMp4EncoderPath } from "./mp4Support"; @@ -112,7 +112,7 @@ export class VideoExporter { private nativeWriteError: Error | null = null; private maxNativeWriteInFlight = 1; private nativeEncoderError: Error | null = null; - private activeFinalizationProgressWatchdog: { refreshProgress: () => void } | null = null; + private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null; constructor(config: VideoExporterConfig) { this.config = config; @@ -382,65 +382,16 @@ export class VideoExporter { workload: FinalizationTimeoutWorkload = "default", progressAware = false, ): Promise { - let timeoutId: ReturnType | null = null; - let idleTimeoutId: ReturnType | null = null; - const timeoutMs = getExportFinalizationTimeoutMs({ + return withFinalizationTimeout({ + promise, + stage, effectiveDurationSec: this.effectiveDurationSec, workload, + progressAware, + onWatchdogChanged: (watchdog) => { + this.activeFinalizationProgressWatchdog = watchdog; + }, }); - const idleTimeoutMs = progressAware - ? getExportFinalizationIdleTimeoutMs({ - effectiveDurationSec: this.effectiveDurationSec, - workload, - }) - : null; - const watchdog: { refreshProgress: () => void } | null = - progressAware && idleTimeoutMs - ? { - refreshProgress: () => undefined, - } - : null; - - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - const rejectWithMessage = (message: string) => { - reject(new Error(message)); - }; - if (watchdog) { - this.activeFinalizationProgressWatchdog = watchdog; - const refreshProgress = () => { - if (idleTimeoutId) { - clearTimeout(idleTimeoutId); - } - idleTimeoutId = setTimeout(() => { - rejectWithMessage( - `Export timed out during ${stage} after ${Math.ceil(idleTimeoutMs! / 1000)} seconds without observable progress`, - ); - }, idleTimeoutMs!); - }; - watchdog.refreshProgress = refreshProgress; - refreshProgress(); - } - timeoutId = setTimeout(() => { - rejectWithMessage( - `Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`, - ); - }, timeoutMs); - }), - ]); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - if (idleTimeoutId) { - clearTimeout(idleTimeoutId); - } - if (this.activeFinalizationProgressWatchdog === watchdog) { - this.activeFinalizationProgressWatchdog = null; - } - } } private getNativeVideoSourcePath(): string | null { From 04e013cbbf16bf66e47835868903fdd6b0c104d8 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Mon, 20 Apr 2026 10:39:10 +0700 Subject: [PATCH 4/5] fix(export): only refresh watchdog on progress --- src/lib/exporter/finalizationTimeout.test.ts | 29 +++++++++++++++ src/lib/exporter/finalizationTimeout.ts | 38 ++++++++++++++++++++ src/lib/exporter/modernVideoExporter.ts | 21 ++++++++++- src/lib/exporter/videoExporter.ts | 21 ++++++++++- 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/lib/exporter/finalizationTimeout.test.ts b/src/lib/exporter/finalizationTimeout.test.ts index ccc53ffe..91728291 100644 --- a/src/lib/exporter/finalizationTimeout.test.ts +++ b/src/lib/exporter/finalizationTimeout.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { + advanceFinalizationProgress, type FinalizationProgressWatchdog, getExportFinalizationIdleTimeoutMs, getExportFinalizationTimeoutMs, + INITIAL_FINALIZATION_PROGRESS_STATE, withFinalizationTimeout, } from "./finalizationTimeout"; @@ -160,4 +162,31 @@ describe("finalizationTimeout", () => { vi.useRealTimers(); } }); + + it("only marks finalization as progressed when normalized progress increases", () => { + const initial = advanceFinalizationProgress({ + renderProgress: 99, + audioProgress: 0.5, + state: INITIAL_FINALIZATION_PROGRESS_STATE, + }); + expect(initial.progressed).toBe(true); + expect(initial.lastRenderProgress).toBe(99); + expect(initial.lastAudioProgress).toBe(0.5); + + const repeated = advanceFinalizationProgress({ + renderProgress: 99, + audioProgress: 0.5, + state: initial, + }); + expect(repeated.progressed).toBe(false); + + const advanced = advanceFinalizationProgress({ + renderProgress: 100, + audioProgress: 0.5, + state: repeated, + }); + expect(advanced.progressed).toBe(true); + expect(advanced.lastRenderProgress).toBe(100); + expect(advanced.lastAudioProgress).toBe(0.5); + }); }); diff --git a/src/lib/exporter/finalizationTimeout.ts b/src/lib/exporter/finalizationTimeout.ts index b098e411..86b7e9dd 100644 --- a/src/lib/exporter/finalizationTimeout.ts +++ b/src/lib/exporter/finalizationTimeout.ts @@ -2,6 +2,10 @@ export type FinalizationTimeoutWorkload = "default" | "audio"; export type FinalizationProgressWatchdog = { refreshProgress: () => void; }; +export type FinalizationProgressState = { + lastRenderProgress: number; + lastAudioProgress: number; +}; const BASE_FINALIZATION_TIMEOUT_MS = 10 * 60_000; const AUDIO_TIMEOUT_HEADROOM_PER_OUTPUT_SECOND_MS = 500; @@ -10,6 +14,11 @@ const MIN_PROGRESS_IDLE_TIMEOUT_MS = 90_000; const MAX_PROGRESS_IDLE_TIMEOUT_MS = 5 * 60_000; const PROGRESS_IDLE_TIMEOUT_FRACTION = 0.25; +export const INITIAL_FINALIZATION_PROGRESS_STATE: FinalizationProgressState = { + lastRenderProgress: -1, + lastAudioProgress: -1, +}; + export function getExportFinalizationTimeoutMs({ effectiveDurationSec, workload = "default", @@ -57,6 +66,35 @@ export function getExportFinalizationIdleTimeoutMs({ ); } +export function advanceFinalizationProgress({ + renderProgress, + audioProgress, + state, +}: { + renderProgress: number; + audioProgress?: number; + state: FinalizationProgressState; +}): FinalizationProgressState & { progressed: boolean } { + const normalizedRenderProgress = Math.max(0, Math.min(renderProgress, 100)); + const normalizedAudioProgress = + typeof audioProgress === "number" && Number.isFinite(audioProgress) + ? Math.max(0, Math.min(audioProgress, 1)) + : null; + const nextRenderProgress = Math.max(state.lastRenderProgress, normalizedRenderProgress); + const nextAudioProgress = + normalizedAudioProgress === null + ? state.lastAudioProgress + : Math.max(state.lastAudioProgress, normalizedAudioProgress); + + return { + progressed: + nextRenderProgress > state.lastRenderProgress || + nextAudioProgress > state.lastAudioProgress, + lastRenderProgress: nextRenderProgress, + lastAudioProgress: nextAudioProgress, + }; +} + export async function withFinalizationTimeout({ promise, stage, diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index bc76be17..35db408d 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -23,8 +23,10 @@ import { getWebCodecsKeyFrameInterval, } from "./exportTuning"; import { + advanceFinalizationProgress, type FinalizationProgressWatchdog, type FinalizationTimeoutWorkload, + INITIAL_FINALIZATION_PROGRESS_STATE, withFinalizationTimeout, } from "./finalizationTimeout"; import { FrameRenderer as ModernFrameRenderer } from "./modernFrameRenderer"; @@ -155,6 +157,8 @@ export class ModernVideoExporter { private finalizationTimeMs = 0; private processedFrameCount = 0; private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null; + private lastFinalizationRenderProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress; + private lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress; private lastProgressSampleTimeMs = 0; private lastProgressSampleFrame = 0; @@ -1156,7 +1160,19 @@ export class ModernVideoExporter { renderProgress: number, audioProgress?: number, ) { - this.activeFinalizationProgressWatchdog?.refreshProgress(); + const nextProgress = advanceFinalizationProgress({ + renderProgress, + audioProgress, + state: { + lastRenderProgress: this.lastFinalizationRenderProgress, + lastAudioProgress: this.lastFinalizationAudioProgress, + }, + }); + if (nextProgress.progressed) { + this.activeFinalizationProgressWatchdog?.refreshProgress(); + } + this.lastFinalizationRenderProgress = nextProgress.lastRenderProgress; + this.lastFinalizationAudioProgress = nextProgress.lastAudioProgress; this.reportProgress(totalFrames, totalFrames, "finalizing", renderProgress, audioProgress); } @@ -1600,6 +1616,9 @@ export class ModernVideoExporter { this.finalizationTimeMs = 0; this.processedFrameCount = 0; this.activeFinalizationProgressWatchdog = null; + this.lastFinalizationRenderProgress = + INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress; + this.lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress; this.effectiveDurationSec = 0; this.lastProgressSampleTimeMs = 0; this.lastProgressSampleFrame = 0; diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index e213e83b..ebcad040 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -14,8 +14,10 @@ import type { } from "@/components/video-editor/types"; import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder"; import { + advanceFinalizationProgress, type FinalizationProgressWatchdog, type FinalizationTimeoutWorkload, + INITIAL_FINALIZATION_PROGRESS_STATE, withFinalizationTimeout, } from "./finalizationTimeout"; import { FrameRenderer } from "./frameRenderer"; @@ -113,6 +115,8 @@ export class VideoExporter { private maxNativeWriteInFlight = 1; private nativeEncoderError: Error | null = null; private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null; + private lastFinalizationRenderProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress; + private lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress; constructor(config: VideoExporterConfig) { this.config = config; @@ -882,7 +886,19 @@ export class VideoExporter { renderProgress: number, audioProgress?: number, ) { - this.activeFinalizationProgressWatchdog?.refreshProgress(); + const nextProgress = advanceFinalizationProgress({ + renderProgress, + audioProgress, + state: { + lastRenderProgress: this.lastFinalizationRenderProgress, + lastAudioProgress: this.lastFinalizationAudioProgress, + }, + }); + if (nextProgress.progressed) { + this.activeFinalizationProgressWatchdog?.refreshProgress(); + } + this.lastFinalizationRenderProgress = nextProgress.lastRenderProgress; + this.lastFinalizationAudioProgress = nextProgress.lastAudioProgress; this.reportProgress(totalFrames, totalFrames, "finalizing", renderProgress, audioProgress); } @@ -1138,6 +1154,9 @@ export class VideoExporter { this.muxer = null; this.audioProcessor = null; this.activeFinalizationProgressWatchdog = null; + this.lastFinalizationRenderProgress = + INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress; + this.lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress; this.encodeQueue = 0; this.pendingMuxing = Promise.resolve(); this.nativePendingWrite = Promise.resolve(); From 19486c7a44d1d1f5cbb2d86b66aa084b88db0eb7 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Mon, 20 Apr 2026 11:21:52 +0700 Subject: [PATCH 5/5] fix(export): cancel timed out finalization work --- src/lib/exporter/finalizationTimeout.test.ts | 20 ++++++++++++ src/lib/exporter/finalizationTimeout.ts | 33 +++++++++++++------- src/lib/exporter/modernVideoExporter.ts | 2 +- src/lib/exporter/videoExporter.ts | 4 +-- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/lib/exporter/finalizationTimeout.test.ts b/src/lib/exporter/finalizationTimeout.test.ts index 91728291..7193470a 100644 --- a/src/lib/exporter/finalizationTimeout.test.ts +++ b/src/lib/exporter/finalizationTimeout.test.ts @@ -189,4 +189,24 @@ describe("finalizationTimeout", () => { expect(advanced.lastRenderProgress).toBe(100); expect(advanced.lastAudioProgress).toBe(0.5); }); + + it("ignores non-finite render progress without poisoning later updates", () => { + const invalid = advanceFinalizationProgress({ + renderProgress: Number.NaN, + audioProgress: 0.25, + state: INITIAL_FINALIZATION_PROGRESS_STATE, + }); + expect(invalid.progressed).toBe(true); + expect(invalid.lastRenderProgress).toBe(-1); + expect(invalid.lastAudioProgress).toBe(0.25); + + const recovered = advanceFinalizationProgress({ + renderProgress: 99, + audioProgress: 0.25, + state: invalid, + }); + expect(recovered.progressed).toBe(true); + expect(recovered.lastRenderProgress).toBe(99); + expect(recovered.lastAudioProgress).toBe(0.25); + }); }); diff --git a/src/lib/exporter/finalizationTimeout.ts b/src/lib/exporter/finalizationTimeout.ts index 86b7e9dd..040409fd 100644 --- a/src/lib/exporter/finalizationTimeout.ts +++ b/src/lib/exporter/finalizationTimeout.ts @@ -75,12 +75,18 @@ export function advanceFinalizationProgress({ audioProgress?: number; state: FinalizationProgressState; }): FinalizationProgressState & { progressed: boolean } { - const normalizedRenderProgress = Math.max(0, Math.min(renderProgress, 100)); + const normalizedRenderProgress = + typeof renderProgress === "number" && Number.isFinite(renderProgress) + ? Math.max(0, Math.min(renderProgress, 100)) + : null; const normalizedAudioProgress = typeof audioProgress === "number" && Number.isFinite(audioProgress) ? Math.max(0, Math.min(audioProgress, 1)) : null; - const nextRenderProgress = Math.max(state.lastRenderProgress, normalizedRenderProgress); + const nextRenderProgress = + normalizedRenderProgress === null + ? state.lastRenderProgress + : Math.max(state.lastRenderProgress, normalizedRenderProgress); const nextAudioProgress = normalizedAudioProgress === null ? state.lastAudioProgress @@ -122,12 +128,17 @@ export async function withFinalizationTimeout({ workload, }) : null; - const watchdog: FinalizationProgressWatchdog | null = - progressAware && idleTimeoutMs !== null && idleTimeoutMs !== undefined - ? { - refreshProgress: () => undefined, - } - : null; + const hasIdleWatchdog = + progressAware && + typeof idleTimeoutMs === "number" && + Number.isFinite(idleTimeoutMs) && + idleTimeoutMs >= 0; + const resolvedIdleTimeoutMs = hasIdleWatchdog ? idleTimeoutMs : null; + const watchdog: FinalizationProgressWatchdog | null = hasIdleWatchdog + ? { + refreshProgress: () => undefined, + } + : null; try { return await Promise.race([ @@ -136,16 +147,16 @@ export async function withFinalizationTimeout({ const rejectWithMessage = (message: string) => { reject(new Error(message)); }; - if (watchdog && idleTimeoutMs !== null) { + if (watchdog && resolvedIdleTimeoutMs !== null) { const refreshProgress = () => { if (idleTimeoutId) { clearTimeout(idleTimeoutId); } idleTimeoutId = setTimeout(() => { rejectWithMessage( - `Export timed out during ${stage} after ${Math.ceil(idleTimeoutMs / 1000)} seconds without observable progress`, + `Export timed out during ${stage} after ${Math.ceil(resolvedIdleTimeoutMs / 1000)} seconds without observable progress`, ); - }, idleTimeoutMs); + }, resolvedIdleTimeoutMs); }; watchdog.refreshProgress = refreshProgress; onWatchdogChanged?.(watchdog); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 35db408d..622a4914 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -985,7 +985,6 @@ export class ModernVideoExporter { } const sessionId = this.nativeExportSessionId; - this.nativeExportSessionId = null; console.log(`[VideoExporter] Finalizing ${NATIVE_EXPORT_ENGINE_NAME} export`, { sessionId, audioMode: audioPlan.audioMode, @@ -1009,6 +1008,7 @@ export class ModernVideoExporter { `${NATIVE_EXPORT_ENGINE_NAME} export finalization`, audioPlan.audioMode === "none" ? "default" : "audio", ); + this.nativeExportSessionId = null; if (!result.success) { return { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index ebcad040..c5c53c80 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -710,8 +710,6 @@ export class VideoExporter { } const sessionId = this.nativeExportSessionId; - this.nativeExportSessionId = null; - const result = await this.awaitWithFinalizationTimeout( window.electronAPI.nativeVideoExportFinish(sessionId, { audioMode: audioPlan.audioMode, @@ -727,6 +725,7 @@ export class VideoExporter { "native export finalization", audioPlan.audioMode === "none" ? "default" : "audio", ); + this.nativeExportSessionId = null; if (!result.success || !result.data) { return { @@ -1152,6 +1151,7 @@ export class VideoExporter { } this.muxer = null; + this.audioProcessor?.cancel(); this.audioProcessor = null; this.activeFinalizationProgressWatchdog = null; this.lastFinalizationRenderProgress =