UNPKG

mediabunny

Version:

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

641 lines (640 loc) 31.5 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/. */ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) { if (value !== null && value !== void 0) { if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected."); var dispose, inner; if (async) { if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined."); dispose = value[Symbol.asyncDispose]; } if (dispose === void 0) { if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined."); dispose = value[Symbol.dispose]; if (async) inner = dispose; } if (typeof dispose !== "function") throw new TypeError("Object not disposable."); if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } }; env.stack.push({ value: value, dispose: dispose, async: async }); } else if (async) { env.stack.push({ async: true }); } return value; }; var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) { return function (env) { function fail(e) { env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e; env.hasError = true; } var r, s = 0; function next() { while (r = env.stack.pop()) { try { if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next); if (r.dispose) { var result = r.dispose.call(r.value); if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); }); } else s |= 1; } catch (e) { fail(e); } } if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve(); if (env.hasError) throw env.error; } return next(); }; })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }); import { AES_128_BLOCK_SIZE, createAes128CbcDecryptStream } from '../aes.js'; import { ENCRYPTION_KEY_CACHE_GROUP, Input } from '../input.js'; import { SegmentedInput } from '../segmented-input.js'; import { toDataView, joinPaths, last, assert, binarySearchLessOrEqual, arrayArgmin, wait, base64ToBytes, } from '../misc.js'; import { readAllLines, readBytes, Reader } from '../reader.js'; import { CustomPathedSource, ReadableStreamSource } from '../source.js'; import { AttributeList, canIgnoreLine, TAG_BYTERANGE, TAG_DISCONTINUITY, TAG_ENDLIST, TAG_EXTINF, TAG_KEY, TAG_MAP, TAG_MEDIA_SEQUENCE, TAG_PLAYLIST_TYPE, TAG_PROGRAM_DATE_TIME, TAG_TARGETDURATION, } from './hls-misc.js'; import { HlsInputFormat } from '../input-format.js'; import { parsePsshBoxContents, psshBoxesAreEqual } from '../isobmff/isobmff-misc.js'; const IV_STRING_REGEX = /^0[xX][0-9a-fA-F]+$/; const BASE64_DATA_URI_REGEX = /^data:.*;base64,/i; export class HlsSegmentedInput extends SegmentedInput { constructor(demuxer, path, trackDeclarations, lines) { super(demuxer.input, path, trackDeclarations); this.segments = []; this.nextLines = null; this.currentUpdateSegmentsPromise = null; this.streamHasEnded = false; this.lastSegmentUpdateTime = -Infinity; this.refreshInterval = 5; // Reasonable default in case the playlist doesn't specify it this.demuxer = demuxer; this.nextLines = lines; } runUpdateSegments() { return this.currentUpdateSegmentsPromise ??= (async () => { try { const remainingWaitTimeMs = this.getRemainingWaitTimeMs(); if (remainingWaitTimeMs > 0) { await wait(remainingWaitTimeMs); } this.lastSegmentUpdateTime = performance.now(); await this.updateSegments(); } finally { this.currentUpdateSegmentsPromise = null; } })(); } getRemainingWaitTimeMs() { const elapsed = performance.now() - this.lastSegmentUpdateTime; const result = Math.max(0, 1000 * this.refreshInterval - elapsed); if (result <= 50) { // If only a little bit of time is left, don't wait at all; this removes the chance for timing race // conditions when running a task every `refreshInterval` seconds return 0; } return result; } /** * Reads and parses the segment info from the playlist file. When called more than one, it updates the existing * segments by appending the new ones. Existing segments are never removed. */ async updateSegments() { let lines = this.nextLines; this.nextLines = null; if (!lines) { const env_1 = { stack: [], error: void 0, hasError: false }; try { const ref = __addDisposableResource(env_1, await this.demuxer.input._getSourceUncached({ path: this.path, isRoot: false }), false); const reader = new Reader(ref.source); const slice = await reader.requestEntireFile(); assert(slice); lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine }); } catch (e_1) { env_1.error = e_1; env_1.hasError = true; } finally { __disposeResources(env_1); } } let headerRead = false; let accumulatedTime = 0; let nextSegmentDuration = null; let currentKey = null; let nextSequenceNumber = 0; let currentFirstSegment = null; let currentInitSegment = null; let lastByteRangeEnd = null; let nextByteRange = null; let lastProgramDateTimeSeconds = null; let targetDuration = null; let segmentSeen = false; // Used for repeated parses where our job it is to only add the new segments let prevLastSegment = last(this.segments) ?? null; const parseByteRange = (content) => { const atIndex = content.indexOf('@'); const length = Number(atIndex === -1 ? content : content.slice(0, atIndex)); if (!Number.isInteger(length) || length < 0) { throw new Error(`Invalid #EXT-X-BYTERANGE length '${content}'.`); } let offset = null; if (atIndex !== -1) { offset = Number(content.slice(atIndex + 1)); if (!Number.isInteger(offset) || offset < 0) { throw new Error(`Invalid #EXT-X-BYTERANGE offset '${content}'.`); } } return { length, offset }; }; const setNextSequenceNumber = (number) => { nextSequenceNumber = number; if (prevLastSegment) { assert(prevLastSegment.sequenceNumber !== null); if (prevLastSegment.sequenceNumber < number) { // The sequence number has finally exceeded the last sequence number we knew, meaning we can now // continue the segment list from there. Set some data to continue where we left off. accumulatedTime = prevLastSegment.timestamp + prevLastSegment.duration; currentFirstSegment = prevLastSegment.firstSegment; currentInitSegment = prevLastSegment.initSegment; lastProgramDateTimeSeconds = prevLastSegment.lastProgramDateTimeSeconds; prevLastSegment = null; } } }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!headerRead) { if (line !== '#EXTM3U') { throw new Error('Invalid M3U8 file; expected first line to be #EXTM3U.'); } headerRead = true; continue; } if (!line.startsWith('#')) { if (!prevLastSegment) { if (nextSegmentDuration === null) { throw new Error('Invalid M3U8 file; a segment must be preceded by an #EXTINF tag.'); } let key = currentKey; if (key && key.method === 'AES-128' && !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 // (on the left) with zeros" const iv = new Uint8Array(AES_128_BLOCK_SIZE); const view = toDataView(iv); view.setUint32(8, Math.floor(nextSequenceNumber / (2 ** 32))); view.setUint32(12, nextSequenceNumber); key = { ...key, iv }; } const fullPath = joinPaths(this.path, line); const location = { path: fullPath, offset: nextByteRange?.offset ?? 0, length: nextByteRange?.length ?? null, }; const segment = { timestamp: accumulatedTime, relativeToUnixEpoch: lastProgramDateTimeSeconds !== null, firstSegment: currentFirstSegment, sequenceNumber: nextSequenceNumber, location, duration: nextSegmentDuration, encryption: key, initSegment: currentInitSegment, lastProgramDateTimeSeconds, }; currentFirstSegment ??= segment; accumulatedTime += nextSegmentDuration; this.segments.push(segment); } else { // We're still seeing segments we already know about } nextSegmentDuration = null; if (nextByteRange === null) { lastByteRangeEnd = null; } else { nextByteRange = null; } setNextSequenceNumber(nextSequenceNumber + 1); } if (line.startsWith(TAG_EXTINF)) { if (prevLastSegment) { segmentSeen = true; continue; } if (!segmentSeen) { if (lastProgramDateTimeSeconds === null && nextSequenceNumber > 0 && targetDuration !== null) { // Offset the first segment's start timestamp by the following: accumulatedTime = nextSequenceNumber * targetDuration; } segmentSeen = true; } const extinfContent = line.slice(TAG_EXTINF.length); const commaIndex = extinfContent.indexOf(','); const durationStr = commaIndex === -1 ? extinfContent : extinfContent.slice(0, commaIndex); const duration = Number(durationStr); if (!Number.isFinite(duration) || duration < 0) { throw new Error(`Invalid #EXTINF tag duration '${durationStr}'.`); } nextSegmentDuration = duration; } else if (line.startsWith(TAG_MAP)) { const attributes = new AttributeList(line.slice(TAG_MAP.length)); const uri = attributes.get('uri'); if (!uri) { throw new Error('Invalid #EXT-X-MAP tag; missing URI attribute.'); } const byteRange = attributes.get('byterange'); let parsedByteRange = null; if (byteRange !== null) { parsedByteRange = parseByteRange(byteRange); } if (parsedByteRange && parsedByteRange.offset === null) { throw new Error('Invalid #EXT-X-MAP tag; BYTERANGE attribute must have a specified offset.'); } if (!prevLastSegment) { const fullPath = joinPaths(this.path, uri); const location = { path: fullPath, offset: parsedByteRange?.offset ?? 0, length: parsedByteRange?.length ?? null, }; if (currentKey?.method === 'AES-128' && !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.'); } const segment = { timestamp: accumulatedTime, relativeToUnixEpoch: lastProgramDateTimeSeconds !== null, firstSegment: null, sequenceNumber: null, location, duration: 0, encryption: currentKey, initSegment: null, lastProgramDateTimeSeconds, }; // Accumulated time and sequence number are not updated in this case currentInitSegment = segment; } else { // We're still seeing segments we already know about } nextSegmentDuration = null; if (nextByteRange === null) { lastByteRangeEnd = null; } else { nextByteRange = null; } } else if (line.startsWith(TAG_KEY)) { const attributes = new AttributeList(line.slice(TAG_KEY.length)); const method = attributes.get('method'); if (method === 'NONE') { currentKey = null; } else if (method === 'AES-128') { const uri = attributes.get('uri'); if (!uri) { throw new Error('Invalid #EXT-X-KEY: AES-128 requires a URI attribute.'); } let iv = null; const ivString = attributes.get('iv'); if (ivString) { if (!IV_STRING_REGEX.test(ivString)) { throw new Error(`Unsupported IV format '${ivString}'.`); } let hex = ivString.slice(2); hex = hex.padStart(AES_128_BLOCK_SIZE * 2, '0'); iv = new Uint8Array(AES_128_BLOCK_SIZE); for (let i = 0; i < AES_128_BLOCK_SIZE; i++) { const startIndex = -AES_128_BLOCK_SIZE * 2 + i; iv[i] = parseInt(hex.slice(startIndex, startIndex + 2), 16); } } const keyFormat = attributes.get('keyformat') ?? 'identity'; if (keyFormat !== 'identity') { throw new Error('For AES-128 encryption, only the \'identity\' KEYFORMAT is currently supported. If you' + ' think other formats should be supported, please raise an issue.'); } currentKey = { method: 'AES-128', keyUri: joinPaths(this.path, uri), iv, keyFormat, }; } else if (method === 'SAMPLE-AES' || method === 'SAMPLE-AES-CTR') { const uri = attributes.get('uri'); if (!uri) { throw new Error(`Invalid #EXT-X-KEY: ${method} requires a URI attribute.`); } const keyFormat = attributes.get('keyformat') ?? 'identity'; if (keyFormat === 'identity') { throw new Error('For SAMPLE-AES and SAMPLE-AES-CTR encryption, the \'identity\' KEYFORMAT is not' + ' supported. If you think this format should be supported, please raise an issue.'); } let psshBox = null; if (BASE64_DATA_URI_REGEX.test(uri)) { const commaIndex = uri.indexOf(','); const bytes = base64ToBytes(uri.slice(commaIndex + 1)); if (bytes.length >= 8 && bytes[4] === 0x70 && bytes[5] === 0x73 && bytes[6] === 0x73 && bytes[7] === 0x68) { const size = toDataView(bytes).getUint32(0); psshBox = parsePsshBoxContents(bytes.subarray(8, Math.min(size, bytes.length))); } } currentKey = { method, psshBox, }; } else { throw new Error(`Unsupported encryption method '${method}'. If you think this method should be supported,` + ` please raise an issue.`); } } else if (line.startsWith(TAG_MEDIA_SEQUENCE)) { const value = line.slice(TAG_MEDIA_SEQUENCE.length); const number = Number(value); if (!Number.isInteger(number) || number < 0) { throw new Error(`Invalid EXT-X-MEDIA-SEQUENCE value '${value}'.`); } setNextSequenceNumber(number); } else if (line.startsWith(TAG_BYTERANGE)) { const parsed = parseByteRange(line.slice(TAG_BYTERANGE.length)); if (parsed.offset === null) { if (lastByteRangeEnd === null) { throw new Error('Invalid M3U8 file; #EXT-X-BYTERANGE without offset requires a previous byte range.'); } parsed.offset = lastByteRangeEnd; } nextByteRange = parsed; lastByteRangeEnd = parsed.offset + parsed.length; } else if (line.startsWith(TAG_PROGRAM_DATE_TIME)) { if (prevLastSegment) { // No need to spend effort parsing dates if we're gonna discard it anyway. Also would be wrong to do // the segment shifting! continue; } const dateTime = line.slice(TAG_PROGRAM_DATE_TIME.length); const dateTimeMs = Date.parse(dateTime); if (!Number.isFinite(dateTimeMs)) { continue; } const dateTimeSeconds = dateTimeMs / 1000; if (lastProgramDateTimeSeconds === dateTimeSeconds) { continue; } if (lastProgramDateTimeSeconds === null && this.segments.length > 0) { // "If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after // one or more Media Segment URIs, the client SHOULD extrapolate // backward from that tag (using EXTINF durations and/or media // timestamps) to associate dates with those segments." const lastSegment = last(this.segments); const lastSegmentEnd = lastSegment.timestamp + lastSegment.duration; const offset = dateTimeSeconds - lastSegmentEnd; for (const segment of this.segments) { segment.timestamp += offset; segment.relativeToUnixEpoch = true; } accumulatedTime += offset; } lastProgramDateTimeSeconds = dateTimeSeconds; accumulatedTime = dateTimeSeconds; // Snap the accumulated time to the datetime } else if (line === TAG_DISCONTINUITY) { currentFirstSegment = null; // Note: the init segment is not reset; the #EXT-X-MAP statement simply lasts until the next // #EXT-X-MAP statement. } else if (line.startsWith(TAG_TARGETDURATION)) { const value = line.slice(TAG_TARGETDURATION.length); const duration = Number(value); if (!Number.isFinite(duration) || duration < 0) { throw new Error(`Invalid EXT-X-TARGETDURATION value '${value}'.`); } this.refreshInterval = duration; targetDuration = duration; } else if (line === TAG_ENDLIST) { this.streamHasEnded = true; break; // No need to keep reading after this } else if (line.startsWith(TAG_PLAYLIST_TYPE)) { const type = line.slice(TAG_PLAYLIST_TYPE.length); if (type.toLowerCase() === 'vod') { // A VOD playlist cannot be updated per spec so we can be sure the stream has ended this.streamHasEnded = true; } } } if (!headerRead) { throw new Error('Invalid M3U8 file; no #EXTM3U header.'); } } async getFirstSegment() { if (this.segments.length === 0) { await this.runUpdateSegments(); } return this.segments[0] ?? null; } async getSegmentAt(timestamp, options) { if (this.segments.length === 0) { await this.runUpdateSegments(); } // If we're skipping the live wait BUT there's no wait time, we're actually not lazy for the first iteration let isLazy = !!options.skipLiveWait && this.getRemainingWaitTimeMs() > 0; while (true) { const index = binarySearchLessOrEqual(this.segments, timestamp, x => x.timestamp); if (index === -1) { return null; } if (index < this.segments.length - 1 || this.streamHasEnded || isLazy) { return this.segments[index]; } const segment = this.segments[index]; if (timestamp < segment.timestamp + segment.duration) { return segment; } await this.runUpdateSegments(); if (options.skipLiveWait) { isLazy = true; // Definitely lazy in the next iteration } } } async getNextSegment(segment, options) { const index = this.segments.indexOf(segment); assert(index !== -1); const nextIndex = index + 1; // If we're skipping the live wait BUT there's no wait time, we're actually not lazy for the first iteration let isLazy = !!options.skipLiveWait && this.getRemainingWaitTimeMs() > 0; while (true) { if (nextIndex < this.segments.length) { return this.segments[nextIndex]; } if (this.streamHasEnded || isLazy) { return null; } await this.runUpdateSegments(); if (options.skipLiveWait) { isLazy = true; // Definitely lazy in the next iteration } } } async getPreviousSegment(segment) { const index = this.segments.indexOf(segment); assert(index !== -1); return this.segments[index - 1] ?? null; } getInputForSegment(segment) { const hlsSegment = segment; const cacheEntry = this.inputCache.find(x => x.segment === hlsSegment); if (cacheEntry) { cacheEntry.age = this.nextInputCacheAge++; return cacheEntry.input; } let initInput = null; if (hlsSegment.initSegment || hlsSegment.firstSegment) { initInput = this.getInputForSegment((hlsSegment.initSegment ?? hlsSegment.firstSegment)); } const formatOptions = { ...this.input._formatOptions, isobmff: { ...this.input._formatOptions.isobmff, // Intercept calls to resolveKeyId to inject our psshBox knowledge into it resolveKeyId: this.input._formatOptions.isobmff?.resolveKeyId && ((options) => { if (!hlsSegment.encryption || !(hlsSegment.encryption.method === 'SAMPLE-AES' || hlsSegment.encryption.method === 'SAMPLE-AES-CTR') || !hlsSegment.encryption.psshBox) { return this.input._formatOptions.isobmff.resolveKeyId(options); } let psshBoxes = options.psshBoxes; const { psshBox } = hlsSegment.encryption; if ((psshBox.keyIds === null || psshBox.keyIds.includes(options.keyId)) && !psshBoxes.some(x => psshBoxesAreEqual(x, psshBox))) { psshBoxes = [...psshBoxes, psshBox]; } return this.input._formatOptions.isobmff.resolveKeyId({ ...options, psshBoxes }); }), }, }; const input = new Input({ source: new CustomPathedSource(hlsSegment.location.path, async (request) => { assert(request.isRoot); // Shouldn't fail since we don't allow recursive HLS const proxiedRequest = { ...request, isRoot: false, }; let ref; const needsSlice = hlsSegment.location.offset > 0 || hlsSegment.location.length !== null; if (!hlsSegment.encryption || hlsSegment.encryption.method === 'SAMPLE-AES' || hlsSegment.encryption.method === 'SAMPLE-AES-CTR') { ref = await this.input._getSourceCached(proxiedRequest); if (needsSlice) { const slice = ref.source.slice(hlsSegment.location.offset, hlsSegment.location.length ?? undefined); const sliceRef = slice.ref(); ref.free(); ref = sliceRef; } } else if (hlsSegment.encryption.method === 'AES-128') { const encryption = hlsSegment.encryption; assert(encryption.iv); let ciphertextRef = await this.input._getSourceCached(proxiedRequest); if (needsSlice) { // Slice before decrypting const slice = ciphertextRef.source.slice(hlsSegment.location.offset, hlsSegment.location.length ?? undefined); const sliceRef = slice.ref(); ciphertextRef.free(); ciphertextRef = sliceRef; } const ciphertextReader = new Reader(ciphertextRef.source); const stream = createAes128CbcDecryptStream(ciphertextReader, async () => { const env_2 = { stack: [], error: void 0, hasError: false }; try { const keyRef = __addDisposableResource(env_2, await this.input._getSourceCached({ path: encryption.keyUri, isRoot: false }, ENCRYPTION_KEY_CACHE_GROUP), false); const keyReader = new Reader(keyRef.source); 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 { key, iv: encryption.iv }; } catch (e_2) { env_2.error = e_2; env_2.hasError = true; } finally { __disposeResources(env_2); } }, () => { ciphertextRef.free(); }); ref = new ReadableStreamSource(stream).ref(); } else { assert(false); } return ref; }), // Do not allow recursive HLS. Cool on paper, but allows for nasty infinite-depth request trees. formats: this.input._formats.filter(x => !(x instanceof HlsInputFormat)), initInput: initInput ?? undefined, formatOptions, }); input._onFormatDetermined = (format) => { if ((hlsSegment.encryption?.method === 'SAMPLE-AES' || hlsSegment.encryption?.method === 'SAMPLE-AES-CTR') && !format._isIsobmff) { // These methods can also be used for formats such as MPEG-TS // eslint-disable-next-line @stylistic/max-len // (see https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/HLS_Sample_Encryption/Encryption/Encryption.html) // but we don't support them there yet, so instead of silently decrypting nothing, we throw an error. throw new Error('The SAMPLE-AES and SAMPLE-AES-CTR encryption methods are currently only supported for' + ' ISOBMFF files.'); } }; this.inputCache.push({ segment: hlsSegment, input, age: this.nextInputCacheAge++, }); const MAX_INPUT_CACHE_SIZE = 4; if (this.inputCache.length > MAX_INPUT_CACHE_SIZE) { const minAgeIndex = arrayArgmin(this.inputCache, x => x.age); assert(minAgeIndex !== -1); this.inputCache.splice(minAgeIndex, 1); // DON'T dispose here; the Input might still be used! The source disposal will happen with GC logic } return input; } async getLiveRefreshInterval() { if (this.getRemainingWaitTimeMs() === 0) { await this.runUpdateSegments(); } return this.streamHasEnded ? null : this.refreshInterval; } }