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();