Add ConversionOptions.tracks, add better converstion start timestamp computation, optimize endPacket logic, utilize metadata duration for progress callback for better performance

This commit is contained in:
Vanilagy
2026-04-08 17:07:02 +02:00
parent fdb5f4102f
commit 7d053ae50d
6 changed files with 131 additions and 38 deletions
+8 -3
View File
@@ -60,11 +60,12 @@
if (true) { if (true) {
input = new Mediabunny.Input({ 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), source: ({ path }) => new Mediabunny.UrlSource(path),
formats: Mediabunny.ALL_FORMATS, formats: Mediabunny.ALL_FORMATS,
}); });
/*
const videoTrack = await input.getPrimaryVideoTrack(); const videoTrack = await input.getPrimaryVideoTrack();
const audioTrack = await input.getPrimaryAudioTrack(); const audioTrack = await input.getPrimaryAudioTrack();
@@ -75,6 +76,7 @@
videoTrack.computeDuration({ skipLiveWait: true }), videoTrack.computeDuration({ skipLiveWait: true }),
audioTrack.computeDuration({ skipLiveWait: true }), audioTrack.computeDuration({ skipLiveWait: true }),
])); ]));
*/
} else { } else {
const file = fileInput.files[0]; const file = fileInput.files[0];
const source = new Mediabunny.BlobSource(file); const source = new Mediabunny.BlobSource(file);
@@ -90,7 +92,8 @@
input, input,
output, output,
audio: (track, n) => ({ audio: (track, n) => ({
discard: !tracks.includes(track), //discard: true,
//discard: !tracks.includes(track),
//codec: 'eac3', //codec: 'eac3',
//discard: true, //discard: true,
//discard: n > 1, //discard: n > 1,
@@ -137,7 +140,8 @@
}, },
*/ */
video: (track) => ({ video: (track) => ({
discard: !tracks.includes(track), //discard: true,
//discard: !tracks.includes(track),
//forceTranscode: true, //forceTranscode: true,
//forceTranscode: true, //forceTranscode: true,
//width: 1280, //width: 1280,
@@ -222,6 +226,7 @@
} }
}, },
trim: { trim: {
end: 10,
//start, //start,
//end: start + 5, //end: start + 5,
////start: 0, ////start: 0,
+11 -2
View File
@@ -11,6 +11,7 @@
document.body.append(fileInput); document.body.append(fileInput);
fileInput.addEventListener('change', async () => { fileInput.addEventListener('change', async () => {
/*
const file = fileInput.files[0]; const file = fileInput.files[0];
const input = new Mediabunny.Input({ const input = new Mediabunny.Input({
formats: Mediabunny.ALL_FORMATS, formats: Mediabunny.ALL_FORMATS,
@@ -25,14 +26,22 @@
//console.log(await input.getDurationFromMetadata(), await input.computeDuration()); //console.log(await input.getDurationFromMetadata(), await input.computeDuration());
return; return;
*/
const manifest = new Mediabunny.Input({ 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), source: ({ path }) => new Mediabunny.UrlSource(path),
formats: Mediabunny.ALL_FORMATS, 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; return;
const videoTrack = await manifest.getPrimaryVideoTrack(); const videoTrack = await manifest.getPrimaryVideoTrack();
+107 -25
View File
@@ -38,6 +38,7 @@ import {
} from './media-source'; } from './media-source';
import { import {
assert, assert,
assertNever,
ceilToMultipleOfTwo, ceilToMultipleOfTwo,
clamp, clamp,
floorToDivisor, floorToDivisor,
@@ -65,6 +66,17 @@ export type ConversionOptions = {
/** The output file. */ /** The output file. */
output: Output; 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 * 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 * 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)) { if (!(options.output instanceof Output)) {
throw new TypeError('options.output must be an 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 ( if (
options.output._tracks.length > 0 options.output._tracks.length > 0
|| Object.keys(options.output._metadataTags).length > 0 || Object.keys(options.output._metadataTags).length > 0
@@ -607,21 +634,45 @@ export class Conversion {
/** @internal */ /** @internal */
async _init() { async _init() {
this._startTimestamp = this._options.trim?.start ?? Math.max( const inputFormat = await this.input.getFormat();
await this.input.getFirstTimestamp(),
// Samples can also have negative timestamps, but the meaning typically is "don't present me", so let's cut let tracks: InputTrack[];
// those out by default.
0, if (Array.isArray(this._options.tracks)) {
); tracks = this._options.tracks;
this._endTimestamp = Math.max(this._options.trim?.end ?? Infinity, this._startTimestamp); } 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(); const outputTrackCounts = this.output.format.getSupportedTrackCounts();
let nVideo = 1; let nVideo = 1;
let nAudio = 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; let trackOptions: ConversionVideoOptions | ConversionAudioOptions | undefined = undefined;
if (track.isVideoTrack()) { if (track.isVideoTrack()) {
if (this._options.video) { if (this._options.video) {
@@ -671,10 +722,39 @@ export class Conversion {
continue; 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()) { if (track.isVideoTrack()) {
await this._processVideoTrack(track, (trackOptions ?? {}) as ConversionVideoOptions); await this._processVideoTrack(track, options as ConversionVideoOptions);
} else if (track.isAudioTrack()) { } 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 // 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; const rawTagsAreUnchanged = inputTags.raw === outputTags.raw;
if (inputTags.raw && rawTagsAreUnchanged && !inputAndOutputFormatMatch) { if (inputTags.raw && rawTagsAreUnchanged && !inputAndOutputFormatMatch) {
@@ -832,7 +912,7 @@ export class Conversion {
return Infinity; // Upper bound (assuming no universe heat death) 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)); const duration = Math.max(0, ...await Promise.all(durationPromises));
@@ -972,15 +1052,16 @@ export class Conversion {
const sink = new EncodedPacketSink(track); const sink = new EncodedPacketSink(track);
const decoderConfig = await track.getDecoderConfig(); const decoderConfig = await track.getDecoderConfig();
const meta: EncodedVideoChunkMetadata = { decoderConfig: decoderConfig ?? undefined }; 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) { if (this._canceled) {
return; return;
} }
if (packet.timestamp >= this._endTimestamp) {
break;
}
const modifiedPacket = packet.clone({ const modifiedPacket = packet.clone({
timestamp: packet.timestamp - this._startTimestamp, timestamp: packet.timestamp - this._startTimestamp,
sideData: alpha === 'discard' sideData: alpha === 'discard'
@@ -989,7 +1070,7 @@ export class Conversion {
}); });
assert(modifiedPacket.timestamp >= 0); assert(modifiedPacket.timestamp >= 0);
this._reportProgress(track.id, modifiedPacket.timestamp); this._reportProgress(track.id, modifiedPacket.timestamp + modifiedPacket.duration);
await source.add(modifiedPacket, meta); await source.add(modifiedPacket, meta);
if (this._synchronizer.shouldWait(track.id, modifiedPacket.timestamp)) { if (this._synchronizer.shouldWait(track.id, modifiedPacket.timestamp)) {
@@ -1292,7 +1373,7 @@ export class Conversion {
return; return;
} }
this._reportProgress(track.id, sample.timestamp); this._reportProgress(track.id, sample.timestamp + sample.duration);
let finalSamples: VideoSample[]; let finalSamples: VideoSample[];
if (!trackOptions.process) { if (!trackOptions.process) {
@@ -1387,21 +1468,22 @@ export class Conversion {
const sink = new EncodedPacketSink(track); const sink = new EncodedPacketSink(track);
const decoderConfig = await track.getDecoderConfig(); const decoderConfig = await track.getDecoderConfig();
const meta: EncodedAudioChunkMetadata = { decoderConfig: decoderConfig ?? undefined }; 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) { if (this._canceled) {
return; return;
} }
if (packet.timestamp >= this._endTimestamp) {
break;
}
const modifiedPacket = packet.clone({ const modifiedPacket = packet.clone({
timestamp: packet.timestamp - this._startTimestamp, timestamp: packet.timestamp - this._startTimestamp,
}); });
assert(modifiedPacket.timestamp >= 0); assert(modifiedPacket.timestamp >= 0);
this._reportProgress(track.id, modifiedPacket.timestamp); this._reportProgress(track.id, modifiedPacket.timestamp + modifiedPacket.duration);
await source.add(modifiedPacket, meta); await source.add(modifiedPacket, meta);
if (this._synchronizer.shouldWait(track.id, modifiedPacket.timestamp)) { if (this._synchronizer.shouldWait(track.id, modifiedPacket.timestamp)) {
@@ -1540,7 +1622,7 @@ export class Conversion {
return; return;
} }
this._reportProgress(track.id, sample.timestamp); this._reportProgress(track.id, sample.timestamp + sample.duration);
let finalSamples: AudioSample[]; let finalSamples: AudioSample[];
if (!trackOptions.process) { if (!trackOptions.process) {
+4 -1
View File
@@ -342,7 +342,7 @@ export class Input<S extends Source = Source> extends EventEmitter<InputEvents>
return this._format; return this._format;
} }
async isSupported(): Promise<boolean> { async canRead(): Promise<boolean> {
try { try {
await this._getDemuxer(); await this._getDemuxer();
return true; return true;
@@ -376,6 +376,9 @@ export class Input<S extends Source = Source> extends EventEmitter<InputEvents>
/** /**
* Returns the timestamp at which the input file starts. More precisely, returns the smallest starting timestamp * Returns the timestamp at which the input file starts. More precisely, returns the smallest starting timestamp
* among all tracks. * 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() { async getFirstTimestamp() {
const tracks = await this.getTracks(); const tracks = await this.getTracks();
+1 -1
View File
@@ -291,7 +291,7 @@ export class EncodedPacketSink {
* method will intelligently preload packets based on the speed of the consumer. * 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 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( packets(
startPacket?: EncodedPacket, startPacket?: EncodedPacket,
-6
View File
@@ -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?