From 7d053ae50d3b0280920e62ae8736aea47b661c19 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:07:02 +0200 Subject: [PATCH] Add ConversionOptions.tracks, add better converstion start timestamp computation, optimize endPacket logic, utilize metadata duration for progress callback for better performance --- dev/convert.html | 11 ++-- dev/demux.html | 13 ++++- src/conversion.ts | 132 +++++++++++++++++++++++++++++++++++++--------- src/input.ts | 5 +- src/media-sink.ts | 2 +- todo.txt | 6 --- 6 files changed, 131 insertions(+), 38 deletions(-) delete mode 100644 todo.txt diff --git a/dev/convert.html b/dev/convert.html index 6b88382..fc1d2cf 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -60,11 +60,12 @@ if (true) { input = new Mediabunny.Input({ - entryPath: 'https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8', + entryPath: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', source: ({ path }) => new Mediabunny.UrlSource(path), formats: Mediabunny.ALL_FORMATS, }); + /* const videoTrack = await input.getPrimaryVideoTrack(); const audioTrack = await input.getPrimaryAudioTrack(); @@ -75,6 +76,7 @@ videoTrack.computeDuration({ skipLiveWait: true }), audioTrack.computeDuration({ skipLiveWait: true }), ])); + */ } else { const file = fileInput.files[0]; const source = new Mediabunny.BlobSource(file); @@ -90,7 +92,8 @@ input, output, audio: (track, n) => ({ - discard: !tracks.includes(track), + //discard: true, + //discard: !tracks.includes(track), //codec: 'eac3', //discard: true, //discard: n > 1, @@ -137,7 +140,8 @@ }, */ video: (track) => ({ - discard: !tracks.includes(track), + //discard: true, + //discard: !tracks.includes(track), //forceTranscode: true, //forceTranscode: true, //width: 1280, @@ -222,6 +226,7 @@ } }, trim: { + end: 10, //start, //end: start + 5, ////start: 0, diff --git a/dev/demux.html b/dev/demux.html index d7a8a32..7aaf58a 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -11,6 +11,7 @@ document.body.append(fileInput); fileInput.addEventListener('change', async () => { + /* const file = fileInput.files[0]; const input = new Mediabunny.Input({ formats: Mediabunny.ALL_FORMATS, @@ -25,14 +26,22 @@ //console.log(await input.getDurationFromMetadata(), await input.computeDuration()); return; + */ const manifest = new Mediabunny.Input({ - entryPath: 'https://playertest.longtailvideo.com/adaptive/alt-audio-no-video/sintel/playlist.m3u8', + entryPath: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', source: ({ path }) => new Mediabunny.UrlSource(path), formats: Mediabunny.ALL_FORMATS, }); + + const audioTrack = await manifest.getPrimaryAudioTrack(); + const ugh = new Mediabunny.EncodedPacketSink(audioTrack); - console.log(await manifest.getTracks()); + for await (const packet of ugh.packets()) { + console.log(packet); + } + + //console.log(await manifest.getTracks()); return; const videoTrack = await manifest.getPrimaryVideoTrack(); diff --git a/src/conversion.ts b/src/conversion.ts index 2829490..e4430a7 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -38,6 +38,7 @@ import { } from './media-source'; import { assert, + assertNever, ceilToMultipleOfTwo, clamp, floorToDivisor, @@ -65,6 +66,17 @@ export type ConversionOptions = { /** The output file. */ output: Output; + /** + * Defines which input tracks are used for conversion. Defaults to `'all'` unless the input is an HLS input, in + * which case it defaults to `'primary'`. + * + * - `'all'`: All input tracks are eligible for conversion. + * - `'primary'`: Only the primary video and audio track from the input are eligible for conversion. + * - Array: A user-specified list of tracks that are eligible for conversion. These must all belong to the + * {@link Input} specified in {@link ConversionOptions.input}. + */ + tracks?: 'all' | 'primary' | InputTrack[]; + /** * Video-specific options. When passing an object, the same options are applied to all video tracks. When passing a * function, it will be invoked for each video track and is expected to return or resolve to the options @@ -547,6 +559,21 @@ export class Conversion { if (!(options.output instanceof Output)) { throw new TypeError('options.output must be an Output.'); } + if ( + options.tracks !== undefined + && !( + options.tracks === 'all' + || options.tracks === 'primary' + || (Array.isArray(options.tracks) && options.tracks.every(x => x instanceof InputTrack)) + ) + ) { + throw new TypeError( + 'options.tracks, when provded, must be either \'all\', \'primary\', or an array of InputTrack.', + ); + } + if (Array.isArray(options.tracks) && !options.tracks.every(x => x.input === options.input)) { + throw new TypeError('Currently, all tracks in options.tracks must belong to options.input.'); + } if ( options.output._tracks.length > 0 || Object.keys(options.output._metadataTags).length > 0 @@ -607,21 +634,45 @@ export class Conversion { /** @internal */ async _init() { - this._startTimestamp = this._options.trim?.start ?? Math.max( - await this.input.getFirstTimestamp(), - // Samples can also have negative timestamps, but the meaning typically is "don't present me", so let's cut - // those out by default. - 0, - ); - this._endTimestamp = Math.max(this._options.trim?.end ?? Infinity, this._startTimestamp); + const inputFormat = await this.input.getFormat(); + + let tracks: InputTrack[]; + + if (Array.isArray(this._options.tracks)) { + tracks = this._options.tracks; + } else { + let trackMode = this._options.tracks; + if (trackMode === undefined) { + // HACK to keep bundle size low, temp for now + const defaultTrackMode = inputFormat.name.includes('(HLS)') + ? 'primary' + : 'all'; + + trackMode = defaultTrackMode; + } + + if (trackMode === 'all') { + tracks = await this.input.getTracks(); + } else if (trackMode === 'primary') { + const primaryVideoTrack = await this.input.getPrimaryVideoTrack(); + const primaryAudioTrack = await this.input.getPrimaryAudioTrack(); + + tracks = [primaryVideoTrack, primaryAudioTrack].filter(x => x !== null); + } else { + assertNever(trackMode); + assert(false); + } + } - const inputTracks = await this.input.getTracks(); const outputTrackCounts = this.output.format.getSupportedTrackCounts(); let nVideo = 1; let nAudio = 1; - for (const track of inputTracks) { + const filteredTracks: InputTrack[] = []; + const filteredTrackOptions: (ConversionVideoOptions | ConversionAudioOptions)[] = []; + + for (const track of tracks) { let trackOptions: ConversionVideoOptions | ConversionAudioOptions | undefined = undefined; if (track.isVideoTrack()) { if (this._options.video) { @@ -671,10 +722,39 @@ export class Conversion { continue; } + filteredTracks.push(track); + filteredTrackOptions.push(trackOptions ?? {}); + } + + if (this._options.trim?.start !== undefined) { + this._startTimestamp = this._options.trim.start; + } else { + // Compute the start timestamp from the set of filtered tracks. Techncially these can still be narrowed + // down later due to discarded tracks, but we need to fix the start timestamp now due to track processing + // depending on it. + this._startTimestamp = Math.max( + Math.min( + ...await Promise.all(filteredTracks.map(x => x.getFirstTimestamp())), + ), + // Samples can also have negative timestamps, but the meaning typically is "don't present me", so let's + // cut those out by default. + 0, + ); + } + + this._endTimestamp = Math.max(this._options.trim?.end ?? Infinity, this._startTimestamp); + + // Run these sequentially so that output tracks have a deterministic order + for (let i = 0; i < filteredTracks.length; i++) { + const track = filteredTracks[i]!; + const options = filteredTrackOptions[i]!; + if (track.isVideoTrack()) { - await this._processVideoTrack(track, (trackOptions ?? {}) as ConversionVideoOptions); + await this._processVideoTrack(track, options as ConversionVideoOptions); } else if (track.isAudioTrack()) { - await this._processAudioTrack(track, (trackOptions ?? {}) as ConversionAudioOptions); + await this._processAudioTrack(track, options as ConversionAudioOptions); + } else { + assert(false); } } @@ -695,7 +775,7 @@ export class Conversion { } // Somewhat dirty but pragmatic - const inputAndOutputFormatMatch = (await this.input.getFormat()).mimeType === this.output.format.mimeType; + const inputAndOutputFormatMatch = inputFormat.mimeType === this.output.format.mimeType; const rawTagsAreUnchanged = inputTags.raw === outputTags.raw; if (inputTags.raw && rawTagsAreUnchanged && !inputAndOutputFormatMatch) { @@ -832,7 +912,7 @@ export class Conversion { return Infinity; // Upper bound (assuming no universe heat death) } - return track.computeDuration(); + return (await track.getDurationFromMetadata()) ?? (await track.computeDuration()); }); const duration = Math.max(0, ...await Promise.all(durationPromises)); @@ -972,15 +1052,16 @@ export class Conversion { const sink = new EncodedPacketSink(track); const decoderConfig = await track.getDecoderConfig(); const meta: EncodedVideoChunkMetadata = { decoderConfig: decoderConfig ?? undefined }; - const endPacket = Number.isFinite(this._endTimestamp) - ? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? undefined - : undefined; - for await (const packet of sink.packets(undefined, endPacket, { verifyKeyPackets: true })) { + for await (const packet of sink.packets(undefined, undefined, { verifyKeyPackets: true })) { if (this._canceled) { return; } + if (packet.timestamp >= this._endTimestamp) { + break; + } + const modifiedPacket = packet.clone({ timestamp: packet.timestamp - this._startTimestamp, sideData: alpha === 'discard' @@ -989,7 +1070,7 @@ export class Conversion { }); assert(modifiedPacket.timestamp >= 0); - this._reportProgress(track.id, modifiedPacket.timestamp); + this._reportProgress(track.id, modifiedPacket.timestamp + modifiedPacket.duration); await source.add(modifiedPacket, meta); if (this._synchronizer.shouldWait(track.id, modifiedPacket.timestamp)) { @@ -1292,7 +1373,7 @@ export class Conversion { return; } - this._reportProgress(track.id, sample.timestamp); + this._reportProgress(track.id, sample.timestamp + sample.duration); let finalSamples: VideoSample[]; if (!trackOptions.process) { @@ -1387,21 +1468,22 @@ export class Conversion { const sink = new EncodedPacketSink(track); const decoderConfig = await track.getDecoderConfig(); const meta: EncodedAudioChunkMetadata = { decoderConfig: decoderConfig ?? undefined }; - const endPacket = Number.isFinite(this._endTimestamp) - ? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? undefined - : undefined; - for await (const packet of sink.packets(undefined, endPacket)) { + for await (const packet of sink.packets()) { if (this._canceled) { return; } + if (packet.timestamp >= this._endTimestamp) { + break; + } + const modifiedPacket = packet.clone({ timestamp: packet.timestamp - this._startTimestamp, }); assert(modifiedPacket.timestamp >= 0); - this._reportProgress(track.id, modifiedPacket.timestamp); + this._reportProgress(track.id, modifiedPacket.timestamp + modifiedPacket.duration); await source.add(modifiedPacket, meta); if (this._synchronizer.shouldWait(track.id, modifiedPacket.timestamp)) { @@ -1540,7 +1622,7 @@ export class Conversion { return; } - this._reportProgress(track.id, sample.timestamp); + this._reportProgress(track.id, sample.timestamp + sample.duration); let finalSamples: AudioSample[]; if (!trackOptions.process) { diff --git a/src/input.ts b/src/input.ts index 7c095ca..3fb0b24 100644 --- a/src/input.ts +++ b/src/input.ts @@ -342,7 +342,7 @@ export class Input extends EventEmitter return this._format; } - async isSupported(): Promise { + async canRead(): Promise { try { await this._getDemuxer(); return true; @@ -376,6 +376,9 @@ export class Input extends EventEmitter /** * Returns the timestamp at which the input file starts. More precisely, returns the smallest starting timestamp * among all tracks. + * + * Note that this method is potentially expensive for inputs with many tracks (such as HLS manifests), since it + * probes every track. */ async getFirstTimestamp() { const tracks = await this.getTracks(); diff --git a/src/media-sink.ts b/src/media-sink.ts index 1078733..9814906 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -291,7 +291,7 @@ export class EncodedPacketSink { * method will intelligently preload packets based on the speed of the consumer. * * @param startPacket - (optional) The packet from which iteration should begin. This packet will also be yielded. - * @param endTimestamp - (optional) The timestamp at which iteration should end. This packet will _not_ be yielded. + * @param endPacket - (optional) The packet at which iteration should end. This packet will _not_ be yielded. */ packets( startPacket?: EncodedPacket, diff --git a/todo.txt b/todo.txt deleted file mode 100644 index 6538a2f..0000000 --- a/todo.txt +++ /dev/null @@ -1,6 +0,0 @@ -idea: discontinuities with extra decoder config on the packet. -Also, why not just have the packet metadata on the packet? I think that would make things easier overall. - -- keep input/demuxer.isSupported()? - -- conversion behavior for HLS; only take primary tracks by default?