UNPKG

mediabunny

Version:

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

727 lines (726 loc) 34.9 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 { AUDIO_CODECS, inferCodecFromCodecString, VIDEO_CODECS } from '../codec.js'; import { Demuxer } from '../demuxer.js'; import { DEFAULT_TRACK_DISPOSITION } from '../metadata.js'; import { assert, joinPaths, UNDETERMINED_LANGUAGE } from '../misc.js'; import { readAllLines } from '../reader.js'; import { AttributeList, canIgnoreLine, HLS_MIME_TYPE, TAG_EXTINF, TAG_I_FRAME_STREAM_INF, TAG_I_FRAMES_ONLY, TAG_MEDIA, TAG_STREAM_INF, } from './hls-misc.js'; import { HlsSegmentedInput } from './hls-segmented-input.js'; import { PathedSource } from '../source.js'; export class HlsDemuxer extends Demuxer { constructor(input) { super(input); this.metadataPromise = null; this.trackBackings = null; this.internalTracks = null; this.segmentedInputs = []; this.hasMasterPlaylist = true; } readMetadata() { return this.metadataPromise ??= (async () => { assert(this.input._rootSource instanceof PathedSource); const { rootPath } = this.input._rootSource; const slice = await this.input._reader.requestEntireFile(); assert(slice); const lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine }); const variantStreams = []; const mediaTags = []; // Let's first iterate through the entire file, collecting all variant streams and media tags for (let i = 1; i < lines.length; i++) { const line = lines[i]; if (line.startsWith(TAG_STREAM_INF)) { const streamInfLineNumber = i; const playlistPath = lines[++i]; if (playlistPath === undefined) { throw new Error('Incorrect M3U8 file; a line must follow the #EXT-X-STREAM-INF tag.'); } const fullPath = joinPaths(rootPath, playlistPath); const attributes = new AttributeList(line.slice(TAG_STREAM_INF.length)); const bandwidth = attributes.getAsNumber('bandwidth'); if (bandwidth === null) { throw new Error('Invalid M3U8 file; #EXT-X-STREAM-INF tag requires a BANDWIDTH attribute with a valid' + ' numerical value.'); } variantStreams.push({ fullPath, attributes, lineNumber: streamInfLineNumber, hasOnlyKeyPackets: false, }); } else if (line.startsWith(TAG_I_FRAME_STREAM_INF)) { const attributes = new AttributeList(line.slice(TAG_I_FRAME_STREAM_INF.length)); const playlistPath = attributes.get('uri'); if (playlistPath === null) { throw new Error('Invalid M3U8 file; #EXT-X-I-FRAME-STREAM-INF tag requires a URI attribute.'); } const bandwidth = attributes.getAsNumber('bandwidth'); if (bandwidth === null) { throw new Error('Invalid M3U8 file; #EXT-X-I-FRAME-STREAM-INF tag requires a BANDWIDTH attribute with a' + ' valid numerical value.'); } const fullPath = joinPaths(rootPath, playlistPath); variantStreams.push({ fullPath, attributes, lineNumber: i, hasOnlyKeyPackets: true, }); } else if (line.startsWith(TAG_MEDIA)) { const attributes = new AttributeList(line.slice(TAG_MEDIA.length)); const type = attributes.get('type'); if (type === null) { throw new Error('Invalid M3U8 file; #EXT-X-MEDIA tag requires a TYPE attribute.'); } const groupId = attributes.get('group-id'); if (groupId === null) { throw new Error('Invalid M3U8 file; #EXT-X-MEDIA tag requires a GROUP-ID attribute.'); } let fullPath = null; const uri = attributes.get('uri'); if (uri !== null) { fullPath = joinPaths(rootPath, uri); } mediaTags.push({ fullPath, attributes, lineNumber: i }); } else if (line === TAG_I_FRAMES_ONLY) { // iFramesOnlyTagFound = true; } else if (line.startsWith(TAG_EXTINF)) { // This is a media playlist, not a master playlist const segmentedInput = new HlsSegmentedInput(this, rootPath, null, lines); this.segmentedInputs = [segmentedInput]; this.hasMasterPlaylist = false; this.trackBackings = await segmentedInput.getTrackBackings(); return; } } const videoGroupIds = [...new Set(mediaTags .filter(tag => tag.attributes.get('type').toLowerCase() === 'video') .map(tag => tag.attributes.get('group-id'))), ]; const audioGroupIds = [...new Set(mediaTags .filter(tag => tag.attributes.get('type').toLowerCase() === 'audio') .map(tag => tag.attributes.get('group-id'))), ]; // Now, let's process & resolve all variant streams in parallel, mapping each of them to tracks. const internalTracksByVariant = await Promise.all(variantStreams.map(async (variantStream, i) => { const result = []; const codecsList = variantStream.attributes.get('codecs'); let codecStrings; if (codecsList) { codecStrings = codecsList.split(',').map(x => x.trim()); } else { // No codecs were specified, we need to read the underlying media data const segmentedInput = this.getSegmentedInputForPath(variantStream.fullPath); const trackBackings = await segmentedInput.getTrackBackings(); const tracksWithCodec = await Promise.all(trackBackings.map(async (t) => ({ track: t, codec: await t.getCodec() }))); codecStrings = await Promise.all(tracksWithCodec .filter(x => x.codec !== null) .map(x => x.track.getDecoderConfig().then(x => x.codec))); } const videoGroupId = variantStream.attributes.get('video'); const audioGroupId = variantStream.attributes.get('audio'); const containsVideoCodecs = codecStrings.some(x => VIDEO_CODECS.includes(inferCodecFromCodecString(x))); const containsAudioCodecs = codecStrings.some(x => AUDIO_CODECS.includes(inferCodecFromCodecString(x))); if (videoGroupId !== null && !containsVideoCodecs) { // A video group is linked but no video codec is listed, sigh. Let's resolve the video codec. 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.`); } // We only need to look at the first matching tag, since all tags are required to have the same // codec anyway const matchingVideoMediaTag = mediaTags.find((mediaTag) => { const groupId = mediaTag.attributes.get('group-id'); const type = mediaTag.attributes.get('type'); return groupId === videoGroupId && type.toLowerCase() === 'video'; }); outer: if (matchingVideoMediaTag) { const uri = matchingVideoMediaTag.attributes.get('uri'); if (uri === null) { break outer; } const fullPath = joinPaths(rootPath, uri); const segmentedInput = this.getSegmentedInputForPath(fullPath); const trackBackings = await segmentedInput.getTrackBackings(); const videoTrack = trackBackings.find(x => x.getType() === 'video'); if (!videoTrack || (await videoTrack.getCodec()) === null) { break outer; } const additionalCodecString = await videoTrack.getDecoderConfig().then(x => x?.codec ?? null); assert(additionalCodecString !== null); codecStrings.push(additionalCodecString); } } if (audioGroupId !== null && !containsAudioCodecs) { // An audio group is linked but no audio codec is listed, sigh. Let's resolve the audio codec. 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.`); } // We only need to look at the first matching tag, since all tags are required to have the same // codec anyway const matchingAudioMediaTag = mediaTags.find((tag) => { const groupId = tag.attributes.get('group-id'); const type = tag.attributes.get('type'); return groupId === audioGroupId && type.toLowerCase() === 'audio'; }); outer: if (matchingAudioMediaTag) { const uri = matchingAudioMediaTag.attributes.get('uri'); if (uri === null) { break outer; } const fullPath = joinPaths(rootPath, uri); const segmentedInput = this.getSegmentedInputForPath(fullPath); const trackBackings = await segmentedInput.getTrackBackings(); const audioTrack = trackBackings.find(x => x.getType() === 'audio'); if (!audioTrack || (await audioTrack.getCodec()) === null) { break outer; } const additionalCodecString = await audioTrack.getDecoderConfig().then(x => x?.codec ?? null); assert(additionalCodecString !== null); codecStrings.push(additionalCodecString); } } // Unique that shit codecStrings = [...new Set(codecStrings)]; let videoCodecString = null; let audioCodecString = null; const bandwidth = variantStream.attributes.getAsNumber('bandwidth'); assert(bandwidth !== null); const averageBandwidth = variantStream.attributes.getAsNumber('average-bandwidth'); const name = variantStream.attributes.get('name'); // Now, finally, loop over each codec string for the variant and resolve each one to one or more tracks. for (const codecString of codecStrings) { const inferredCodec = inferCodecFromCodecString(codecString); if (inferredCodec === null) { continue; } if (VIDEO_CODECS.includes(inferredCodec)) { if (videoCodecString !== null) { throw new Error('Unsupported M3U8 file; multiple video codecs found in the CODECS attribute of a' + ' variant stream.'); } videoCodecString = codecString; const videoGroupId = variantStream.attributes.get('video'); if (videoGroupId === null) { const resolution = variantStream.attributes.get('resolution'); let width = null; let height = null; if (resolution) { const match = resolution.match(/^(\d+)x(\d+)$/); if (match) { width = Number(match[1]); height = Number(match[2]); } } result.push({ id: -1, demuxer: this, backingTrack: null, default: true, autoselect: true, languageCode: UNDETERMINED_LANGUAGE, lineNumber: variantStream.lineNumber, fullPath: variantStream.fullPath, fullCodecString: videoCodecString, pairingMask: 1n << BigInt(i), peakBitrate: bandwidth, averageBitrate: averageBandwidth, name, hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets, info: { type: 'video', width, height, }, }); } 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 = null; let height = null; if (resolution) { const match = resolution.match(/^(\d+)x(\d+)$/); if (match) { width = Number(match[1]); height = Number(match[2]); } } result.push({ id: -1, demuxer: this, backingTrack: null, default: getMediaTagDefault(mediaTag.attributes), // Autoselect is inferred to be true if the default is true autoselect: getMediaTagDefault(mediaTag.attributes) || getMediaTagAutoselect(mediaTag.attributes), languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')), lineNumber: mediaTag.lineNumber, fullPath: mediaTag.fullPath ?? variantStream.fullPath, fullCodecString: videoCodecString, pairingMask: 1n << BigInt(i), peakBitrate: null, averageBitrate: null, name: mediaTag.attributes.get('name'), hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets, info: { type: 'video', width, height, }, }); } } } else if (AUDIO_CODECS.includes(inferredCodec)) { if (audioCodecString !== null) { throw new Error('Unsupported M3U8 file; multiple audio codecs found in the CODECS attribute of a' + ' variant stream.'); } audioCodecString = codecString; const audioGroupId = variantStream.attributes.get('audio'); if (audioGroupId === null) { const channels = variantStream.attributes.get('channels'); const parsedChannels = channels !== null ? Number(channels.split('/')[0]) : null; result.push({ id: -1, demuxer: this, backingTrack: null, default: true, autoselect: true, languageCode: UNDETERMINED_LANGUAGE, lineNumber: variantStream.lineNumber, fullPath: variantStream.fullPath, fullCodecString: audioCodecString, pairingMask: 1n << BigInt(i), peakBitrate: bandwidth, averageBitrate: averageBandwidth, name, hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets, info: { type: 'audio', numberOfChannels: parsedChannels !== null && Number.isInteger(parsedChannels) && parsedChannels > 0 ? parsedChannels : null, }, }); } 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.split('/')[0]) : null; result.push({ id: -1, demuxer: this, backingTrack: null, default: getMediaTagDefault(mediaTag.attributes), // Autoselect is inferred to be true if the default is true autoselect: getMediaTagDefault(mediaTag.attributes) || getMediaTagAutoselect(mediaTag.attributes), languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')), lineNumber: mediaTag.lineNumber, fullPath: mediaTag.fullPath ?? variantStream.fullPath, fullCodecString: audioCodecString, pairingMask: 1n << BigInt(i), peakBitrate: null, averageBitrate: null, name: mediaTag.attributes.get('name'), hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets, info: { type: 'audio', numberOfChannels: parsedChannels !== null && Number.isInteger(parsedChannels) && parsedChannels > 0 ? parsedChannels : null, }, }); } } } } return result; })); const internalTracks = []; const addInternalTrack = (track) => { const existingTrack = internalTracks.find(x => x.fullPath === track.fullPath && x.info.type === track.info.type); if (existingTrack) { existingTrack.pairingMask |= track.pairingMask; existingTrack.default ||= track.default; existingTrack.autoselect ||= track.autoselect; existingTrack.lineNumber = Math.min(existingTrack.lineNumber, track.lineNumber); if (track.peakBitrate !== null) { existingTrack.peakBitrate = Math.max(existingTrack.peakBitrate ?? -Infinity, track.peakBitrate); } if (track.averageBitrate !== null) { existingTrack.averageBitrate = Math.max(existingTrack.averageBitrate ?? -Infinity, track.averageBitrate); } if (existingTrack.languageCode === UNDETERMINED_LANGUAGE) { existingTrack.languageCode = track.languageCode; } } else { track.id = internalTracks.length + 1; internalTracks.push(track); } }; for (const variantInternalTracks of internalTracksByVariant) { for (const trackEntry of variantInternalTracks) { addInternalTrack(trackEntry); } } // Order tracks by how they appear in the file internalTracks.sort((a, b) => a.lineNumber - b.lineNumber); this.trackBackings = []; for (const internalTrack of internalTracks) { if (internalTrack.info.type === 'video') { this.trackBackings.push(new HlsInputVideoTrackBacking(internalTrack)); } else { this.trackBackings.push(new HlsInputAudioTrackBacking(internalTrack)); } } this.internalTracks = internalTracks; })(); } async getTrackBackings() { await this.readMetadata(); assert(this.trackBackings); return this.trackBackings; } getSegmentedInputForPath(path) { let segmentedInput = this.segmentedInputs.find(x => x.path === path); if (segmentedInput) { return segmentedInput; } let decls = null; if (this.internalTracks) { const tracks = this.internalTracks.filter(x => x.fullPath === path); decls = tracks.map(x => ({ id: x.id, type: x.info.type, })); } segmentedInput = new HlsSegmentedInput(this, path, decls, null); this.segmentedInputs.push(segmentedInput); return segmentedInput; } async getMetadataTags() { return {}; } async getMimeType() { return HLS_MIME_TYPE; } dispose() { if (this.segmentedInputs) { for (const segInput of this.segmentedInputs) { segInput.dispose(); } this.segmentedInputs.length = 0; } } } class HlsInputTrackBacking { constructor(internalTrack) { this.internalTrack = internalTrack; this.hydrationPromise = null; } hydrate() { return this.hydrationPromise ??= (async () => { const segmentedInput = this.internalTrack.demuxer.getSegmentedInputForPath(this.internalTrack.fullPath); let trackBacking = null; const trackBackings = await segmentedInput.getTrackBackings(); const matchingType = trackBackings.filter(x => x.getType() === this.getType()); if (matchingType.length === 1) { // Avoids reading fields on the track trackBacking = matchingType[0]; } else { if (this instanceof HlsInputVideoTrackBacking) { for (const backing of matchingType) { if ((await backing.getCodec()) === this.getCodec()) { trackBacking = backing; break; } } } else { assert(this instanceof HlsInputAudioTrackBacking); for (const backing of matchingType) { if ((await backing.getCodec()) === this.getCodec()) { trackBacking = backing; break; } } } } if (!trackBacking) { throw new Error('Could not find matching track in underlying media data.'); } this.internalTrack.backingTrack = trackBacking; })(); } /** If the backing track is already present, delegate synchronously; otherwise, hydrate first. */ delegate(fn) { if (this.internalTrack.backingTrack) { return fn(); } return this.hydrate().then(fn); } getCodec() { throw new Error('Not implemented on base class.'); } getDisposition() { return { ...DEFAULT_TRACK_DISPOSITION, // Meanings are swapped in HLS: "Default" means that a track is the primary track. default: this.internalTrack.autoselect, primary: this.internalTrack.default, }; } getId() { return this.internalTrack.id; } getPairingMask() { return this.internalTrack.pairingMask; } getInternalCodecId() { return null; } getLanguageCode() { return this.internalTrack.languageCode; } getName() { return this.internalTrack.name; } getNumber() { assert(this.internalTrack.demuxer.internalTracks); const trackType = this.internalTrack.info.type; let number = 0; for (const track of this.internalTrack.demuxer.internalTracks) { if (track.info.type === trackType) { number++; } if (track === this.internalTrack) { break; } } return number; } getTimeResolution() { return this.delegate(() => this.internalTrack.backingTrack.getTimeResolution()); } isRelativeToUnixEpoch() { return this.delegate(() => this.internalTrack.backingTrack.isRelativeToUnixEpoch()); } getBitrate() { return this.internalTrack.peakBitrate; } getAverageBitrate() { return this.internalTrack.averageBitrate; } async getDurationFromMetadata(options) { await this.hydrate(); return this.internalTrack.backingTrack.getDurationFromMetadata(options); } async getLiveRefreshInterval() { await this.hydrate(); return this.internalTrack.backingTrack.getLiveRefreshInterval(); } getHasOnlyKeyPackets() { return this.internalTrack.hasOnlyKeyPackets || null; } async getFirstPacket(options) { await this.hydrate(); return this.internalTrack.backingTrack.getFirstPacket(options); } async getPacket(timestamp, options) { await this.hydrate(); return this.internalTrack.backingTrack.getPacket(timestamp, options); } async getKeyPacket(timestamp, options) { await this.hydrate(); return this.internalTrack.backingTrack.getKeyPacket(timestamp, options); } async getNextPacket(packet, options) { await this.hydrate(); return this.internalTrack.backingTrack.getNextPacket(packet, options); } async getNextKeyPacket(packet, options) { await this.hydrate(); return this.internalTrack.backingTrack.getNextKeyPacket(packet, options); } } class HlsInputVideoTrackBacking extends HlsInputTrackBacking { constructor(internalTrack) { super(internalTrack); } get backingVideoTrack() { return this.internalTrack.backingTrack; } getType() { return 'video'; } getCodec() { const inferredCodec = inferCodecFromCodecString(this.internalTrack.fullCodecString); return inferredCodec; } getCodedWidth() { return this.delegate(() => this.backingVideoTrack.getCodedWidth()); } getCodedHeight() { return this.delegate(() => this.backingVideoTrack.getCodedHeight()); } getSquarePixelWidth() { return this.delegate(() => this.backingVideoTrack.getSquarePixelWidth()); } getSquarePixelHeight() { return this.delegate(() => this.backingVideoTrack.getSquarePixelHeight()); } getMetadataDisplayWidth() { if (this.backingVideoTrack) { return null; } return this.internalTrack.info.width; } getMetadataDisplayHeight() { if (this.backingVideoTrack) { return null; } return this.internalTrack.info.height; } getRotation() { return this.delegate(() => this.backingVideoTrack.getRotation()); } async getColorSpace() { await this.hydrate(); return this.backingVideoTrack.getColorSpace(); } async canBeTransparent() { await this.hydrate(); return this.backingVideoTrack.canBeTransparent(); } getMetadataCodecParameterString() { if (this.backingVideoTrack) { return null; } return this.internalTrack.fullCodecString; } async getDecoderConfig() { await this.hydrate(); return this.backingVideoTrack.getDecoderConfig(); } } class HlsInputAudioTrackBacking extends HlsInputTrackBacking { constructor(internalTrack) { super(internalTrack); } get backingAudioTrack() { return this.internalTrack.backingTrack; } getType() { return 'audio'; } getCodec() { const inferredCodec = inferCodecFromCodecString(this.internalTrack.fullCodecString); return inferredCodec; } getNumberOfChannels() { if (this.internalTrack.info.numberOfChannels !== null) { return this.internalTrack.info.numberOfChannels; } return this.delegate(() => this.backingAudioTrack.getNumberOfChannels()); } getSampleRate() { return this.delegate(() => this.backingAudioTrack.getSampleRate()); } getMetadataCodecParameterString() { if (this.backingAudioTrack) { return null; } return this.internalTrack.fullCodecString; } async getDecoderConfig() { await this.hydrate(); return this.backingAudioTrack.getDecoderConfig(); } } const getMediaTagDefault = (attributes) => { const value = attributes.get('default'); if (value === null) { return false; } const normalized = value.toUpperCase(); if (normalized === 'YES') { return true; } if (normalized === 'NO') { return false; } throw new Error(`Invalid M3U8 file; #EXT-X-MEDIA DEFAULT attribute must be YES or NO, got "${value}".`); }; const getMediaTagAutoselect = (attributes) => { const value = attributes.get('autoselect'); if (value === null) { return false; } const normalized = value.toUpperCase(); if (normalized === 'YES') { return true; } if (normalized === 'NO') { return false; } throw new Error(`Invalid M3U8 file; #EXT-X-MEDIA AUTOSELECT attribute must be YES or NO, got "${value}".`); }; const preprocessLanguageCode = (code) => { if (code === null) { return UNDETERMINED_LANGUAGE; } const languageSubtag = code.split('-')[0]; if (!languageSubtag) { return UNDETERMINED_LANGUAGE; } // Technically invalid, for now: The language subtag might be a language code from ISO 639-1, // ISO 639-2, ISO 639-3, ISO 639-5 or some other thing (source: Wikipedia). But, `languageCode` is // documented as ISO 639-2. Changing the definition would be a breaking change. This will get // cleaned up in the future by defining languageCode to be BCP 47 instead. return languageSubtag; };