From b5cc7777d7b40b4a8813598b24ae6465526fc486 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 14 Mar 2026 11:57:59 +0100 Subject: [PATCH] Fix export finalization stalls on Windows --- src/components/video-editor/ExportDialog.tsx | 10 ++++- src/lib/exporter/audioEncoder.ts | 42 +++++++++++++++----- src/lib/exporter/videoExporter.ts | 18 ++++++++- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/components/video-editor/ExportDialog.tsx b/src/components/video-editor/ExportDialog.tsx index 7c00271..b36020c 100644 --- a/src/components/video-editor/ExportDialog.tsx +++ b/src/components/video-editor/ExportDialog.tsx @@ -66,6 +66,9 @@ export function ExportDialog({ const getStatusMessage = () => { if (error) return "Please try again"; if (isCompiling || isFinalizing) { + if (exportFormat === "mp4") { + return "Finalizing video export..."; + } if (renderProgress !== undefined && renderProgress > 0) { return `Compiling GIF... ${renderProgress}%`; } @@ -77,6 +80,7 @@ export function ExportDialog({ // Get title based on phase const getTitle = () => { if (error) return "Export Failed"; + if (isFinalizing && exportFormat === "mp4") return "Finalizing Video"; if (isCompiling || isFinalizing) return "Compiling GIF"; return `Exporting ${formatLabel}`; }; @@ -233,7 +237,11 @@ export function ExportDialog({ {isCompiling || isFinalizing ? "Status" : "Format"}
- {isCompiling || isFinalizing ? "Compiling..." : formatLabel} + {isFinalizing && exportFormat === "mp4" + ? "Finalizing..." + : isCompiling || isFinalizing + ? "Compiling..." + : formatLabel}
diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index 74be05e..815c8d8 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -8,7 +8,12 @@ const DECODE_BACKPRESSURE_LIMIT = 20; export class AudioProcessor { private cancelled = false; - async process(demuxer: WebDemuxer, muxer: VideoMuxer, trimRegions?: TrimRegion[]): Promise { + async process( + demuxer: WebDemuxer, + muxer: VideoMuxer, + trimRegions?: TrimRegion[], + readEndSec?: number, + ): Promise { let audioConfig: AudioDecoderConfig; try { audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig; @@ -34,19 +39,36 @@ export class AudioProcessor { }); decoder.configure(audioConfig); - const reader = (demuxer.read("audio") as ReadableStream).getReader(); + const safeReadEndSec = + typeof readEndSec === "number" && Number.isFinite(readEndSec) + ? Math.max(0, readEndSec) + : undefined; + const audioStream = ( + safeReadEndSec !== undefined + ? demuxer.read("audio", 0, safeReadEndSec) + : demuxer.read("audio") + ) as ReadableStream; + const reader = audioStream.getReader(); - while (!this.cancelled) { - const { done, value: chunk } = await reader.read(); - if (done || !chunk) break; + try { + while (!this.cancelled) { + const { done, value: chunk } = await reader.read(); + if (done || !chunk) break; - const timestampMs = chunk.timestamp / 1000; - if (this.isInTrimRegion(timestampMs, sortedTrims)) continue; + const timestampMs = chunk.timestamp / 1000; + if (this.isInTrimRegion(timestampMs, sortedTrims)) continue; - decoder.decode(chunk); + decoder.decode(chunk); - while (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT && !this.cancelled) { - await new Promise((resolve) => setTimeout(resolve, 1)); + while (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT && !this.cancelled) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + } + } finally { + try { + await reader.cancel(); + } catch { + /* reader already closed */ } } diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 583b133..060c9b5 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -97,6 +97,7 @@ export class VideoExporter { this.config.speedRegions, ); const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); + const readEndSec = Math.max(videoInfo.duration, videoInfo.streamDuration ?? 0) + 0.5; console.log("[VideoExporter] Original duration:", videoInfo.duration, "s"); console.log("[VideoExporter] Effective duration:", effectiveDuration, "s"); @@ -183,13 +184,28 @@ export class VideoExporter { // Wait for all video muxing operations to complete await Promise.all(this.muxingPromises); + if (this.config.onProgress) { + this.config.onProgress({ + currentFrame: totalFrames, + totalFrames, + percentage: 100, + estimatedTimeRemaining: 0, + phase: "finalizing", + }); + } + // Process audio track if present if (hasAudio && !this.cancelled) { const demuxer = this.streamingDecoder!.getDemuxer(); if (demuxer) { console.log("[VideoExporter] Processing audio track..."); this.audioProcessor = new AudioProcessor(); - await this.audioProcessor.process(demuxer, this.muxer!, this.config.trimRegions); + await this.audioProcessor.process( + demuxer, + this.muxer!, + this.config.trimRegions, + readEndSec, + ); } }