UNPKG

mediabunny

Version:

Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.

364 lines (363 loc) 15.4 kB
/*! * 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()); } }