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 {