fix: replace real-time audio export with streaming decode + chunked offline rendering

Major rewrite of the audio export pipeline for stability and memory efficiency:

## Streaming Decode
- Primary decode path uses WebDemuxer + AudioDecoder (WebCodecs streaming)
- Falls back to bulk decodeAudioData if streaming fails
- Avoids holding full compressed file + decoded PCM simultaneously

## Chunked Offline Rendering
- Processes timeline in 30-second OfflineAudioContext chunks
- Memory bounded to ~30s of PCM per chunk regardless of recording length

## Chunked WAV Writing
- Writes PCM in ~256KB chunks instead of single massive ArrayBuffer
- Eliminates OOM for long recordings on the native/FFmpeg export path

## Chunked Encoding
- Single AudioEncoder kept alive across all chunks (clean AAC stream)
- Proper backpressure and error propagation per chunk

## scheduleBufferThroughTimeline chunk windowing
- Clips source nodes to chunk boundaries for correct cross-chunk audio
- Backwards compatible defaults when no chunk params provided

## Other export fixes
- Progress cap: 99% -> 100% in both exporters and UI
- Muxing errors: propagated instead of silently swallowed
- Mac recording: enhanced warning when no audio files for muxing
- Export timeout: eliminated by removing real-time rendering
This commit is contained in:
webadderall
2026-04-18 20:29:14 +10:00
parent 8f3a62d811
commit cbe0b9feac
5 changed files with 943 additions and 522 deletions
+4 -1
View File
@@ -158,7 +158,10 @@ export async function muxNativeMacRecordingWithAudio(
}
if (availableAudioInputs.length === 0) {
console.warn("[mux] No valid audio files to mux");
console.warn(
"[mux] No valid audio files to mux — video will have no audio. " +
`system=${systemAudioPath ?? "none"} mic=${microphonePath ?? "none"}`,
);
return;
}
+3 -4
View File
@@ -4177,8 +4177,8 @@ export default function VideoEditor() {
? Math.min(
typeof exportProgress?.renderProgress === "number"
? exportProgress.renderProgress
: (exportProgress?.percentage ?? 99),
99,
: (exportProgress?.percentage ?? 100),
100,
)
: null;
const isLightningExportInProgress =
@@ -4454,8 +4454,7 @@ export default function VideoEditor() {
</p>
{isRenderingAudio ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Audio requires real-time playback for speed/overlay
edits
Processing audio with speed/overlay edits
</p>
) : exportRenderSpeedLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1152,10 +1152,10 @@ export class ModernVideoExporter {
const estimatedTimeRemaining =
averageRenderFps > 0 ? remainingFrames / averageRenderFps : 0;
const safeRenderProgress =
phase === "finalizing" ? Math.max(0, Math.min(renderProgress ?? 99, 99)) : undefined;
phase === "finalizing" ? Math.max(0, Math.min(renderProgress ?? 100, 100)) : undefined;
const percentage =
phase === "finalizing"
? (safeRenderProgress ?? 99)
? (safeRenderProgress ?? 100)
: totalFrames > 0
? (currentFrame / totalFrames) * 100
: 100;
@@ -1399,6 +1399,10 @@ export class ModernVideoExporter {
}
} catch (error) {
console.error("Muxing error:", error);
if (!this.encoderError) {
this.encoderError = error instanceof Error ? error : new Error(String(error));
}
this.cancelled = true;
}
});
this.encodeQueue--;
+2 -2
View File
@@ -885,10 +885,10 @@ export class VideoExporter {
const estimatedTimeRemaining =
averageRenderFps > 0 ? remainingFrames / averageRenderFps : 0;
const safeRenderProgress =
phase === "finalizing" ? Math.max(0, Math.min(renderProgress ?? 99, 99)) : undefined;
phase === "finalizing" ? Math.max(0, Math.min(renderProgress ?? 100, 100)) : undefined;
const percentage =
phase === "finalizing"
? (safeRenderProgress ?? 99)
? (safeRenderProgress ?? 100)
: totalFrames > 0
? (currentFrame / totalFrames) * 100
: 100;