/*! * 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 { AudioCodec, MediaCodec, VideoCodec } from './codec'; import { Demuxer, DurationMetadataRequestOptions } from './demuxer'; import { Input } from './input'; import { VirtualInputFormat } from './input-format'; import { InputAudioTrack, InputAudioTrackBacking, InputTrack, InputTrackBacking, InputVideoTrack, InputVideoTrackBacking, } from './input-track'; import { PacketRetrievalOptions } from './media-sink'; import { MetadataTags, TrackDisposition } from './metadata'; import { arrayCount, assert, Rotation, roundToDivisor } from './misc'; import { EncodedPacket } from './packet'; import { NullSource } from './source'; export type SegmentedInputMetadata = { name: string | null; bitrate: number | null; // doc block: this refers to the _peak_ bitrate averageBitrate: number | null; codecs: MediaCodec[]; codecStrings: string[]; resolution: { width: number; height: number } | null; frameRate: number | null; isKeyFrameOnly: boolean; }; export type AssociatedGroup = { id: string; type: 'video' | 'audio' | 'subtitles' | 'closed-captions'; }; export type Segment = { timestamp: number; duration: number; relativeToUnixEpoch: boolean; firstSegment: Segment | null; }; export type SegmentRetrievalOptions = { skipLiveWait?: boolean; }; export abstract class SegmentedInput { input: Input; path: string; virtualInput: Input | null = null; nextInputCacheAge = 0; inputCache: { segment: Segment; input: Input; age: number; }[] = []; constructor(input: Input, path: string) { this.input = input; this.path = path; } abstract getFirstSegment(options: SegmentRetrievalOptions): Promise; abstract getSegmentAt(timestamp: number, options: SegmentRetrievalOptions): Promise; abstract getNextSegment(segment: Segment, options: SegmentRetrievalOptions): Promise; abstract getPreviousSegment(segment: Segment, options: SegmentRetrievalOptions): Promise; abstract getInputForSegment(segment: Segment): Input; abstract getLiveRefreshInterval(): Promise; async getDurationFromMetadata(options: DurationMetadataRequestOptions) { const lastSegment = await this.getSegmentAt(Infinity, { skipLiveWait: options.skipLiveWait, }); if (!lastSegment) { return null; } return lastSegment.timestamp + lastSegment.duration; } toInput() { return this.virtualInput ??= new Input({ source: new NullSource(), formats: [new VirtualInputFormat(() => new SegmentedInputDemuxer(this.input, this))], }); } dispose() { for (const entry of this.inputCache) { entry.input.dispose(); } this.inputCache.length = 0; this.virtualInput?.dispose(); } } class SegmentedInputDemuxer extends Demuxer { segmentedInput: SegmentedInput; tracksPromise: Promise | null = null; firstSegment: Segment | null = null; firstSegmentFirstTimestamps = new WeakMap(); constructor(input: Input, segmentedInput: SegmentedInput) { super(input); this.segmentedInput = segmentedInput; } async getDurationFromMetadata(): Promise { throw new Error('Unreachable'); } async getMetadataTags(): Promise { throw new Error('Unreachable'); } async getMimeType(): Promise { throw new Error('Unreachable'); } async getTracks(): Promise { return this.tracksPromise ??= (async () => { this.firstSegment = await this.segmentedInput.getFirstSegment({}); if (!this.firstSegment) { return []; } const input = this.segmentedInput.getInputForSegment(this.firstSegment); const inputTracks = await input.getTracks(); const tracks: InputTrack[] = []; for (const track of inputTracks) { if (track.type === 'video') { const number = arrayCount(tracks, x => x.type === 'video') + 1; tracks.push(new InputVideoTrack( this.input, new SegmentedInputInputVideoTrackBacking(track, this, number), )); } else if (track.type === 'audio') { const number = arrayCount(tracks, x => x.type === 'audio') + 1; tracks.push(new InputAudioTrack( this.input, new SegmentedInputInputAudioTrackBacking(track, this, number), )); } } return tracks; })(); } async getMediaOffset(segment: Segment, input: Input) { const firstSegment = segment.firstSegment ?? segment; let firstSegmentFirstTimestamp: number; if (this.firstSegmentFirstTimestamps.has(firstSegment)) { firstSegmentFirstTimestamp = this.firstSegmentFirstTimestamps.get(firstSegment)!; } else { const firstInput = this.segmentedInput.getInputForSegment(firstSegment); firstSegmentFirstTimestamp = await firstInput.getFirstTimestamp(); this.firstSegmentFirstTimestamps.set(firstSegment, firstSegmentFirstTimestamp); } if (firstSegment === segment) { return firstSegment.timestamp - firstSegmentFirstTimestamp; } const segmentFirstTimestamp = await input.getFirstTimestamp(); 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; } } } type PacketInfo = { segment: Segment; track: InputTrack; sourcePacket: EncodedPacket; }; class SegmentedInputInputTrackBacking implements InputTrackBacking { firstInputTrack: InputTrack; demuxer: SegmentedInputDemuxer; packetInfos = new WeakMap(); number: number; constructor(firstInputTrack: InputTrack, demuxer: SegmentedInputDemuxer, number: number) { this.firstInputTrack = firstInputTrack; this.demuxer = demuxer; this.number = number; } getHasOnlyKeyPackets() { return this.firstInputTrack.hasOnlyKeyPackets; } getId(): number { return this.firstInputTrack._backing.getId(); } getGroupId(): number { return this.firstInputTrack._backing.getGroupId(); } getPairingMask(): bigint { return this.firstInputTrack._backing.getPairingMask(); } getNumber(): number { return this.number; } getCodec(): MediaCodec | null { return this.firstInputTrack._backing.getCodec(); } getInternalCodecId(): string | number | Uint8Array | null { return this.firstInputTrack._backing.getInternalCodecId(); } getDisposition(): TrackDisposition { return this.firstInputTrack._backing.getDisposition(); } getLanguageCode(): string { return this.firstInputTrack._backing.getLanguageCode(); } getName(): string | null { return this.firstInputTrack._backing.getName(); } getTimeResolution(): number { return this.firstInputTrack._backing.getTimeResolution(); } getTimestampsAreRelativeToUnixEpoch(): boolean { assert(this.demuxer.firstSegment); return this.demuxer.firstSegment.relativeToUnixEpoch; } getBitrate(): number | null { return this.firstInputTrack._backing.getBitrate(); } getAverageBitrate(): number | null { return this.firstInputTrack._backing.getAverageBitrate(); } getDurationFromMetadata(options: DurationMetadataRequestOptions): Promise { return this.demuxer.segmentedInput.getDurationFromMetadata(options); } getLiveRefreshInterval(): Promise { return this.demuxer.segmentedInput.getLiveRefreshInterval(); } async createAdjustedPacket(packet: EncodedPacket, segment: Segment, track: InputTrack) { assert(packet.sequenceNumber >= 0); assert(this.demuxer.firstSegment); const mediaOffset = await this.demuxer.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.demuxer.firstSegment.timestamp; const modified = packet.clone({ timestamp: roundToDivisor( packet.timestamp + mediaOffset, track.timeResolution, ), // 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: PacketRetrievalOptions): Promise { assert(this.demuxer.firstSegment); const packet = await this.firstInputTrack._backing.getFirstPacket(options); if (!packet) { return null; } return this.createAdjustedPacket(packet, this.demuxer.firstSegment, this.firstInputTrack); } getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise { return this._getNextInternal(packet, options, false); } getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise { return this._getNextInternal(packet, options, true); } async _getNextInternal( packet: EncodedPacket, options: PacketRetrievalOptions, keyframesOnly: boolean, ): Promise { 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: Segment | null = info.segment; while (true) { const nextSegment = await this.demuxer.segmentedInput.getNextSegment(currentSegment, { skipLiveWait: options.skipLiveWait, }); if (!nextSegment) { return null; } const nextInput = this.demuxer.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: number, options: PacketRetrievalOptions): Promise { return this._getPacketInternal(timestamp, options, false); } getKeyPacket(timestamp: number, options: PacketRetrievalOptions): Promise { return this._getPacketInternal(timestamp, options, true); } async _getPacketInternal( timestamp: number, options: PacketRetrievalOptions, keyframesOnly: boolean, ): Promise { let currentSegment = await this.demuxer.segmentedInput.getSegmentAt(timestamp, { skipLiveWait: options.skipLiveWait, }); if (!currentSegment) { return null; } while (currentSegment) { const input = this.demuxer.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.demuxer.segmentedInput.getPreviousSegment(currentSegment, { skipLiveWait: options.skipLiveWait, }); continue; } const mediaOffset = await this.demuxer.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.demuxer.segmentedInput.getPreviousSegment(currentSegment, { skipLiveWait: options.skipLiveWait, }); continue; } return this.createAdjustedPacket(packet, currentSegment, track); } return null; } } class SegmentedInputInputVideoTrackBacking extends SegmentedInputInputTrackBacking implements InputVideoTrackBacking { override firstInputTrack!: InputVideoTrack; override getCodec(): VideoCodec | null { return this.firstInputTrack._backing.getCodec(); } getCodedWidth(): number { return this.firstInputTrack._backing.getCodedWidth(); } getCodedHeight(): number { return this.firstInputTrack._backing.getCodedHeight(); } getSquarePixelWidth(): number { return this.firstInputTrack._backing.getSquarePixelWidth(); } getSquarePixelHeight(): number { return this.firstInputTrack._backing.getSquarePixelHeight(); } getRotation(): Rotation { return this.firstInputTrack._backing.getRotation(); } getColorSpace(): Promise { return this.firstInputTrack._backing.getColorSpace(); } canBeTransparent(): Promise { return this.firstInputTrack._backing.canBeTransparent(); } getDecoderConfig(): Promise { return this.firstInputTrack._backing.getDecoderConfig(); } } class SegmentedInputInputAudioTrackBacking extends SegmentedInputInputTrackBacking implements InputAudioTrackBacking { override firstInputTrack!: InputAudioTrack; override getCodec(): AudioCodec | null { return this.firstInputTrack._backing.getCodec(); } getNumberOfChannels(): number { return this.firstInputTrack._backing.getNumberOfChannels(); } getSampleRate(): number { return this.firstInputTrack._backing.getSampleRate(); } getDecoderConfig(): Promise { return this.firstInputTrack._backing.getDecoderConfig(); } }