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) {
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,
+11 -2
View File
@@ -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();
+107 -25
View File
@@ -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) {
+4 -1
View File
@@ -342,7 +342,7 @@ export class Input<S extends Source = Source> extends EventEmitter<InputEvents>
return this._format;
}
async isSupported(): Promise<boolean> {
async canRead(): Promise<boolean> {
try {
await this._getDemuxer();
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
* 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();
+1 -1
View File
@@ -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,
-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?