add audio passthrough export path

This commit is contained in:
webadderall
2026-04-04 13:07:34 +11:00
parent 4e83ec98ee
commit c29673b9fc
+134
View File
@@ -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<EncodedAudioChunk>,
audioConfig: AudioDecoderConfig,
muxer: VideoMuxer,
): Promise<boolean> {
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<EncodedAudioChunk>,
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<Blob> {
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)
}