feat: show real-time audio rendering progress during export finalization

Instead of freezing at 'Finalizing 99%' while audio renders in real-time,
the export UI now shows 'Rendering audio XX%' with a live progress bar and
an explanatory note about why real-time playback is required.

Progress is reported every 250ms from the RAF tick loop in AudioProcessor
and piped through VideoExporter to the UI via the existing onProgress callback.
This commit is contained in:
webadderall
2026-04-09 18:25:25 +10:00
parent 4069b75fc6
commit c85fb7a05a
4 changed files with 48 additions and 14 deletions
+15 -8
View File
@@ -3803,6 +3803,7 @@ export default function VideoEditor() {
const isExportSaving = exportProgress?.phase === "saving";
const isExportFinalizing = exportProgress?.phase === "finalizing";
const isRenderingAudio = isExportFinalizing && typeof exportProgress?.audioProgress === "number";
const exportFinalizingProgress = isExportFinalizing
? Math.min(
typeof exportProgress?.renderProgress === "number"
@@ -3858,13 +3859,17 @@ export default function VideoEditor() {
const exportPercentLabel = exportProgress
? isExportSaving
? t("editor.exportStatus.saving", "Opening save dialog...")
: isExportFinalizing
? t("editor.exportStatus.finalizingPercent", "Finalizing {{percent}}%", {
percent: Math.round(exportFinalizingProgress ?? 99),
})
: t("editor.exportStatus.completePercent", "{{percent}}% complete", {
percent: Math.round(exportProgress.percentage),
: isRenderingAudio
? t("editor.exportStatus.renderingAudio", "Rendering audio {{percent}}%", {
percent: Math.round((exportProgress.audioProgress ?? 0) * 100),
})
: isExportFinalizing
? t("editor.exportStatus.finalizingPercent", "Finalizing {{percent}}%", {
percent: Math.round(exportFinalizingProgress ?? 99),
})
: t("editor.exportStatus.completePercent", "{{percent}}% complete", {
percent: Math.round(exportProgress.percentage),
})
: t("editor.exportStatus.preparing", "Preparing export...");
const projectBrowser = (
@@ -4062,13 +4067,15 @@ export default function VideoEditor() {
<div
className="h-full bg-[#2563EB] transition-all duration-300 ease-out"
style={{
width: `${Math.min(exportFinalizingProgress ?? (exportProgress?.percentage ?? 8), 100)}%`,
width: `${Math.min(isRenderingAudio ? (exportProgress.audioProgress ?? 0) * 100 : (exportFinalizingProgress ?? (exportProgress?.percentage ?? 8)), 100)}%`,
}}
/>
)}
</div>
<p className="mt-2 text-xs text-slate-400">{exportPercentLabel}</p>
{exportRenderSpeedLabel ? (
{isRenderingAudio ? (
<p className="mt-1 text-[11px] text-slate-500">Audio requires real-time playback for speed/overlay edits</p>
) : exportRenderSpeedLabel ? (
<p className="mt-1 text-[11px] text-slate-500">{exportRenderSpeedLabel}</p>
) : null}
{exportRuntimeLabel ? (
+18
View File
@@ -18,6 +18,7 @@ type TrimLikeRegion = TrimRegion | ClipRegion
export class AudioProcessor {
private cancelled = false
private onProgress?: (progress: number) => void
private isPassthroughAudioCodec(codec: string | undefined): boolean {
if (!codec) {
@@ -88,6 +89,10 @@ export class AudioProcessor {
* 1) no speed regions -> fast WebCodecs trim-only pipeline
* 2) speed regions present -> pitch-preserving rendered timeline pipeline
*/
setOnProgress(callback: (progress: number) => void) {
this.onProgress = callback
}
async process(
demuxer: WebDemuxer | null,
muxer: VideoMuxer,
@@ -567,6 +572,9 @@ export class AudioProcessor {
await this.seekTo(timelineMedia, 0)
await timelineMedia.play()
const totalDurationMs = (timelineMedia.duration || 0) * 1000
let lastProgressReport = 0
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
if (rafId !== null) {
@@ -594,6 +602,16 @@ export class AudioProcessor {
return
}
// Report audio rendering progress
if (this.onProgress && totalDurationMs > 0) {
const now = performance.now()
if (now - lastProgressReport > 250) {
lastProgressReport = now
const progress = Math.min((timelineMedia.currentTime * 1000) / totalDurationMs, 1)
this.onProgress(progress)
}
}
let currentTimeMs = timelineMedia.currentTime * 1000
const activeTrimRegion = this.findActiveTrimRegion(currentTimeMs, trimRegions)
+1
View File
@@ -29,6 +29,7 @@ export interface ExportProgress {
encoderName?: string;
phase?: 'extracting' | 'finalizing' | 'saving'; // Phase of export
renderProgress?: number; // 0-100, progress of GIF rendering phase
audioProgress?: number; // 0-1, progress of real-time audio rendering (speed/audio regions)
}
export interface ExportMetrics {
+14 -6
View File
@@ -246,8 +246,8 @@ export class VideoExporter {
this.reportFinalizingProgress(totalFrames, 96);
if (useNativeEncoder && nativeAudioPlan) {
this.reportFinalizingProgress(totalFrames, 99);
return await this.finishNativeVideoExport(nativeAudioPlan);
this.reportFinalizingProgress(totalFrames, 99, 0);
return await this.finishNativeVideoExport(nativeAudioPlan, totalFrames);
}
// Finalize encoding
@@ -264,7 +264,10 @@ export class VideoExporter {
const demuxer = this.streamingDecoder.getDemuxer();
if (demuxer || hasAudioRegions || hasSourceAudioFallback) {
this.audioProcessor = new AudioProcessor();
this.reportFinalizingProgress(totalFrames, 99);
this.audioProcessor.setOnProgress((progress) => {
this.reportFinalizingProgress(totalFrames, 99, progress);
});
this.reportFinalizingProgress(totalFrames, 99, 0);
await this.awaitWithFinalizationTimeout(
this.audioProcessor.process(
demuxer,
@@ -502,7 +505,7 @@ export class VideoExporter {
}
}
private async finishNativeVideoExport(audioPlan: NativeAudioPlan): Promise<ExportResult> {
private async finishNativeVideoExport(audioPlan: NativeAudioPlan, totalFrames: number): Promise<ExportResult> {
if (!this.nativeExportSessionId) {
return { success: false, error: "Native export session is not active" };
}
@@ -512,6 +515,9 @@ export class VideoExporter {
if (audioPlan.audioMode === "edited-track") {
this.audioProcessor = new AudioProcessor();
this.audioProcessor.setOnProgress((progress) => {
this.reportFinalizingProgress(totalFrames, 99, progress);
});
const audioBlob = await this.awaitWithFinalizationTimeout(
this.audioProcessor.renderEditedAudioTrack(
this.config.videoUrl,
@@ -593,8 +599,8 @@ export class VideoExporter {
exportFrame.close();
}
private reportFinalizingProgress(totalFrames: number, renderProgress: number) {
this.reportProgress(totalFrames, totalFrames, "finalizing", renderProgress);
private reportFinalizingProgress(totalFrames: number, renderProgress: number, audioProgress?: number) {
this.reportProgress(totalFrames, totalFrames, "finalizing", renderProgress, audioProgress);
}
private reportProgress(
@@ -602,6 +608,7 @@ export class VideoExporter {
totalFrames: number,
phase: ExportProgress["phase"] = "extracting",
renderProgress?: number,
audioProgress?: number,
) {
const nowMs = this.getNowMs();
const elapsedSeconds = Math.max((nowMs - this.exportStartTimeMs) / 1000, 0.001);
@@ -637,6 +644,7 @@ export class VideoExporter {
renderFps,
phase,
renderProgress: safeRenderProgress,
audioProgress: typeof audioProgress === "number" ? Math.max(0, Math.min(audioProgress, 1)) : undefined,
});
}
}