From 5e62ad3215b0c9b24ab3aaa0568675b35e4fab26 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Sun, 12 Apr 2026 17:54:43 -0500 Subject: [PATCH 1/7] fix: validate export duration and fix audio trim in speed-aware path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the export pipeline: 1. Container duration from WebM metadata can be unreliable (Chromium bug on Linux — reports Infinity, 0, or inflated values). The pipeline trusted this value, causing inflated exports, frozen video, and "decode ended early" errors. Fix: scan actual packet timestamps in loadMetadata() and compare against container duration. Use packet-based ground truth when they diverge. 2. The speed-aware audio path (renderPitchPreservedTimelineAudio) recorded in real-time via MediaRecorder but never paused recording during trim-region seeks. Seek dead time was captured as audio, inflating the audio track beyond the video duration. Fix: pause MediaRecorder during trim seeks, skip past initial trim before recording starts, wait for seek completion before resuming. Fixes #276, #433. Partially addresses #428. --- src/lib/exporter/audioEncoder.ts | 106 +++++++++++++++++++--- src/lib/exporter/streamingDecoder.test.ts | 33 ++++++- src/lib/exporter/streamingDecoder.ts | 54 ++++++++++- src/lib/exporter/videoExporter.ts | 10 +- 4 files changed, 180 insertions(+), 23 deletions(-) diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index 490eed2..e373702 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -5,6 +5,7 @@ import type { VideoMuxer } from "./muxer"; const AUDIO_BITRATE = 128_000; const DECODE_BACKPRESSURE_LIMIT = 20; const MIN_SPEED_REGION_DELTA_MS = 0.0001; +const SEEK_TIMEOUT_MS = 5_000; export class AudioProcessor { private cancelled = false; @@ -20,7 +21,7 @@ export class AudioProcessor { videoUrl: string, trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[], - readEndSec?: number, + validatedDurationSec?: number, ): Promise { const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : []; const sortedSpeedRegions = speedRegions @@ -35,14 +36,20 @@ export class AudioProcessor { videoUrl, sortedTrims, sortedSpeedRegions, + validatedDurationSec, ); - if (!this.cancelled) { + if (!this.cancelled && renderedAudioBlob.size > 0) { await this.muxRenderedAudioBlob(renderedAudioBlob, muxer); return; } + return; } // No speed edits: keep the original demux/decode/encode path with trim timestamp remap. + const readEndSec = + typeof validatedDurationSec === "number" && Number.isFinite(validatedDurationSec) + ? validatedDurationSec + 0.5 + : undefined; await this.processTrimOnlyAudio(demuxer, muxer, sortedTrims, readEndSec); } @@ -187,6 +194,7 @@ export class AudioProcessor { videoUrl: string, trimRegions: TrimRegion[], speedRegions: SpeedRegion[], + validatedDurationSec?: number, ): Promise { const media = document.createElement("audio"); media.src = videoUrl; @@ -211,15 +219,41 @@ export class AudioProcessor { const destinationNode = audioContext.createMediaStreamDestination(); sourceNode.connect(destinationNode); - const { recorder, recordedBlobPromise } = this.startAudioRecording(destinationNode.stream); let rafId: number | null = null; + let recorder: MediaRecorder | null = null; + let recordedBlobPromise: Promise | null = null; try { if (audioContext.state === "suspended") { await audioContext.resume(); } - await this.seekTo(media, 0); + // Skip past any initial trim region before recording starts + // to avoid capturing trimmed audio during the first frames. + let startPosition = 0; + const initialTrim = this.findActiveTrimRegion(0, trimRegions); + if (initialTrim) { + startPosition = initialTrim.endMs / 1000; + } + + const effectiveEnd = validatedDurationSec ?? media.duration; + if (startPosition >= effectiveEnd) { + // All content is trimmed — return silent blob + return new Blob([], { type: "audio/webm" }); + } + + await this.seekTo(media, startPosition); + + // Set initial playback rate for the starting position + const initialSpeedRegion = this.findActiveSpeedRegion(startPosition * 1000, speedRegions); + if (initialSpeedRegion) { + media.playbackRate = initialSpeedRegion.speed; + } + + // Start recording only AFTER seeking past trims + const recording = this.startAudioRecording(destinationNode.stream); + recorder = recording.recorder; + recordedBlobPromise = recording.recordedBlobPromise; await media.play(); await new Promise((resolve, reject) => { @@ -249,24 +283,69 @@ export class AudioProcessor { return; } + // Stop playback at validated duration — browser's media.duration + // may be inflated from bad container metadata. + if (validatedDurationSec !== undefined && media.currentTime >= validatedDurationSec) { + media.pause(); + cleanup(); + resolve(); + return; + } + const currentTimeMs = media.currentTime * 1000; const activeTrimRegion = this.findActiveTrimRegion(currentTimeMs, trimRegions); if (activeTrimRegion && !media.paused && !media.ended) { const skipToTime = activeTrimRegion.endMs / 1000; - if (skipToTime >= media.duration) { + if ( + skipToTime >= media.duration || + (validatedDurationSec !== undefined && skipToTime >= validatedDurationSec) + ) { media.pause(); cleanup(); resolve(); return; } + // Pause recording during trim seek to prevent capturing + // silence/noise as the audio element seeks. + media.pause(); + if (recorder?.state === "recording") recorder.pause(); + const onSeeked = () => { + clearTimeout(seekTimer); + if (this.cancelled) { + cleanup(); + resolve(); + return; + } + if (recorder?.state === "paused") recorder.resume(); + media + .play() + .then(() => { + if (!this.cancelled) rafId = requestAnimationFrame(tick); + }) + .catch((err) => { + cleanup(); + reject( + new Error( + `Failed to resume playback after trim seek: ${err instanceof Error ? err.message : String(err)}`, + ), + ); + }); + }; + const seekTimer = window.setTimeout(() => { + media.removeEventListener("seeked", onSeeked); + cleanup(); + reject(new Error("Audio seek timed out while skipping trim region")); + }, SEEK_TIMEOUT_MS); + media.addEventListener("seeked", onSeeked, { once: true }); media.currentTime = skipToTime; - } else { - const activeSpeedRegion = this.findActiveSpeedRegion(currentTimeMs, speedRegions); - const playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; - if (Math.abs(media.playbackRate - playbackRate) > 0.0001) { - media.playbackRate = playbackRate; - } + return; + } + + const activeSpeedRegion = this.findActiveSpeedRegion(currentTimeMs, speedRegions); + const playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; + if (Math.abs(media.playbackRate - playbackRate) > 0.0001) { + media.playbackRate = playbackRate; } if (!media.paused && !media.ended) { @@ -286,7 +365,7 @@ export class AudioProcessor { cancelAnimationFrame(rafId); } media.pause(); - if (recorder.state !== "inactive") { + if (recorder && recorder.state !== "inactive") { recorder.stop(); } destinationNode.stream.getTracks().forEach((track) => track.stop()); @@ -297,6 +376,9 @@ export class AudioProcessor { media.load(); } + if (!recordedBlobPromise) { + return new Blob([], { type: "audio/webm" }); + } const recordedBlob = await recordedBlobPromise; if (this.cancelled) { throw new Error("Export cancelled"); diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts index 1969c84..e3a0ecc 100644 --- a/src/lib/exporter/streamingDecoder.test.ts +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -1,5 +1,36 @@ import { describe, expect, it } from "vitest"; -import { shouldFailDecodeEndedEarly } from "./streamingDecoder"; +import { shouldFailDecodeEndedEarly, validateDuration } from "./streamingDecoder"; + +describe("validateDuration", () => { + it("returns scanned duration when container reports Infinity", () => { + expect(validateDuration(Infinity, 15.3)).toBe(15.3); + }); + + it("returns scanned duration when container reports 0", () => { + expect(validateDuration(0, 15.3)).toBe(15.3); + }); + + it("returns scanned duration when container reports NaN", () => { + expect(validateDuration(NaN, 15.3)).toBe(15.3); + }); + + it("returns scanned duration when container is inflated beyond threshold", () => { + expect(validateDuration(42, 15.3)).toBe(15.3); + }); + + it("returns container duration when values are close", () => { + expect(validateDuration(15.5, 15.3)).toBe(15.5); + }); + + it("returns container duration when scanned is slightly higher", () => { + // container < scanned (scanned overshoot from last frame duration) + expect(validateDuration(15.0, 15.3)).toBe(15.0); + }); + + it("returns container duration when scanned is zero (corrupted/empty file)", () => { + expect(validateDuration(10, 0)).toBe(10); + }); +}); describe("shouldFailDecodeEndedEarly", () => { it("does not fail once every segment has been satisfied", () => { diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 651a557..7e261ff 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -70,6 +70,32 @@ type EarlyDecodeEndCheck = { const EARLY_DECODE_END_THRESHOLD_SEC = 1; const METADATA_TAIL_TOLERANCE_SEC = 1.5; const STREAM_DURATION_MATCH_TOLERANCE_SEC = 0.25; +const DURATION_DIVERGENCE_THRESHOLD_SEC = 1.5; + +/** + * Validate container duration against actual packet timestamps. + * + * Chrome/Electron's MediaRecorder writes WebM containers with unreliable + * Duration fields (often Infinity, 0, or inflated) — especially on Linux. + * This function picks the most trustworthy duration value. + * + * @param containerDuration Duration from the container-level metadata + * @param scannedDuration Duration derived from actual packet timestamps (ground truth) + */ +export function validateDuration(containerDuration: number, scannedDuration: number): number { + if (scannedDuration <= 0) { + // Zero scanned duration means corrupted/empty file — fall back to container + // (downstream shouldFailDecodeEndedEarly will catch truly empty files) + return Math.max(containerDuration, 0); + } + if (!Number.isFinite(containerDuration) || containerDuration <= 0) { + return scannedDuration; + } + if (containerDuration - scannedDuration > DURATION_DIVERGENCE_THRESHOLD_SEC) { + return scannedDuration; + } + return containerDuration; +} export function shouldFailDecodeEndedEarly({ cancelled, @@ -201,10 +227,34 @@ export class StreamingVideoDecoder { const audioStream = mediaInfo.streams.find((s) => s.codec_type_string === "audio"); + // Scan video packets to find the true content boundary. + // MediaRecorder (especially on Linux) writes unreliable container durations. + // Packet timestamps are ground truth — no decode needed, just timestamp reads. + let maxPacketEndUs = 0; + const scanReader = ( + this.demuxer.read("video") as ReadableStream + ).getReader(); + try { + while (true) { + const { done, value } = await scanReader.read(); + if (done || !value) break; + const endUs = value.timestamp + (value.duration ?? 0); + if (endUs > maxPacketEndUs) maxPacketEndUs = endUs; + } + } finally { + try { + await scanReader.cancel(); + } catch { + /* already closed */ + } + } + const scannedDuration = maxPacketEndUs / 1_000_000; + const validatedDuration = validateDuration(mediaInfo.duration, scannedDuration); + this.metadata = { width: videoStream?.width || 1920, height: videoStream?.height || 1080, - duration: mediaInfo.duration, + duration: validatedDuration, streamDuration: typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) ? videoStream.duration @@ -305,7 +355,7 @@ export class StreamingVideoDecoder { // One forward stream through the whole file. // Pass explicit range because some containers are truncated when no end is provided. - const readEndSec = Math.max(this.metadata.duration, this.metadata.streamDuration ?? 0) + 0.5; + const readEndSec = this.metadata.duration + 0.5; const reader = this.demuxer.read("video", 0, readEndSec).getReader(); // Feed chunks to decoder in background with backpressure diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index dcfcc3e..d007b30 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -157,17 +157,11 @@ export class VideoExporter { this.muxer = muxer; await muxer.initialize(); - const { effectiveDuration, totalFrames } = streamingDecoder.getExportMetrics( + const { totalFrames } = streamingDecoder.getExportMetrics( this.config.frameRate, this.config.trimRegions, this.config.speedRegions, ); - 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"); - console.log("[VideoExporter] Total frames to export:", totalFrames); - console.log("[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)"); const frameDuration = 1_000_000 / this.config.frameRate; let frameIndex = 0; @@ -346,7 +340,7 @@ export class VideoExporter { this.config.videoUrl, this.config.trimRegions, this.config.speedRegions, - readEndSec, + videoInfo.duration, ); } } From 337838294d69bf43089c993127bf44641794ba13 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Sun, 12 Apr 2026 18:06:01 -0500 Subject: [PATCH 2/7] fix: pass explicit range to packet scan read Some containers are truncated when read() has no end bound. Use container/stream duration + buffer as scan range, matching the same pattern used in decodeAll(). --- src/lib/exporter/streamingDecoder.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 7e261ff..cc5ded5 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -230,9 +230,11 @@ export class StreamingVideoDecoder { // Scan video packets to find the true content boundary. // MediaRecorder (especially on Linux) writes unreliable container durations. // Packet timestamps are ground truth — no decode needed, just timestamp reads. + // Pass explicit range because some containers are truncated without one. + const scanEndSec = Math.max(mediaInfo.duration, videoStream?.duration ?? 0, 0) + 0.5; let maxPacketEndUs = 0; const scanReader = ( - this.demuxer.read("video") as ReadableStream + this.demuxer.read("video", 0, scanEndSec) as ReadableStream ).getReader(); try { while (true) { From 83ea025ed8168456ab3573d3397efbdeb6f5c235 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Sun, 12 Apr 2026 18:17:50 -0500 Subject: [PATCH 3/7] fix: handle NaN in zero-scan fallback and symmetric divergence check - validateDuration returns 0 instead of NaN when both container is NaN and scanned is zero - Use Math.abs for divergence check so container under-reporting is also corrected (not just over-reporting) --- src/lib/exporter/streamingDecoder.test.ts | 8 ++++++++ src/lib/exporter/streamingDecoder.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts index e3a0ecc..55b9123 100644 --- a/src/lib/exporter/streamingDecoder.test.ts +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -27,9 +27,17 @@ describe("validateDuration", () => { expect(validateDuration(15.0, 15.3)).toBe(15.0); }); + it("returns scanned duration when container under-reports beyond threshold", () => { + expect(validateDuration(10, 15.3)).toBe(15.3); + }); + it("returns container duration when scanned is zero (corrupted/empty file)", () => { expect(validateDuration(10, 0)).toBe(10); }); + + it("returns 0 when both container is NaN and scanned is zero", () => { + expect(validateDuration(NaN, 0)).toBe(0); + }); }); describe("shouldFailDecodeEndedEarly", () => { diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index cc5ded5..b0866f5 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -86,12 +86,12 @@ export function validateDuration(containerDuration: number, scannedDuration: num if (scannedDuration <= 0) { // Zero scanned duration means corrupted/empty file — fall back to container // (downstream shouldFailDecodeEndedEarly will catch truly empty files) - return Math.max(containerDuration, 0); + return Number.isFinite(containerDuration) ? Math.max(containerDuration, 0) : 0; } if (!Number.isFinite(containerDuration) || containerDuration <= 0) { return scannedDuration; } - if (containerDuration - scannedDuration > DURATION_DIVERGENCE_THRESHOLD_SEC) { + if (Math.abs(containerDuration - scannedDuration) > DURATION_DIVERGENCE_THRESHOLD_SEC) { return scannedDuration; } return containerDuration; From 61e895a75a1436a8b8985b4a975238b5b2ce519e Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Thu, 16 Apr 2026 14:18:40 -0500 Subject: [PATCH 4/7] fix: sanitize packet-scan range against NaN/Infinity duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mediaInfo.duration from web-demuxer can be NaN or Infinity on Chromium Linux (same MediaRecorder bug this PR otherwise addresses). That value flowed straight into Math.max + demuxer.read() as scanEndSec, producing an invalid range argument and breaking the ground-truth packet scan. Guard both mediaInfo.duration and videoStream.duration with Number.isFinite before Math.max; validateDuration() already handled the downstream use. Drop redundant WebDemuxer.read() / getDecoderConfig() type casts while here — the generics infer the chunk/config type from the media string literal, so the `as ReadableStream` and `as AudioDecoderConfig` are no-ops. --- src/lib/exporter/audioEncoder.ts | 11 +++++------ src/lib/exporter/streamingDecoder.ts | 13 +++++++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index e373702..611ef5b 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -62,7 +62,7 @@ export class AudioProcessor { ): Promise { let audioConfig: AudioDecoderConfig; try { - audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig; + audioConfig = await demuxer.getDecoderConfig("audio"); } catch { console.warn("[AudioProcessor] No audio track found, skipping"); return; @@ -87,11 +87,10 @@ export class AudioProcessor { typeof readEndSec === "number" && Number.isFinite(readEndSec) ? Math.max(0, readEndSec) : undefined; - const audioStream = ( + const audioStream = safeReadEndSec !== undefined ? demuxer.read("audio", 0, safeReadEndSec) - : demuxer.read("audio") - ) as ReadableStream; + : demuxer.read("audio"); const reader = audioStream.getReader(); try { @@ -396,8 +395,8 @@ export class AudioProcessor { try { await demuxer.load(file); - const audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig; - const reader = (demuxer.read("audio") as ReadableStream).getReader(); + const audioConfig = await demuxer.getDecoderConfig("audio"); + const reader = demuxer.read("audio").getReader(); let isFirstChunk = true; try { diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index b0866f5..fca92e2 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -231,11 +231,16 @@ export class StreamingVideoDecoder { // MediaRecorder (especially on Linux) writes unreliable container durations. // Packet timestamps are ground truth — no decode needed, just timestamp reads. // Pass explicit range because some containers are truncated without one. - const scanEndSec = Math.max(mediaInfo.duration, videoStream?.duration ?? 0, 0) + 0.5; + // Sanitize because mediaInfo.duration can be NaN/Infinity (Chromium Linux bug), + // which would propagate into demuxer.read() as an invalid endpoint. + const containerDurationSec = Number.isFinite(mediaInfo.duration) ? mediaInfo.duration : 0; + const streamDurationSec = + typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) + ? videoStream.duration + : 0; + const scanEndSec = Math.max(containerDurationSec, streamDurationSec, 0) + 0.5; let maxPacketEndUs = 0; - const scanReader = ( - this.demuxer.read("video", 0, scanEndSec) as ReadableStream - ).getReader(); + const scanReader = this.demuxer.read("video", 0, scanEndSec).getReader(); try { while (true) { const { done, value } = await scanReader.read(); From 4d4b08db07811e2c248f9e1bd45de457cee76e35 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Thu, 16 Apr 2026 14:31:51 -0500 Subject: [PATCH 5/7] fix: skip chained initial trims before recording starts Startup trim-skip only consulted the first active region at t=0, so back-to-back or overlapping trims starting at zero (e.g. [0,500ms] followed by [500ms,1000ms]) left the second region un-skipped. The in-flight tick loop would catch it, but MediaRecorder was already running by then, capturing up to one rAF frame of trimmed audio into the blob and shifting the downstream timeline. Loop findActiveTrimRegion from the advancing startPosition until no region matches or startPosition >= effectiveEnd, bounded by trimRegions.length for safety. Recompute initialSpeedRegion from the final startPosition so playbackRate reflects the true start point. --- src/lib/exporter/audioEncoder.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index 611ef5b..64d46d0 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -227,15 +227,18 @@ export class AudioProcessor { await audioContext.resume(); } - // Skip past any initial trim region before recording starts - // to avoid capturing trimmed audio during the first frames. + // Skip past any initial trim region(s) before recording starts to avoid + // capturing trimmed audio during the first rAF frames of playback. + // Loops to handle back-to-back or overlapping trims at t=0. + const effectiveEnd = validatedDurationSec ?? media.duration; let startPosition = 0; - const initialTrim = this.findActiveTrimRegion(0, trimRegions); - if (initialTrim) { - startPosition = initialTrim.endMs / 1000; + for (let i = 0; i <= trimRegions.length; i++) { + const activeTrim = this.findActiveTrimRegion(startPosition * 1000, trimRegions); + if (!activeTrim) break; + startPosition = activeTrim.endMs / 1000; + if (startPosition >= effectiveEnd) break; } - const effectiveEnd = validatedDurationSec ?? media.duration; if (startPosition >= effectiveEnd) { // All content is trimmed — return silent blob return new Blob([], { type: "audio/webm" }); From 0c01db7afa99f11e13624825135a6bdf5de7a993 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Thu, 16 Apr 2026 14:33:27 -0500 Subject: [PATCH 6/7] fix: fall back to unbounded packet scan when duration hints missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier NaN/Infinity guard collapsed both duration hints to 0 when the container reported invalid values, which turned scanEndSec into 0.5s. The packet scan then read only the first half-second, scannedDuration capped there, and validateDuration fell back to that wrong value for the entire export — exactly the Chromium Linux case this PR is meant to fix. Use a 24h sentinel as the read endpoint when no hint is usable. An explicit end is still required (some containers are truncated without one, per prior comment), but the sentinel is large enough to exceed any realistic recording so the scan reaches real EOF. --- src/lib/exporter/streamingDecoder.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index fca92e2..00d9f0b 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -71,6 +71,11 @@ const EARLY_DECODE_END_THRESHOLD_SEC = 1; const METADATA_TAIL_TOLERANCE_SEC = 1.5; const STREAM_DURATION_MATCH_TOLERANCE_SEC = 0.25; const DURATION_DIVERGENCE_THRESHOLD_SEC = 1.5; +// Fallback upper bound for the packet scan when no reliable duration hint is +// available. Explicit end is required (some containers are truncated without +// one), but the hint-derived bound would cap the scan prematurely when +// container/stream duration are missing or corrupt. +const SCAN_UNBOUNDED_FALLBACK_SEC = 24 * 60 * 60; /** * Validate container duration against actual packet timestamps. @@ -238,7 +243,9 @@ export class StreamingVideoDecoder { typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) ? videoStream.duration : 0; - const scanEndSec = Math.max(containerDurationSec, streamDurationSec, 0) + 0.5; + const hintedDurationSec = Math.max(containerDurationSec, streamDurationSec, 0); + const scanEndSec = + hintedDurationSec > 0 ? hintedDurationSec + 0.5 : SCAN_UNBOUNDED_FALLBACK_SEC; let maxPacketEndUs = 0; const scanReader = this.demuxer.read("video", 0, scanEndSec).getReader(); try { From dd8c001f6d0a3b74379d78e39b514abf95e383ca Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Thu, 16 Apr 2026 14:49:27 -0500 Subject: [PATCH 7/7] refactor: require validatedDurationSec in AudioProcessor, drop fallbacks AudioProcessor.process and renderPitchPreservedTimelineAudio accepted validatedDurationSec as optional, so the speed-aware path fell back to media.duration when it was absent. HTMLMediaElement.duration can be Infinity for the same MediaRecorder/Chromium Linux containers this PR targets, which would make effectiveEnd and the playback stop checks unreliable. The only caller (VideoExporter.process) already threads streamingDecoder's validatedDuration through, so make the parameter required. Drop the media.duration fallback, the Number.isFinite guard on readEndSec, and the two `!== undefined` checks in the tick loop. While here: - Document that +0.5 on readEndSec mirrors streamingDecoder.decodeAll's read window so trim-only and speed-aware paths stay in sync. - Replace the unreachable silent-blob fallback at the end of renderPitchPreservedTimelineAudio with a loud invariant throw, so a broken recorder contract surfaces instead of yielding empty audio. --- src/lib/exporter/audioEncoder.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index 64d46d0..08cdaf1 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -19,9 +19,9 @@ export class AudioProcessor { demuxer: WebDemuxer, muxer: VideoMuxer, videoUrl: string, - trimRegions?: TrimRegion[], - speedRegions?: SpeedRegion[], - validatedDurationSec?: number, + trimRegions: TrimRegion[] | undefined, + speedRegions: SpeedRegion[] | undefined, + validatedDurationSec: number, ): Promise { const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : []; const sortedSpeedRegions = speedRegions @@ -46,10 +46,9 @@ export class AudioProcessor { } // No speed edits: keep the original demux/decode/encode path with trim timestamp remap. - const readEndSec = - typeof validatedDurationSec === "number" && Number.isFinite(validatedDurationSec) - ? validatedDurationSec + 0.5 - : undefined; + // The +0.5s buffer mirrors streamingDecoder.decodeAll's read window so the trim-only + // and speed-aware paths agree on how far to read past the validated duration boundary. + const readEndSec = validatedDurationSec + 0.5; await this.processTrimOnlyAudio(demuxer, muxer, sortedTrims, readEndSec); } @@ -193,7 +192,7 @@ export class AudioProcessor { videoUrl: string, trimRegions: TrimRegion[], speedRegions: SpeedRegion[], - validatedDurationSec?: number, + validatedDurationSec: number, ): Promise { const media = document.createElement("audio"); media.src = videoUrl; @@ -230,7 +229,7 @@ export class AudioProcessor { // Skip past any initial trim region(s) before recording starts to avoid // capturing trimmed audio during the first rAF frames of playback. // Loops to handle back-to-back or overlapping trims at t=0. - const effectiveEnd = validatedDurationSec ?? media.duration; + const effectiveEnd = validatedDurationSec; let startPosition = 0; for (let i = 0; i <= trimRegions.length; i++) { const activeTrim = this.findActiveTrimRegion(startPosition * 1000, trimRegions); @@ -287,7 +286,7 @@ export class AudioProcessor { // Stop playback at validated duration — browser's media.duration // may be inflated from bad container metadata. - if (validatedDurationSec !== undefined && media.currentTime >= validatedDurationSec) { + if (media.currentTime >= validatedDurationSec) { media.pause(); cleanup(); resolve(); @@ -299,10 +298,7 @@ export class AudioProcessor { if (activeTrimRegion && !media.paused && !media.ended) { const skipToTime = activeTrimRegion.endMs / 1000; - if ( - skipToTime >= media.duration || - (validatedDurationSec !== undefined && skipToTime >= validatedDurationSec) - ) { + if (skipToTime >= media.duration || skipToTime >= validatedDurationSec) { media.pause(); cleanup(); resolve(); @@ -379,7 +375,10 @@ export class AudioProcessor { } if (!recordedBlobPromise) { - return new Blob([], { type: "audio/webm" }); + // Invariant: either an early return above fires, or startAudioRecording ran and + // populated recordedBlobPromise before the playback Promise resolved. Reaching + // here means that contract was broken — fail loud instead of returning silence. + throw new Error("Audio recorder finished without assigning recordedBlobPromise"); } const recordedBlob = await recordedBlobPromise; if (this.cancelled) {