mediabunny
Version:
Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.
364 lines (363 loc) • 15.4 kB
JavaScript
/*!
* Copyright (c) 2026-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { arrayCount, assert, roundToDivisor } from './misc.js';
export class SegmentedInput {
constructor(input, path, trackDeclarations) {
this.nextInputCacheAge = 0;
this.inputCache = [];
this.trackBackingsPromise = null;
this.firstSegment = null;
this.firstSegmentFirstTimestamps = new WeakMap();
this.firstTimestampCache = new WeakMap();
this.input = input;
this.path = path;
this.trackDeclarations = trackDeclarations;
}
async getDurationFromMetadata(options) {
const lastSegment = await this.getSegmentAt(Infinity, {
skipLiveWait: options.skipLiveWait,
});
if (!lastSegment) {
return null;
}
return lastSegment.timestamp + lastSegment.duration;
}
async getTrackBackings() {
return this.trackBackingsPromise ??= (async () => {
const backings = [];
if (this.trackDeclarations) {
for (const decl of this.trackDeclarations) {
if (decl.type === 'video') {
const number = arrayCount(backings, x => x.getType() === 'video') + 1;
backings.push(new SegmentedInputInputVideoTrackBacking(this, decl, number));
}
else if (decl.type === 'audio') {
const number = arrayCount(backings, x => x.getType() === 'audio') + 1;
backings.push(new SegmentedInputInputAudioTrackBacking(this, decl, number));
}
}
}
else {
// There are no declarations, we must determine the tracks from the first segment
this.firstSegment = await this.getFirstSegment({});
if (!this.firstSegment) {
return [];
}
const input = this.getInputForSegment(this.firstSegment);
const inputTracks = await input.getTracks();
for (const track of inputTracks) {
if (track.type === 'video') {
const number = arrayCount(backings, x => x.getType() === 'video') + 1;
backings.push(new SegmentedInputInputVideoTrackBacking(this, {
id: backings.length + 1,
type: 'video',
}, number));
}
else if (track.type === 'audio') {
const number = arrayCount(backings, x => x.getType() === 'audio') + 1;
backings.push(new SegmentedInputInputAudioTrackBacking(this, {
id: backings.length + 1,
type: 'audio',
}, number));
}
}
}
return backings;
})();
}
// This operation is done a lot and can be semi-expensive, so it's good to have a cache for it
async getFirstTimestampForInput(input) {
const existing = this.firstTimestampCache.get(input);
if (existing !== undefined) {
return existing;
}
const firstTimestamp = await input.getFirstTimestamp();
this.firstTimestampCache.set(input, firstTimestamp);
return firstTimestamp;
}
async getMediaOffset(segment, input) {
const firstSegment = segment.firstSegment ?? segment;
let firstSegmentFirstTimestamp;
if (this.firstSegmentFirstTimestamps.has(firstSegment)) {
firstSegmentFirstTimestamp = this.firstSegmentFirstTimestamps.get(firstSegment);
}
else {
const firstInput = this.getInputForSegment(firstSegment);
firstSegmentFirstTimestamp = await this.getFirstTimestampForInput(firstInput);
this.firstSegmentFirstTimestamps.set(firstSegment, firstSegmentFirstTimestamp);
}
if (firstSegment === segment) {
return firstSegment.timestamp - firstSegmentFirstTimestamp;
}
const segmentFirstTimestamp = await this.getFirstTimestampForInput(input);
const segmentElapsed = segment.timestamp - firstSegment.timestamp;
const inputElapsed = segmentFirstTimestamp - firstSegmentFirstTimestamp;
const difference = inputElapsed - segmentElapsed;
if (Math.abs(difference) <= Math.min(0.25, segmentElapsed)) { // Heuristic
// We're close enough
return firstSegment.timestamp - firstSegmentFirstTimestamp;
}
else {
// Ideally, each segment has absolute timestamps that are relative to some outside clock which is
// consistent across segments. This is often the case, but not always. Either the container format used is
// not timestamped at all (like ADTS), or the segments are just fucky. In this case, use the segment's
// relative timestamp to determine where we are, and completely offset out the segment's input start
// timestamp.
return segment.timestamp - segmentFirstTimestamp;
}
}
dispose() {
for (const entry of this.inputCache) {
entry.input.dispose();
}
this.inputCache.length = 0;
}
}
class SegmentedInputInputTrackBacking {
constructor(segmentedInput, decl, number) {
this.packetInfos = new WeakMap();
this.hydrationPromise = null;
this.firstInputTrack = null;
this.segmentedInput = segmentedInput;
this.decl = decl;
this.number = number;
}
hydrate() {
return this.hydrationPromise ??= (async () => {
this.segmentedInput.firstSegment ??= await this.segmentedInput.getFirstSegment({});
if (!this.segmentedInput.firstSegment) {
throw new Error('Missing first segment, can\'t retrieve track.');
}
const input = this.segmentedInput.getInputForSegment(this.segmentedInput.firstSegment);
const inputTracks = await input.getTracks();
const track = inputTracks.find(x => x.type === this.decl.type && x.number === this.number);
if (!track) {
throw new Error('No matching track found in underlying media data.');
}
this.firstInputTrack = track;
})();
}
getId() {
return this.decl.id;
}
getType() {
return this.decl.type;
}
getNumber() {
return this.number;
}
/** If the backing track is already present, delegate synchronously; otherwise, hydrate first. */
delegate(fn) {
if (this.firstInputTrack) {
return fn();
}
return this.hydrate().then(fn);
}
async getDecoderConfig() {
return this.delegate(() => this.firstInputTrack._backing.getDecoderConfig());
}
getHasOnlyKeyPackets() {
return this.delegate(() => this.firstInputTrack._backing.getHasOnlyKeyPackets?.() ?? null);
}
getPairingMask() {
return 1n;
}
getCodec() {
return this.delegate(() => this.firstInputTrack._backing.getCodec());
}
getInternalCodecId() {
return this.delegate(() => this.firstInputTrack._backing.getInternalCodecId());
}
getDisposition() {
return this.delegate(() => this.firstInputTrack._backing.getDisposition());
}
getLanguageCode() {
return this.delegate(() => this.firstInputTrack._backing.getLanguageCode());
}
getName() {
return this.delegate(() => this.firstInputTrack._backing.getName());
}
getTimeResolution() {
return this.delegate(() => this.firstInputTrack._backing.getTimeResolution());
}
async isRelativeToUnixEpoch() {
await this.hydrate();
assert(this.segmentedInput.firstSegment);
return this.segmentedInput.firstSegment.relativeToUnixEpoch;
}
getBitrate() {
return this.delegate(() => this.firstInputTrack._backing.getBitrate());
}
getAverageBitrate() {
return this.delegate(() => this.firstInputTrack._backing.getAverageBitrate());
}
getDurationFromMetadata(options) {
return this.segmentedInput.getDurationFromMetadata(options);
}
getLiveRefreshInterval() {
return this.segmentedInput.getLiveRefreshInterval();
}
async createAdjustedPacket(packet, segment, track) {
assert(packet.sequenceNumber >= 0);
assert(this.segmentedInput.firstSegment);
const mediaOffset = await this.segmentedInput.getMediaOffset(segment, track.input);
// If we didn't do this then sequence numbers would exceed Number.MAX_SAFE_INTEGER for Unix-timestamped segments
const segmentTimestampRelativeToFirst = segment.timestamp - this.segmentedInput.firstSegment.timestamp;
const modified = packet.clone({
timestamp: roundToDivisor(packet.timestamp + mediaOffset, await track.getTimeResolution()),
// The 1e8 assumes a max of 100 MB per second, highly unlikely to be hit, so this should guarantee
// monotonically increasing sequence numbers across segments.
sequenceNumber: Math.floor(1e8 * segmentTimestampRelativeToFirst) + packet.sequenceNumber,
});
this.packetInfos.set(modified, {
segment,
track,
sourcePacket: packet,
});
return modified;
}
async getFirstPacket(options) {
await this.hydrate();
assert(this.segmentedInput.firstSegment);
assert(this.firstInputTrack);
const packet = await this.firstInputTrack._backing.getFirstPacket(options);
if (!packet) {
return null;
}
return this.createAdjustedPacket(packet, this.segmentedInput.firstSegment, this.firstInputTrack);
}
getNextPacket(packet, options) {
return this._getNextInternal(packet, options, false);
}
getNextKeyPacket(packet, options) {
return this._getNextInternal(packet, options, true);
}
async _getNextInternal(packet, options, keyframesOnly) {
const info = this.packetInfos.get(packet);
if (!info) {
throw new Error('Packet was not created from this track.');
}
const nextPacket = keyframesOnly
? await info.track._backing.getNextKeyPacket(info.sourcePacket, options)
: await info.track._backing.getNextPacket(info.sourcePacket, options);
if (nextPacket) {
return this.createAdjustedPacket(nextPacket, info.segment, info.track);
}
let currentSegment = info.segment;
while (true) {
const nextSegment = await this.segmentedInput.getNextSegment(currentSegment, {
skipLiveWait: options.skipLiveWait,
});
if (!nextSegment) {
return null;
}
const nextInput = this.segmentedInput.getInputForSegment(nextSegment);
const nextTracks = await nextInput.getTracks();
const nextTrack = nextTracks.find(t => t.type === info.track.type && t.number === info.track.number);
if (!nextTrack) {
currentSegment = nextSegment;
continue;
}
const firstPacket = await nextTrack._backing.getFirstPacket(options);
if (!firstPacket) {
return null;
}
return this.createAdjustedPacket(firstPacket, nextSegment, nextTrack);
}
}
getPacket(timestamp, options) {
return this._getPacketInternal(timestamp, options, false);
}
getKeyPacket(timestamp, options) {
return this._getPacketInternal(timestamp, options, true);
}
async _getPacketInternal(timestamp, options, keyframesOnly) {
let currentSegment = await this.segmentedInput.getSegmentAt(timestamp, {
skipLiveWait: options.skipLiveWait,
});
if (!currentSegment) {
return null;
}
await this.hydrate();
while (currentSegment) {
const input = this.segmentedInput.getInputForSegment(currentSegment);
const tracks = await input.getTracks();
const track = tracks.find(t => (t.type === this.firstInputTrack.type && t.number === this.firstInputTrack.number));
if (!track) {
// Search the previous segment
currentSegment = await this.segmentedInput.getPreviousSegment(currentSegment, {
skipLiveWait: options.skipLiveWait,
});
continue;
}
const mediaOffset = await this.segmentedInput.getMediaOffset(currentSegment, input);
const offsetTimestamp = timestamp - mediaOffset;
const packet = keyframesOnly
? await track._backing.getKeyPacket(offsetTimestamp, options)
: await track._backing.getPacket(offsetTimestamp, options);
if (!packet) {
// Search the previous segment
currentSegment = await this.segmentedInput.getPreviousSegment(currentSegment, {
skipLiveWait: options.skipLiveWait,
});
continue;
}
return this.createAdjustedPacket(packet, currentSegment, track);
}
return null;
}
}
class SegmentedInputInputVideoTrackBacking extends SegmentedInputInputTrackBacking {
getType() {
return 'video';
}
getCodec() {
return this.delegate(() => this.firstInputTrack._backing.getCodec());
}
getCodedWidth() {
return this.delegate(() => this.firstInputTrack._backing.getCodedWidth());
}
getCodedHeight() {
return this.delegate(() => this.firstInputTrack._backing.getCodedHeight());
}
getSquarePixelWidth() {
return this.delegate(() => this.firstInputTrack._backing.getSquarePixelWidth());
}
getSquarePixelHeight() {
return this.delegate(() => this.firstInputTrack._backing.getSquarePixelHeight());
}
getRotation() {
return this.delegate(() => this.firstInputTrack._backing.getRotation());
}
async getColorSpace() {
return this.delegate(() => this.firstInputTrack._backing.getColorSpace());
}
async canBeTransparent() {
return this.delegate(() => this.firstInputTrack._backing.canBeTransparent());
}
async getDecoderConfig() {
return this.delegate(() => this.firstInputTrack._backing.getDecoderConfig());
}
}
class SegmentedInputInputAudioTrackBacking extends SegmentedInputInputTrackBacking {
getType() {
return 'audio';
}
getCodec() {
return this.delegate(() => this.firstInputTrack._backing.getCodec());
}
getNumberOfChannels() {
return this.delegate(() => this.firstInputTrack._backing.getNumberOfChannels());
}
getSampleRate() {
return this.delegate(() => this.firstInputTrack._backing.getSampleRate());
}
async getDecoderConfig() {
return this.delegate(() => this.firstInputTrack._backing.getDecoderConfig());
}
}