diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index a235a21f..cca0485d 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -12,6 +12,70 @@ const MP4_AUDIO_CODEC = 'mp4a.40.2' export class AudioProcessor { private cancelled = false + private isPassthroughAudioCodec(codec: string | undefined): boolean { + if (!codec) { + return false + } + + const normalizedCodec = codec.toLowerCase() + return ( + normalizedCodec === MP4_AUDIO_CODEC + || normalizedCodec === 'aac' + || normalizedCodec.startsWith('mp4a.40.2') + ) + } + + private async passthroughAudioStream( + audioStream: ReadableStream, + audioConfig: AudioDecoderConfig, + muxer: VideoMuxer, + ): Promise { + if (!this.isPassthroughAudioCodec(audioConfig.codec)) { + return false + } + + const reader = audioStream.getReader() + let wroteAudio = false + let passthroughTimestampOffsetUs: number | null = null + + try { + while (!this.cancelled) { + const { done, value: chunk } = await reader.read() + if (done || !chunk) break + + if (passthroughTimestampOffsetUs === null) { + passthroughTimestampOffsetUs = Math.min(0, chunk.timestamp) + } + + const normalizedTimestamp = Math.max( + 0, + chunk.timestamp - passthroughTimestampOffsetUs, + ) + const outputChunk = passthroughTimestampOffsetUs === 0 + ? chunk + : this.cloneEncodedAudioChunkWithTimestamp(chunk, normalizedTimestamp) + + await muxer.addAudioChunk( + outputChunk, + wroteAudio + ? undefined + : { + decoderConfig: audioConfig, + }, + ) + wroteAudio = true + } + } finally { + try { + await reader.cancel() + } catch { + // reader already closed + } + } + + return wroteAudio + } + /** * Audio export has two modes: * 1) no speed regions -> fast WebCodecs trim-only pipeline @@ -65,9 +129,64 @@ export class AudioProcessor { return } + if (sortedTrims.length === 0) { + let audioConfig: AudioDecoderConfig + try { + audioConfig = (await demuxer.getDecoderConfig('audio')) as AudioDecoderConfig + } catch { + console.warn('[AudioProcessor] No audio track found, skipping') + return + } + + const audioStream = typeof readEndSec === 'number' + ? demuxer.read('audio', 0, readEndSec) + : demuxer.read('audio') + + const copiedSourceAudio = await this.passthroughAudioStream( + audioStream as ReadableStream, + audioConfig, + muxer, + ) + + if (copiedSourceAudio) { + return + } + } + await this.processTrimOnlyAudio(demuxer, muxer, sortedTrims, readEndSec) } + async renderEditedAudioTrack( + videoUrl: string, + trimRegions?: TrimRegion[], + speedRegions?: SpeedRegion[], + audioRegions?: AudioRegion[], + sourceAudioFallbackPaths?: string[], + ): Promise { + const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : [] + const sortedSpeedRegions = speedRegions + ? [...speedRegions] + .filter((region) => region.endMs - region.startMs > MIN_SPEED_REGION_DELTA_MS) + .sort((a, b) => a.startMs - b.startMs) + : [] + const sortedAudioRegions = audioRegions + ? [...audioRegions].sort((a, b) => a.startMs - b.startMs) + : [] + const sortedSourceAudioFallbackPaths = sourceAudioFallbackPaths + ? sourceAudioFallbackPaths.filter( + (audioPath) => typeof audioPath === 'string' && audioPath.trim().length > 0, + ) + : [] + + return this.renderMixedTimelineAudio( + videoUrl, + sortedTrims, + sortedSpeedRegions, + sortedAudioRegions, + sortedSourceAudioFallbackPaths, + ) + } + // Legacy trim-only path used when no speed regions are configured. private async processTrimOnlyAudio( demuxer: WebDemuxer, @@ -728,6 +847,21 @@ export class AudioProcessor { }) } + private cloneEncodedAudioChunkWithTimestamp( + src: EncodedAudioChunk, + newTimestamp: number, + ): EncodedAudioChunk { + const data = new Uint8Array(src.byteLength) + src.copyTo(data) + + return new EncodedAudioChunk({ + type: src.type, + timestamp: newTimestamp, + duration: src.duration ?? undefined, + data, + }) + } + private isInTrimRegion(timestampMs: number, trims: TrimRegion[]) { return trims.some((trim) => timestampMs >= trim.startMs && timestampMs < trim.endMs) }