diff --git a/examples/metadata-extraction/metadata-extraction.ts b/examples/metadata-extraction/metadata-extraction.ts index ace5e9e..c58804f 100644 --- a/examples/metadata-extraction/metadata-extraction.ts +++ b/examples/metadata-extraction/metadata-extraction.ts @@ -10,15 +10,21 @@ const horizontalRule = document.querySelector('hr') as HTMLHRElement; const bytesReadElement = document.querySelector('#bytes-read') as HTMLParagraphElement; const metadataContainer = document.querySelector('#metadata-container') as HTMLDivElement; -const extractMetadata = (resource: File | string) => { +const extractMetadata = async (resource: File | string) => { // Create a new input from the resource - const source = resource instanceof File - ? new BlobSource(resource) - : new UrlSource(resource); - const input = new Input({ - source, - formats: ALL_FORMATS, // Accept all formats - }); + let input: Input; + if (resource instanceof File) { + input = new Input({ + source: new BlobSource(resource), + formats: ALL_FORMATS, // Accept all formats + }); + } else { + input = new Input({ + entryPath: resource, + source: ({ path }) => new UrlSource(path), + formats: ALL_FORMATS, // Accept all formats + }); + } let bytesRead = 0; let fileSize: number | null = null; @@ -31,13 +37,18 @@ const extractMetadata = (resource: File | string) => { } }; - input.source.onread = (start, end) => { + const source = await input.getSource(); + + source.onread = (start, end) => { bytesRead += end - start; updateBytesRead(); }; // Get the input's size - void input.source.getSize().then(size => fileSize = size); + void source.getSize().then((size) => { + fileSize = size; + updateBytesRead(); + }); // This object contains all the data that gets displayed: const object = { @@ -46,26 +57,28 @@ const extractMetadata = (resource: File | string) => { 'Starts at': input.getFirstTimestamp().then(start => `${start} seconds`), 'Ends at': input.computeDuration().then(duration => `${duration} seconds`), 'Tracks': input.getTracks().then(tracks => tracks.map(track => ({ - 'Type': track.type, - 'Codec': track.codec, + 'Type': track.resolve('type').then(type => type), + 'Codec': track.resolve('codec').then(codec => codec), 'Full codec string': track.getCodecParameterString(), 'Starts at': track.getFirstTimestamp().then(start => `${start} seconds`), 'Ends at': track.computeDuration().then(duration => `${duration} seconds`), - 'Language code': track.languageCode, + 'Language code': track.resolve('languageCode').then(languageCode => languageCode), ...(track.isVideoTrack() ? { - 'Coded width': `${track.codedWidth} pixels`, - 'Coded height': `${track.codedHeight} pixels`, - 'Rotation': `${track.rotation}° clockwise`, - 'Pixel aspect ratio': `${track.pixelAspectRatio.num}:${track.pixelAspectRatio.den}`, - 'Display width': `${track.displayWidth} pixels`, - 'Display height': `${track.displayHeight} pixels`, + 'Coded width': track.resolve('codedWidth').then(codedWidth => `${codedWidth} pixels`), + 'Coded height': track.resolve('codedHeight').then(codedHeight => `${codedHeight} pixels`), + 'Rotation': track.resolve('rotation').then(rotation => `${rotation}° clockwise`), + 'Pixel aspect ratio': track.resolve('pixelAspectRatio').then(pixelAspectRatio => + `${pixelAspectRatio.num}:${pixelAspectRatio.den}`, + ), + 'Display width': track.resolve('displayWidth').then(displayWidth => `${displayWidth} pixels`), + 'Display height': track.resolve('displayHeight').then(displayHeight => `${displayHeight} pixels`), 'Transparency': track.canBeTransparent(), } : track.isAudioTrack() ? { - 'Number of channels': track.numberOfChannels, - 'Sample rate': `${track.sampleRate} Hz`, + 'Number of channels': track.resolve('numberOfChannels').then(numberOfChannels => numberOfChannels), + 'Sample rate': track.resolve('sampleRate').then(sampleRate => `${sampleRate} Hz`), } : {}), 'Packet statistics': shortDelay().then(() => track.computePacketStats()).then(stats => ({ diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index c9f8dc2..4caf91b 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -60,7 +60,6 @@ export class HlsDemuxer extends Demuxer { readMetadata() { return this.metadataPromise ??= (async () => { - assert(typeof this.input._source === 'function'); assert(this.input._entryPath !== null); let line = this.lineReader.readNextLine(); @@ -285,6 +284,12 @@ export class HlsDemuxer extends Demuxer { let videoCodecString: string | null = null; let audioCodecString: string | null = null; + const bandwidth = variantStream.attributes.getAsNumber('bandwidth'); + assert(bandwidth !== null); + + const averageBandwidth = variantStream.attributes.getAsNumber('average-bandwidth'); + const name = variantStream.attributes.get('name'); + for (const codecString of codecStrings) { const inferredCodec = inferCodecFromCodecString(codecString); if (inferredCodec === null) { @@ -300,79 +305,11 @@ export class HlsDemuxer extends Demuxer { } videoCodecString = codecString; - } else if (AUDIO_CODECS.includes(inferredCodec as AudioCodec)) { - if (audioCodecString !== null) { - throw new Error( - 'Unsupported M3U8 file; multiple audio codecs found in the CODECS attribute of a' - + ' variant stream.', - ); - } - audioCodecString = codecString; - } - } + const videoGroupId = variantStream.attributes.get('video'); - const bandwidth = variantStream.attributes.getAsNumber('bandwidth'); - assert(bandwidth !== null); - - const averageBandwidth = variantStream.attributes.getAsNumber('average-bandwidth'); - const name = variantStream.attributes.get('name'); - - if (videoCodecString !== null) { - const videoGroupId = variantStream.attributes.get('video'); - - if (videoGroupId === null) { - const resolution = variantStream.attributes.get('resolution'); - let width: number | null = null; - let height: number | null = null; - - if (resolution) { - const match = resolution.match(/^(\d+)x(\d+)$/); - if (match) { - width = Number(match[1]); - height = Number(match[2]); - } - } - - addInternalTrack({ - id: internalTracks.length + 1, - demuxer: this, - inputTrack: null, - backingTrack: null, - default: true, - languageCode: UNDETERMINED_LANGUAGE, - lineNumber: variantStream.lineNumber, - fullPath: variantStream.fullPath, - fullCodecString: videoCodecString, - groupId: 1, - pairingMask: 1n << BigInt(i), - peakBitrate: bandwidth, - averageBitrate: averageBandwidth, - name, - info: { - type: 'video', - width, - height, - }, - }, false); - } else { - if (!videoGroupIds.includes(videoGroupId)) { - throw new Error( - `Invalid M3U8 file; variant stream references video group "${videoGroupId}" which` - + ` is not defined in any #EXT-X-MEDIA tags.`, - ); - } - - for (const mediaTag of mediaTags) { - const groupId = mediaTag.attributes.get('group-id')!; - const type = mediaTag.attributes.get('type')!; - - if (groupId !== videoGroupId || type.toLowerCase() !== 'video') { - continue; - } - - const resolution = mediaTag.attributes.get('resolution') - ?? variantStream.attributes.get('resolution'); + if (videoGroupId === null) { + const resolution = variantStream.attributes.get('resolution'); let width: number | null = null; let height: number | null = null; @@ -389,78 +326,88 @@ export class HlsDemuxer extends Demuxer { demuxer: this, inputTrack: null, backingTrack: null, - default: getMediaTagDefault(mediaTag.attributes), - languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')), - lineNumber: mediaTag.lineNumber, - fullPath: mediaTag.fullPath ?? variantStream.fullPath, + default: true, + languageCode: UNDETERMINED_LANGUAGE, + lineNumber: variantStream.lineNumber, + fullPath: variantStream.fullPath, fullCodecString: videoCodecString, - groupId: 3 + videoGroupIds.indexOf(groupId), + groupId: 1, pairingMask: 1n << BigInt(i), - peakBitrate: null, - averageBitrate: null, - name: mediaTag.attributes.get('name'), + peakBitrate: bandwidth, + averageBitrate: averageBandwidth, + name, info: { type: 'video', width, height, }, - }, true); + }, false); + } else { + if (!videoGroupIds.includes(videoGroupId)) { + throw new Error( + `Invalid M3U8 file; variant stream references video group "${videoGroupId}" which` + + ` is not defined in any #EXT-X-MEDIA tags.`, + ); + } + + for (const mediaTag of mediaTags) { + const groupId = mediaTag.attributes.get('group-id')!; + const type = mediaTag.attributes.get('type')!; + + if (groupId !== videoGroupId || type.toLowerCase() !== 'video') { + continue; + } + + const resolution = mediaTag.attributes.get('resolution') + ?? variantStream.attributes.get('resolution'); + let width: number | null = null; + let height: number | null = null; + + if (resolution) { + const match = resolution.match(/^(\d+)x(\d+)$/); + if (match) { + width = Number(match[1]); + height = Number(match[2]); + } + } + + addInternalTrack({ + id: internalTracks.length + 1, + demuxer: this, + inputTrack: null, + backingTrack: null, + default: getMediaTagDefault(mediaTag.attributes), + languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')), + lineNumber: mediaTag.lineNumber, + fullPath: mediaTag.fullPath ?? variantStream.fullPath, + fullCodecString: videoCodecString, + groupId: 3 + videoGroupIds.indexOf(groupId), + pairingMask: 1n << BigInt(i), + peakBitrate: null, + averageBitrate: null, + name: mediaTag.attributes.get('name'), + info: { + type: 'video', + width, + height, + }, + }, true); + } } - } - } - - if (audioCodecString !== null) { - const audioGroupId = variantStream.attributes.get('audio'); - - if (audioGroupId === null) { - const channels = variantStream.attributes.get('channels'); - const parsedChannels = channels !== null - ? Number(channels) - : null; - - addInternalTrack({ - id: internalTracks.length + 1, - demuxer: this, - inputTrack: null, - backingTrack: null, - default: true, - languageCode: UNDETERMINED_LANGUAGE, - lineNumber: variantStream.lineNumber, - fullPath: variantStream.fullPath, - fullCodecString: audioCodecString, - groupId: 2, - pairingMask: 1n << BigInt(i), - peakBitrate: bandwidth, - averageBitrate: averageBandwidth, - name, - info: { - type: 'audio', - numberOfChannels: - parsedChannels !== null - && Number.isInteger(parsedChannels) - && parsedChannels > 0 - ? parsedChannels - : null, - }, - }, false); - } else { - if (!audioGroupIds.includes(audioGroupId)) { + } else if (AUDIO_CODECS.includes(inferredCodec as AudioCodec)) { + if (audioCodecString !== null) { throw new Error( - `Invalid M3U8 file; variant stream references audio group "${audioGroupId}" which` - + ` is not defined in any #EXT-X-MEDIA tags.`, + 'Unsupported M3U8 file; multiple audio codecs found in the CODECS attribute of a' + + ' variant stream.', ); } - for (const mediaTag of mediaTags) { - const groupId = mediaTag.attributes.get('group-id')!; - const type = mediaTag.attributes.get('type')!; + audioCodecString = codecString; - if (groupId !== audioGroupId || type.toLowerCase() !== 'audio') { - continue; - } + const audioGroupId = variantStream.attributes.get('audio'); - const channels = mediaTag.attributes.get('channels') - ?? variantStream.attributes.get('channels'); + if (audioGroupId === null) { + const channels = variantStream.attributes.get('channels'); const parsedChannels = channels !== null ? Number(channels) : null; @@ -470,26 +417,74 @@ export class HlsDemuxer extends Demuxer { demuxer: this, inputTrack: null, backingTrack: null, - default: getMediaTagDefault(mediaTag.attributes), - languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')), - lineNumber: mediaTag.lineNumber, - fullPath: mediaTag.fullPath ?? variantStream.fullPath, + default: true, + languageCode: UNDETERMINED_LANGUAGE, + lineNumber: variantStream.lineNumber, + fullPath: variantStream.fullPath, fullCodecString: audioCodecString, - groupId: 3 + videoGroupIds.length + audioGroupIds.indexOf(groupId), + groupId: 2, pairingMask: 1n << BigInt(i), - peakBitrate: null, - averageBitrate: null, - name: mediaTag.attributes.get('name'), + peakBitrate: bandwidth, + averageBitrate: averageBandwidth, + name, info: { type: 'audio', numberOfChannels: + parsedChannels !== null + && Number.isInteger(parsedChannels) + && parsedChannels > 0 + ? parsedChannels + : null, + }, + }, false); + } else { + if (!audioGroupIds.includes(audioGroupId)) { + throw new Error( + `Invalid M3U8 file; variant stream references audio group "${audioGroupId}" which` + + ` is not defined in any #EXT-X-MEDIA tags.`, + ); + } + + for (const mediaTag of mediaTags) { + const groupId = mediaTag.attributes.get('group-id')!; + const type = mediaTag.attributes.get('type')!; + + if (groupId !== audioGroupId || type.toLowerCase() !== 'audio') { + continue; + } + + const channels = mediaTag.attributes.get('channels') + ?? variantStream.attributes.get('channels'); + const parsedChannels = channels !== null + ? Number(channels) + : null; + + addInternalTrack({ + id: internalTracks.length + 1, + demuxer: this, + inputTrack: null, + backingTrack: null, + default: getMediaTagDefault(mediaTag.attributes), + languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')), + lineNumber: mediaTag.lineNumber, + fullPath: mediaTag.fullPath ?? variantStream.fullPath, + fullCodecString: audioCodecString, + groupId: 3 + videoGroupIds.length + audioGroupIds.indexOf(groupId), + pairingMask: 1n << BigInt(i), + peakBitrate: null, + averageBitrate: null, + name: mediaTag.attributes.get('name'), + info: { + type: 'audio', + numberOfChannels: parsedChannels !== null && Number.isInteger(parsedChannels) && parsedChannels > 0 ? parsedChannels : null, - }, - }, true); + }, + }, true); + } } } } @@ -568,6 +563,10 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { throw new Error('Could not find matching track in underlying media data.'); } + if (!track.isHydrated) { + await track.hydrate(); // Just in case, typically not needed except for cursed shit like recursive .m3u8 + } + this.internalTrack.backingTrack = track; } diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index 56d372b..631a3e7 100644 --- a/src/hls/hls-segmented-input.ts +++ b/src/hls/hls-segmented-input.ts @@ -1,7 +1,7 @@ import { AES_128_BLOCK_SIZE } from '../aes'; import { Segment, SegmentEncryptionInfo, SegmentLocation } from '../segment'; import { SegmentedInput } from '../segmented-input'; -import { AsyncMutex, binarySearchLessOrEqual, toDataView, joinPaths, last } from '../misc'; +import { toDataView, joinPaths } from '../misc'; import { LineReader, Reader } from '../reader'; import { HlsDemuxer } from './hls-demuxer'; import { AttributeList, canIgnoreLine } from './hls-misc'; @@ -9,22 +9,20 @@ import { AttributeList, canIgnoreLine } from './hls-misc'; const IV_STRING_REGEX = /^0[xX][0-9a-fA-F]+$/; export class HlsSegmentedInput extends SegmentedInput { - _demuxer: HlsDemuxer; - _lineReader: LineReader; - _segments: Segment[] = []; - _nextSegmentDuration: number | null = null; - _nextSegmentTitle: string | null = null; - _accumulatedTime = 0; - _headerRead = false; - _mutex = new AsyncMutex(); - _currentKey: SegmentEncryptionInfo | null = null; - _nextSequenceNumber = 0; - _currentFirstSegment: Segment | null = null; - _currentInitSegment: Segment | null = null; - _lastByteRangeEnd: number | null = null; - _nextByteRange: { offset: number; length: number } | null = null; + demuxer: HlsDemuxer; + segments: Segment[] = []; + nextSegmentDuration: number | null = null; + nextSegmentTitle: string | null = null; + accumulatedTime = 0; + headerRead = false; + segmentsPromise: Promise; + currentKey: SegmentEncryptionInfo | null = null; + nextSequenceNumber = 0; + currentFirstSegment: Segment | null = null; + currentInitSegment: Segment | null = null; + lastByteRangeEnd: number | null = null; + nextByteRange: { offset: number; length: number } | null = null; - /** @internal */ constructor( demuxer: HlsDemuxer, path: string, @@ -32,101 +30,42 @@ export class HlsSegmentedInput extends SegmentedInput { ) { super(demuxer.input, path); - this._demuxer = demuxer; + this.demuxer = demuxer; + let lineReader: LineReader; if (reader) { - this._lineReader = new LineReader(() => reader, canIgnoreLine); + lineReader = new LineReader(() => reader, canIgnoreLine); } else { - this._lineReader = new LineReader(async () => { - const source = await this._demuxer.input._getSourceUncached({ path: this.path }); + lineReader = new LineReader(async () => { + const source = await this.demuxer.input._getSourceUncached({ path: this.path }); return new Reader(source); }, canIgnoreLine); } - } - - async getFirstSegment() { - if (this._segments.length === 0) { - await this._readNextSegment(); - } - - return this._segments[0] ?? null; - } - - async getSegmentAt(relativeTimestamp: number) { - await this._readUntilSegmentAt(relativeTimestamp); - - const index = binarySearchLessOrEqual(this._segments, relativeTimestamp, x => x.relativeTimestamp); - if (index === -1) { - return null; - } - - return this._segments[index]!; - } - - async getNextSegment(segment: Segment) { - const index = this._segments.indexOf(segment); - if (index === -1) { - throw new Error('Segment was not created by this variant.'); - } - - if (index + 1 < this._segments.length) { - return this._segments[index + 1]!; - } - - if (this._lineReader.reachedEnd) { - return null; - } - - await this._readNextSegment(); - return this._segments[index + 1] ?? null; - } - - async getPreviousSegment(segment: Segment): Promise { - const index = this._segments.indexOf(segment); - if (index === -1) { - throw new Error('Segment was not created by this variant.'); - } - - if (index - 1 >= 0) { - return this._segments[index - 1]!; - } - - return null; - } - - async _readNextSegment() { - const segmentCount = this._segments.length; - const release = await this._mutex.acquire(); - - try { - if (segmentCount < this._segments.length) { - // The next segment has already been read by someone else, great - return; - } + this.segmentsPromise ??= (async () => { while (true) { - let line = this._lineReader.readNextLine(); + let line = lineReader.readNextLine(); if (line instanceof Promise) line = await line; if (line === null) { - return; + break; } - if (!this._headerRead) { + if (!this.headerRead) { if (line !== '#EXTM3U') { throw new Error('Invalid M3U8 file; expected first line to be #EXTM3U.'); } - this._headerRead = true; + this.headerRead = true; continue; } if (!line.startsWith('#')) { - if (this._nextSegmentDuration === null) { + if (this.nextSegmentDuration === null) { throw new Error('Invalid M3U8 file; a segment must be preceeded by a #EXTINF tag.'); } - let key = this._currentKey; + let key = this.currentKey; if (key && !key.iv) { // "the Media Sequence Number is to be used as the IV when decrypting a Media Segment, by // putting its big-endian binary representation into a 16-octet (128-bit) buffer and padding @@ -134,8 +73,8 @@ export class HlsSegmentedInput extends SegmentedInput { const iv = new Uint8Array(AES_128_BLOCK_SIZE); const view = toDataView(iv); - view.setUint32(8, Math.floor(this._nextSequenceNumber / (2 ** 32))); - view.setUint32(12, this._nextSequenceNumber); + view.setUint32(8, Math.floor(this.nextSequenceNumber / (2 ** 32))); + view.setUint32(12, this.nextSequenceNumber); key = { ...key, iv }; } @@ -143,35 +82,33 @@ export class HlsSegmentedInput extends SegmentedInput { const fullPath = joinPaths(this.path, line); const location: SegmentLocation = { path: fullPath, - offset: this._nextByteRange?.offset ?? 0, - length: this._nextByteRange?.length ?? null, + offset: this.nextByteRange?.offset ?? 0, + length: this.nextByteRange?.length ?? null, }; const segment = new Segment( this, location, - this._accumulatedTime, - this._nextSegmentDuration, - this._nextSegmentTitle, + this.accumulatedTime, + this.nextSegmentDuration, + this.nextSegmentTitle, key, - this._currentFirstSegment, - this._currentInitSegment, + this.currentFirstSegment, + this.currentInitSegment, ); - this._segments.push(segment); - this._accumulatedTime += this._nextSegmentDuration; - this._nextSequenceNumber++; - this._currentFirstSegment ??= segment; + this.segments.push(segment); + this.accumulatedTime += this.nextSegmentDuration; + this.nextSequenceNumber++; + this.currentFirstSegment ??= segment; - this._nextSegmentDuration = null; - this._nextSegmentTitle = null; + this.nextSegmentDuration = null; + this.nextSegmentTitle = null; - if (this._nextByteRange === null) { - this._lastByteRangeEnd = null; + if (this.nextByteRange === null) { + this.lastByteRangeEnd = null; } else { - this._nextByteRange = null; + this.nextByteRange = null; } - - return; } if (line.startsWith('#EXTINF:')) { @@ -184,8 +121,8 @@ export class HlsSegmentedInput extends SegmentedInput { } const title = commaIndex === -1 ? null : extinfContent.slice(commaIndex + 1).trim() || null; - this._nextSegmentDuration = duration; - this._nextSegmentTitle = title; + this.nextSegmentDuration = duration; + this.nextSegmentTitle = title; } else if (line.startsWith('#EXT-X-MAP:')) { const attributes = new AttributeList(line.slice(11)); const uri = attributes.get('uri'); @@ -195,17 +132,17 @@ export class HlsSegmentedInput extends SegmentedInput { const byteRange = attributes.get('byterange'); if (byteRange !== null) { - this._parseAndUpdateByteRange(byteRange); + this.parseAndUpdateByteRange(byteRange); } const fullPath = joinPaths(this.path, uri); const location: SegmentLocation = { path: fullPath, - offset: this._nextByteRange?.offset ?? 0, - length: this._nextByteRange?.length ?? null, + offset: this.nextByteRange?.offset ?? 0, + length: this.nextByteRange?.length ?? null, }; - if (this._currentKey?.method === 'AES-128' && !this._currentKey.iv) { + if (this.currentKey?.method === 'AES-128' && !this.currentKey.iv) { // Required by the spec throw new Error('IV attribute must be set on #EXT-X-KEY tag preceding the #EXT-X-MAP tag.'); } @@ -213,31 +150,31 @@ export class HlsSegmentedInput extends SegmentedInput { const segment = new Segment( this, location, - this._accumulatedTime, + this.accumulatedTime, 0, null, - this._currentKey, + this.currentKey, null, null, ); // Accumulated time and sequence number are not updated in this case - this._currentInitSegment = segment; + this.currentInitSegment = segment; - this._nextSegmentDuration = null; - this._nextSegmentTitle = null; + this.nextSegmentDuration = null; + this.nextSegmentTitle = null; - if (this._nextByteRange === null) { - this._lastByteRangeEnd = null; + if (this.nextByteRange === null) { + this.lastByteRangeEnd = null; } else { - this._nextByteRange = null; + this.nextByteRange = null; } } else if (line.startsWith('#EXT-X-KEY:')) { const attributes = new AttributeList(line.slice(11)); const method = attributes.get('method'); if (method === 'NONE') { - this._currentKey = null; + this.currentKey = null; } else if (method === 'AES-128') { const uri = attributes.get('uri'); if (!uri) { @@ -261,7 +198,7 @@ export class HlsSegmentedInput extends SegmentedInput { } } - this._currentKey = { + this.currentKey = { method: 'AES-128', keyUri: joinPaths(this.path, uri), iv, @@ -278,20 +215,20 @@ export class HlsSegmentedInput extends SegmentedInput { throw new Error(`Invalid EXT-X-MEDIA-SEQUENCE value '${value}'.`); } - this._nextSequenceNumber = number; + this.nextSequenceNumber = number; } else if (line.startsWith('#EXT-X-BYTERANGE:')) { - this._parseAndUpdateByteRange(line.slice(17)); + this.parseAndUpdateByteRange(line.slice(17)); } else if (line.startsWith('#EXT-X-DISCONTINUITY')) { - this._currentFirstSegment = null; - this._currentInitSegment = null; + this.currentFirstSegment = null; + this.currentInitSegment = null; } } - } finally { - release(); - } + + return this.segments; + })(); } - _parseAndUpdateByteRange(content: string) { + parseAndUpdateByteRange(content: string) { const atIndex = content.indexOf('@'); const length = Number(atIndex === -1 ? content : content.slice(0, atIndex)); @@ -306,26 +243,19 @@ export class HlsSegmentedInput extends SegmentedInput { throw new Error(`Invalid #EXT-X-BYTERANGE offset '${content}'.`); } } else { - if (this._lastByteRangeEnd === null) { + if (this.lastByteRangeEnd === null) { throw new Error( 'Invalid M3U8 file; #EXT-X-BYTERANGE without offset requires a previous byte range.', ); } - offset = this._lastByteRangeEnd; + offset = this.lastByteRangeEnd; } - this._nextByteRange = { offset, length }; - this._lastByteRangeEnd = offset + length; + this.nextByteRange = { offset, length }; + this.lastByteRangeEnd = offset + length; } - async _readUntilSegmentAt(relativeTimestamp: number) { - while (!this._lineReader.reachedEnd) { - const lastSegment = last(this._segments); - if (lastSegment && lastSegment.relativeTimestamp > relativeTimestamp) { - break; - } - - await this._readNextSegment(); - } + async getSegments() { + return this.segmentsPromise; } } diff --git a/src/input-track.ts b/src/input-track.ts index d9030bb..d80c127 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -175,12 +175,20 @@ export abstract class InputTrack { * with a negative timestamp should not be presented. */ async getFirstTimestamp() { + if (!this.isHydrated) { + await this.hydrate(); + } + const firstPacket = await this._backing.getFirstPacket({ metadataOnly: true }); return firstPacket?.timestamp ?? 0; } /** Returns the end timestamp of the last packet of this track, in seconds. */ async computeDuration() { + if (!this.isHydrated) { + await this.hydrate(); + } + const lastPacket = await this._backing.getPacket(Infinity, { metadataOnly: true }); return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0); } @@ -310,7 +318,7 @@ export abstract class InputTrack { ); } - getUnhydrated>(key: K): this[K] | null { + get>(key: K): this[K] | null { try { return this[key]; } catch (error) { @@ -322,13 +330,13 @@ export abstract class InputTrack { } } - resolve>(key: K): MaybePromise { + async resolve>(key: K): Promise { try { return this[key]; } catch (error) { if (error instanceof TrackNotHydratedError) { - return this.hydrate() - .then(() => this[key]); + await this.hydrate(); + return this[key]; } throw error; @@ -435,7 +443,11 @@ export class InputVideoTrack extends InputTrack { } /** Returns the color space of the track's samples. */ - getColorSpace() { + async getColorSpace() { + if (!this.isHydrated) { + await this.hydrate(); + } + return this._backing.getColorSpace(); } @@ -449,7 +461,11 @@ export class InputVideoTrack extends InputTrack { } /** Checks if this track may contain transparent samples with alpha data. */ - canBeTransparent() { + async canBeTransparent() { + if (!this.isHydrated) { + await this.hydrate(); + } + return this._backing.canBeTransparent(); } @@ -458,17 +474,29 @@ export class InputVideoTrack extends InputTrack { * track's packets using a [`VideoDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/VideoDecoder). Returns * null if the track's codec is unknown. */ - getDecoderConfig() { + async getDecoderConfig() { + if (!this.isHydrated) { + await this.hydrate(); + } + return this._backing.getDecoderConfig(); } async getCodecParameterString() { + if (!this.isHydrated) { + await this.hydrate(); + } + const decoderConfig = await this._backing.getDecoderConfig(); return decoderConfig?.codec ?? null; } async canDecode() { try { + if (!this.isHydrated) { + await this.hydrate(); + } + const decoderConfig = await this._backing.getDecoderConfig(); if (!decoderConfig) { return false; @@ -558,17 +586,29 @@ export class InputAudioTrack extends InputTrack { * track's packets using an [`AudioDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/AudioDecoder). Returns * null if the track's codec is unknown. */ - getDecoderConfig() { + async getDecoderConfig() { + if (!this.isHydrated) { + await this.hydrate(); + } + return this._backing.getDecoderConfig(); } async getCodecParameterString() { + if (!this.isHydrated) { + await this.hydrate(); + } + const decoderConfig = await this._backing.getDecoderConfig(); return decoderConfig?.codec ?? null; } async canDecode() { try { + if (!this.isHydrated) { + await this.hydrate(); + } + const decoderConfig = await this._backing.getDecoderConfig(); if (!decoderConfig) { return false; @@ -613,8 +653,7 @@ export class InputAudioTrack extends InputTrack { export class TrackNotHydratedError extends Error { /** Creates a new {@link InputDisposedError}. */ constructor( - message = 'InputTrack is not hydrated; please call hydrate() first, or use the resolve() or getUnhydrated()' - + ' method.', + message = 'InputTrack is not hydrated; please call hydrate() first, or use the resolve() or get() methods.', ) { super(message); this.name = 'TrackNotHydratedError'; diff --git a/src/input.ts b/src/input.ts index deeea26..6e65efe 100644 --- a/src/input.ts +++ b/src/input.ts @@ -43,7 +43,7 @@ export type InputOptions = { /** A list of supported formats. If the source file is not of one of these formats, then it cannot be read. */ formats: InputFormat[]; /** The source from which data will be read. */ - source: S | ((request: SourceRequest) => MaybePromise); + source: S | ((request: SourceRequest) => MaybePromise); entryPath?: string; initInput?: Input; }; @@ -77,11 +77,16 @@ export class Input implements Disposable { /** @internal */ _sourceCache: { request: SourceRequest; - sourcePromise: Promise; + sourcePromise: Promise; age: number; cacheGroup: number; }[] = []; + /** + * Called whenever a source is resolved for internal operations. + */ + onSource?: (source: Source, request: SourceRequest | null) => unknown; + /** True if the input has been disposed. */ get disposed() { return this._disposed; @@ -131,6 +136,8 @@ export class Input implements Disposable { throw new TypeError('The returned Source must not be disposed.'); } + this.onSource?.(source, request); + return source; } @@ -168,6 +175,7 @@ export class Input implements Disposable { let source: Source; if (this._source instanceof Source) { source = this._source; + this.onSource?.(source, null); } else { assert(this._entryPath !== null); source = await this._getSourceUncached({ path: this._entryPath }); @@ -188,12 +196,42 @@ export class Input implements Disposable { } /** - * Returns the source from which this input file reads its data. This is the same source that was passed to the - * constructor. + * Returns a source for the given request. + * + * If this input was created with a direct {@link Source}, that source is always returned. If this input was created + * with a source function, this method resolves it using the provided request or the entry path. + */ + getSource(request?: SourceRequest): MaybePromise { + if (this._source instanceof Source) { + return this._source; + } + + assert(this._entryPath !== null); + return this._getSourceCached(request ?? { path: this._entryPath }); + } + + /** + * @deprecated Use {@link getSource} instead. + * + * Returns the source from which this input file reads data for the entry path. Throws if the source-resolving + * function returns a Promise. */ get source() { - // TODO throw if function or some shit? - return this._source; + if (this._source instanceof Source) { + return this._source; + } + + assert(this._entryPath !== null); + + const source = this._source({ path: this._entryPath }); + if (source instanceof Promise) { + throw new TypeError( + 'Input.source cannot be used when the source function resolves asynchronously.' + + ' Use getSource() instead.', + ); + } + + return source; } /** @@ -248,7 +286,7 @@ export class Input implements Disposable { return Math.min(...firstTimestamps); } - /** Returns the list of all tracks of this input file. */ + /** Returns the list of all tracks of this input file in the order in which they appear in the file. */ async getTracks(query?: TrackQuery) { const demuxer = await this._getDemuxer(); const tracks = this._tracksCache ??= await demuxer.getTracks(); @@ -318,6 +356,16 @@ export class Input implements Disposable { return demuxer.getMetadataTags(); } + async allTracksAreHydrated() { + const tracks = await this.getTracks(); + return tracks.every(x => x.isHydrated); + } + + async hydrateAllTracks() { + const tracks = await this.getTracks(); + await Promise.all(tracks.map(x => x.hydrate())); + } + /** * Disposes this input and frees connected resources. When an input is disposed, ongoing read operations will be * canceled, all future read operations will fail, any open decoders will be closed, and all ongoing media sink diff --git a/src/media-sink.ts b/src/media-sink.ts index 140ee10..8fa357a 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -134,13 +134,17 @@ export class EncodedPacketSink { * Retrieves the track's first packet (in decode order), or null if it has no packets. The first packet is very * likely to be a key packet, but it doesn't have to be. */ - getFirstPacket(options: PacketRetrievalOptions = {}) { + async getFirstPacket(options: PacketRetrievalOptions = {}) { validatePacketRetrievalOptions(options); if (this._track.input._disposed) { throw new InputDisposedError(); } + if (!this._track.isHydrated) { + await this._track.hydrate(); + } + return maybeFixPacketType(this._track, this._track._backing.getFirstPacket(options), options); } @@ -169,7 +173,7 @@ export class EncodedPacketSink { * * @param timestamp - The timestamp used for retrieval, in seconds. */ - getPacket(timestamp: number, options: PacketRetrievalOptions = {}) { + async getPacket(timestamp: number, options: PacketRetrievalOptions = {}) { validateTimestamp(timestamp); validatePacketRetrievalOptions(options); @@ -177,6 +181,10 @@ export class EncodedPacketSink { throw new InputDisposedError(); } + if (!this._track.isHydrated) { + await this._track.hydrate(); + } + return maybeFixPacketType(this._track, this._track._backing.getPacket(timestamp, options), options); } @@ -184,7 +192,7 @@ export class EncodedPacketSink { * Retrieves the packet following the given packet (in decode order), or null if the given packet is the * last packet. */ - getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}) { + async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}) { if (!(packet instanceof EncodedPacket)) { throw new TypeError('packet must be an EncodedPacket.'); } @@ -194,6 +202,10 @@ export class EncodedPacketSink { throw new InputDisposedError(); } + if (!this._track.isHydrated) { + await this._track.hydrate(); + } + return maybeFixPacketType(this._track, this._track._backing.getNextPacket(packet, options), options); } @@ -216,6 +228,10 @@ export class EncodedPacketSink { throw new InputDisposedError(); } + if (!this._track.isHydrated) { + await this._track.hydrate(); + } + if (!options.verifyKeyPackets) { return this._track._backing.getKeyPacket(timestamp, options); } @@ -251,6 +267,10 @@ export class EncodedPacketSink { throw new InputDisposedError(); } + if (!this._track.isHydrated) { + await this._track.hydrate(); + } + if (!options.verifyKeyPackets) { return this._track._backing.getNextKeyPacket(packet, options); } diff --git a/src/segment.ts b/src/segment.ts index 0cb4d22..3fd7772 100644 --- a/src/segment.ts +++ b/src/segment.ts @@ -19,17 +19,17 @@ export type SegmentLocation = { }; export class Segment { - readonly variant: SegmentedInput; - readonly location: SegmentLocation; - readonly relativeTimestamp: number; - readonly duration: number; - readonly title: string | null; - readonly encryption: SegmentEncryptionInfo | null; - readonly firstSegment: Segment | null; - readonly initSegment: Segment | null; + input: SegmentedInput; + location: SegmentLocation; + relativeTimestamp: number; + duration: number; + title: string | null; + encryption: SegmentEncryptionInfo | null; + firstSegment: Segment | null; + initSegment: Segment | null; constructor( - variant: SegmentedInput, + input: SegmentedInput, location: SegmentLocation, relativeTimestamp: number, duration: number, @@ -38,7 +38,7 @@ export class Segment { firstSegment: Segment | null, initSegment: Segment | null, ) { - this.variant = variant; + this.input = input; this.location = location; this.relativeTimestamp = relativeTimestamp; this.duration = duration; @@ -48,78 +48,85 @@ export class Segment { this.initSegment = initSegment; } - toInput(): Promise { - const cacheEntry = this.variant._inputCache.find(x => x.segment === this); + toInput(): Input { + const cacheEntry = this.input.inputCache.find(x => x.segment === this); if (cacheEntry) { - cacheEntry.age = this.variant._nextInputCacheAge++; - return cacheEntry.inputPromise; + cacheEntry.age = this.input.nextInputCacheAge++; + return cacheEntry.input; } - const inputPromise = (async () => { - let initInputPromise: Promise | null = null; - if (this.initSegment || this.firstSegment) { - initInputPromise = (this.initSegment ?? this.firstSegment)!.toInput(); - } + let initInput: Input | null = null; + if (this.initSegment || this.firstSegment) { + initInput = (this.initSegment ?? this.firstSegment)!.toInput(); + } - let source: Source; - - const needsSlice = this.location.offset > 0 || this.location.length !== null; - - if (!this.encryption) { - source = await this.variant.input._getSourceCached({ path: this.location.path }); - if (needsSlice) { - source = source.slice(this.location.offset, this.location.length ?? undefined); - } - } else { - assert(this.encryption.iv); - - let ciphertextSource = await this.variant.input._getSourceCached({ path: this.location.path }); - if (needsSlice) { - // Slice before decrypting - ciphertextSource = ciphertextSource.slice(this.location.offset, this.location.length ?? undefined); + const input = new Input({ + entryPath: this.location.path, + source: async (request) => { + if (request.path !== this.location.path) { + // This code technically allows for recursive .m3u8 files for example. Uncached because the added + // input adds its own layer of caching, so here we just do a passthrough. + return this.input.input._getSourceUncached(request); } - const ciphertextReader = new Reader(ciphertextSource); + let source: Source; + const needsSlice = this.location.offset > 0 || this.location.length !== null; - const stream = createAesDecryptStream(ciphertextReader, async () => { - const keySource = await this.variant.input._getSourceCached( - { path: this.encryption!.keyUri }, - ENCRYPTION_KEY_CACHE_GROUP, - ); - const keyReader = new Reader(keySource); - const keySlice = await keyReader.requestSlice(0, AES_128_BLOCK_SIZE); - if (!keySlice) { - throw new Error('Invalid AES-128 key; expected at least 16 bytes of data.'); + if (!this.encryption) { + source = await this.input.input._getSourceCached(request); + if (needsSlice) { + source = source.slice(this.location.offset, this.location.length ?? undefined); } - const key = readBytes(keySlice, AES_128_BLOCK_SIZE); + } else { + assert(this.encryption.iv); - return { key, iv: this.encryption!.iv! }; - }); + let ciphertextSource = await this.input.input._getSourceCached(request); + if (needsSlice) { + // Slice before decrypting + ciphertextSource = ciphertextSource.slice( + this.location.offset, + this.location.length ?? undefined, + ); + } - source = new ReadableStreamSource(stream); - } + const ciphertextReader = new Reader(ciphertextSource); - const initInput = await initInputPromise; + const stream = createAesDecryptStream(ciphertextReader, async () => { + const keySource = await this.input.input._getSourceCached( + { path: this.encryption!.keyUri }, + ENCRYPTION_KEY_CACHE_GROUP, + ); + const keyReader = new Reader(keySource); + const keySlice = await keyReader.requestSlice(0, AES_128_BLOCK_SIZE); + if (!keySlice) { + throw new Error('Invalid AES-128 key; expected at least 16 bytes of data.'); + } + const key = readBytes(keySlice, AES_128_BLOCK_SIZE); - return new Input({ - source, - formats: this.variant.input._formats, - initInput: initInput ?? undefined, - }); - })(); + return { key, iv: this.encryption!.iv! }; + }); - this.variant._inputCache.push({ + source = new ReadableStreamSource(stream); + } + + return source; + }, + formats: this.input.input._formats, + initInput: initInput ?? undefined, + }); + + this.input.inputCache.push({ segment: this, - inputPromise, - age: this.variant._nextInputCacheAge++, + input, + age: this.input.nextInputCacheAge++, }); const MAX_INPUT_CACHE_SIZE = 4; - if (this.variant._inputCache.length > MAX_INPUT_CACHE_SIZE) { - const minAgeIndex = arrayArgmin(this.variant._inputCache, x => x.age); - this.variant._inputCache.splice(minAgeIndex, 1); + if (this.input.inputCache.length > MAX_INPUT_CACHE_SIZE) { + const minAgeIndex = arrayArgmin(this.input.inputCache, x => x.age); + this.input.inputCache.splice(minAgeIndex, 1); } - return inputPromise; + return input; } } diff --git a/src/segmented-input.ts b/src/segmented-input.ts index c346922..5a651f1 100644 --- a/src/segmented-input.ts +++ b/src/segmented-input.ts @@ -21,7 +21,7 @@ import { import { Segment } from './segment'; import { PacketRetrievalOptions } from './media-sink'; import { MetadataTags, TrackDisposition } from './metadata'; -import { arrayCount, assert, Rotation } from './misc'; +import { arrayCount, assert, binarySearchLessOrEqual, Rotation } from './misc'; import { EncodedPacket } from './packet'; import { NullSource } from './source'; @@ -42,47 +42,57 @@ export type AssociatedGroup = { }; export abstract class SegmentedInput { - readonly input: Input; - readonly path: string; + input: Input; + path: string; - otherInputLol: Input | null = null; - /** @internal */ - _nextInputCacheAge = 0; - /** @internal */ - _inputCache: { + virtualInput: Input | null = null; + nextInputCacheAge = 0; + inputCache: { segment: Segment; - inputPromise: Promise; // We store the promise so it's immediately available in the cache + input: Input; age: number; }[] = []; - /** @internal */ constructor(input: Input, path: string) { this.input = input; this.path = path; } - abstract getFirstSegment(): Promise; - abstract getSegmentAt(timestamp: number): Promise; - abstract getNextSegment(segment: Segment): Promise; - abstract getPreviousSegment(segment: Segment): Promise; + abstract getSegments(): Promise; - async* segments(startTimestamp?: number) { - let currentSegment: Segment | null; + async getFirstSegment() { + const segments = await this.getSegments(); + return segments[0] ?? null; + } - if (startTimestamp !== undefined) { - currentSegment = await this.getSegmentAt(startTimestamp); - } else { - currentSegment = await this.getFirstSegment(); + async getSegmentAt(timestamp: number) { + const segments = await this.getSegments(); + const index = binarySearchLessOrEqual(segments, timestamp, x => x.relativeTimestamp); + if (index === -1) { + return null; } - while (currentSegment !== null) { - yield currentSegment; - currentSegment = await this.getNextSegment(currentSegment); - } + return segments[index]!; + } + + async getNextSegment(segment: Segment): Promise { + const segments = await this.getSegments(); + const index = segments.indexOf(segment); + assert(index !== -1); + + return segments[index + 1] ?? null; + } + + async getPreviousSegment(segment: Segment): Promise { + const segments = await this.getSegments(); + const index = segments.indexOf(segment); + assert(index !== -1); + + return segments[index - 1] ?? null; } toInput() { - return this.otherInputLol ??= new Input({ + return this.virtualInput ??= new Input({ source: new NullSource(), formats: [new VirtualInputFormat(() => new SegmentedInputDemuxer(this.input, this))], }); @@ -90,24 +100,24 @@ export abstract class SegmentedInput { } class SegmentedInputDemuxer extends Demuxer { - variant: SegmentedInput; + segmentedInput: SegmentedInput; tracksPromise: Promise | null = null; firstSegment: Segment | null = null; firstSegmentFirstTimestamps = new WeakMap(); - constructor(input: Input, variant: SegmentedInput) { + constructor(input: Input, segmentedInput: SegmentedInput) { super(input); - this.variant = variant; + this.segmentedInput = segmentedInput; } override async isSupported() { - const firstSegment = await this.variant.getFirstSegment(); + const firstSegment = await this.segmentedInput.getFirstSegment(); if (!firstSegment) { return true; // There's no data but that's supported } - const input = await firstSegment.toInput(); + const input = firstSegment.toInput(); return input.isSupported(); } @@ -121,12 +131,12 @@ class SegmentedInputDemuxer extends Demuxer { async getTracks(): Promise { return this.tracksPromise ??= (async () => { - this.firstSegment = await this.variant.getFirstSegment(); + this.firstSegment = await this.segmentedInput.getFirstSegment(); if (!this.firstSegment) { return []; } - const input = await this.firstSegment.toInput(); + const input = this.firstSegment.toInput(); const inputTracks = await input.getTracks(); const tracks: InputTrack[] = []; @@ -159,7 +169,7 @@ class SegmentedInputDemuxer extends Demuxer { if (this.firstSegmentFirstTimestamps.has(firstSegment)) { firstSegmentFirstTimestamp = this.firstSegmentFirstTimestamps.get(firstSegment)!; } else { - const firstInput = await firstSegment.toInput(); + const firstInput = firstSegment.toInput(); firstSegmentFirstTimestamp = await firstInput.getFirstTimestamp(); this.firstSegmentFirstTimestamps.set(firstSegment, firstSegmentFirstTimestamp); } @@ -312,12 +322,12 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { let currentSegment: Segment | null = info.segment; while (true) { - const nextSegment = await this.demuxer.variant.getNextSegment(currentSegment); + const nextSegment = await this.demuxer.segmentedInput.getNextSegment(currentSegment); if (!nextSegment) { return null; } - const nextInput = await nextSegment.toInput(); + const nextInput = nextSegment.toInput(); const nextTracks = await nextInput.getTracks(); const nextTrack = nextTracks.find(t => t.type === info.track.type && t.number === info.track.number); @@ -348,13 +358,13 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { options: PacketRetrievalOptions, keyframesOnly: boolean, ): Promise { - let currentSegment = await this.demuxer.variant.getSegmentAt(timestamp); + let currentSegment = await this.demuxer.segmentedInput.getSegmentAt(timestamp); if (!currentSegment) { return null; } while (currentSegment) { - const input = await currentSegment.toInput(); + const input = currentSegment.toInput(); const tracks = await input.getTracks(); const track = tracks.find(t => ( t.type === this.firstInputTrack.type && t.number === this.firstInputTrack.number @@ -362,7 +372,7 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { if (!track) { // Search the previous segment - currentSegment = await this.demuxer.variant.getPreviousSegment(currentSegment); + currentSegment = await this.demuxer.segmentedInput.getPreviousSegment(currentSegment); continue; } @@ -375,7 +385,7 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { if (!packet) { // Search the previous segment - currentSegment = await this.demuxer.variant.getPreviousSegment(currentSegment); + currentSegment = await this.demuxer.segmentedInput.getPreviousSegment(currentSegment); continue; } diff --git a/todo.txt b/todo.txt index c40d0e1..e26b64e 100644 --- a/todo.txt +++ b/todo.txt @@ -11,5 +11,7 @@ Also: for robustness, do a different track matching algorithm for hls playback. - Timestamp across variants; i think the date should actually be used. Make the timestamp relative to the date? How does that play with timeResolution? -- add comment that addTracks by default returns in the order in the file. Same with track.number -- ALL_FORMATS but for HLS only \ No newline at end of file +- ALL_FORMATS but for HLS only + +- computeDuration and getFirstTimestamp are dangerous with HLS +- keep input/demuxer.isSupported()? \ No newline at end of file