From f99adf2208bff1d17fa2751f6df593fa19d2058f Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:31:43 +0200 Subject: [PATCH] Optimize media tag codec parsing, add a test for it, extract tag strings into constants --- src/hls/hls-demuxer.ts | 77 +++++++++++++++++++--------------- src/hls/hls-misc.ts | 15 +++++++ src/hls/hls-segmented-input.ts | 51 +++++++++++++--------- test/node/hls-input.test.ts | 12 ++++++ 4 files changed, 102 insertions(+), 53 deletions(-) diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index b947338..3dd5c62 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -20,7 +20,16 @@ import { TrackType } from '../output'; import { assert, joinPaths, MaybePromise, Rotation, UNDETERMINED_LANGUAGE } from '../misc'; import { EncodedPacket } from '../packet'; import { readAllLines } from '../reader'; -import { AttributeList, canIgnoreLine, HLS_MIME_TYPE } from './hls-misc'; +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'; import { HlsSegmentedInput } from './hls-segmented-input'; import { PathedSource } from '../source'; import { SegmentedInputTrackDeclaration } from '../segmented-input'; @@ -91,7 +100,7 @@ export class HlsDemuxer extends Demuxer { for (let i = 1; i < lines.length; i++) { const line = lines[i]!; - if (line.startsWith('#EXT-X-STREAM-INF:')) { + if (line.startsWith(TAG_STREAM_INF)) { const streamInfLineNumber = i; const playlistPath = lines[++i]; if (playlistPath === undefined) { @@ -99,7 +108,7 @@ export class HlsDemuxer extends Demuxer { } const fullPath = joinPaths(source.rootPath, playlistPath); - const attributes = new AttributeList(line.slice(18)); + const attributes = new AttributeList(line.slice(TAG_STREAM_INF.length)); const bandwidth = attributes.getAsNumber('bandwidth'); if (bandwidth === null) { @@ -115,8 +124,8 @@ export class HlsDemuxer extends Demuxer { lineNumber: streamInfLineNumber, hasOnlyKeyPackets: false, }); - } else if (line.startsWith('#EXT-X-I-FRAME-STREAM-INF:')) { - const attributes = new AttributeList(line.slice(26)); + } 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) { @@ -141,8 +150,8 @@ export class HlsDemuxer extends Demuxer { lineNumber: i, hasOnlyKeyPackets: true, }); - } else if (line.startsWith('#EXT-X-MEDIA:')) { - const attributes = new AttributeList(line.slice(13)); + } else if (line.startsWith(TAG_MEDIA)) { + const attributes = new AttributeList(line.slice(TAG_MEDIA.length)); const type = attributes.get('type'); if (type === null) { @@ -165,9 +174,9 @@ export class HlsDemuxer extends Demuxer { } mediaTags.push({ fullPath, attributes, lineNumber: i }); - } else if (line === '#EXT-X-I-FRAMES-ONLY') { + } else if (line === TAG_I_FRAMES_ONLY) { // iFramesOnlyTagFound = true; - } else if (line.startsWith('#EXTINF:')) { + } else if (line.startsWith(TAG_EXTINF)) { // This is a media playlist, not a master playlist const segmentedInput = new HlsSegmentedInput(this, source.rootPath, null, lines); @@ -234,16 +243,19 @@ export class HlsDemuxer extends Demuxer { ); } - const matchingVideoMediaTags = mediaTags.filter((mediaTag) => { + // 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'; }); - const additionalCodecStrings = await Promise.all(matchingVideoMediaTags.map(async (tag) => { - const uri = tag.attributes.get('uri'); + outer: + if (matchingVideoMediaTag) { + const uri = matchingVideoMediaTag.attributes.get('uri'); if (uri === null) { - return null; + break outer; } const fullPath = joinPaths(source.rootPath, uri); @@ -252,17 +264,14 @@ export class HlsDemuxer extends Demuxer { const videoTrack = trackBackings.find(x => x.getType() === 'video'); if (!videoTrack || (await videoTrack.getCodec()) === null) { - return null; + break outer; } - const codecParameterString = await videoTrack.getDecoderConfig().then(x => x?.codec ?? null); - assert(codecParameterString !== null); - return codecParameterString; - })); + const additionalCodecString = await videoTrack.getDecoderConfig().then(x => x?.codec ?? null); + assert(additionalCodecString !== null); - codecStrings.push( - ...additionalCodecStrings.filter((x): x is string => x !== null), - ); + codecStrings.push(additionalCodecString); + } } if (audioGroupId !== null && !containsAudioCodecs) { @@ -275,16 +284,19 @@ export class HlsDemuxer extends Demuxer { ); } - const matchingAudioMediaTags = mediaTags.filter((tag) => { + // 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'; }); - const additionalCodecStrings = await Promise.all(matchingAudioMediaTags.map(async (tag) => { - const uri = tag.attributes.get('uri'); + outer: + if (matchingAudioMediaTag) { + const uri = matchingAudioMediaTag.attributes.get('uri'); if (uri === null) { - return null; + break outer; } const fullPath = joinPaths(source.rootPath, uri); @@ -293,17 +305,14 @@ export class HlsDemuxer extends Demuxer { const audioTrack = trackBackings.find(x => x.getType() === 'audio'); if (!audioTrack || (await audioTrack.getCodec()) === null) { - return null; + break outer; } - const codecParameterString = await audioTrack.getDecoderConfig().then(x => x?.codec ?? null); - assert(codecParameterString !== null); - return codecParameterString; - })); + const additionalCodecString = await audioTrack.getDecoderConfig().then(x => x?.codec ?? null); + assert(additionalCodecString !== null); - codecStrings.push( - ...additionalCodecStrings.filter((x): x is string => x !== null), - ); + codecStrings.push(additionalCodecString); + } } // Unique that shit @@ -440,7 +449,7 @@ export class HlsDemuxer extends Demuxer { if (audioGroupId === null) { const channels = variantStream.attributes.get('channels'); const parsedChannels = channels !== null - ? Number(channels) + ? Number(channels.split('/')[0]!) : null; result.push({ diff --git a/src/hls/hls-misc.ts b/src/hls/hls-misc.ts index 3a68ebe..d34beeb 100644 --- a/src/hls/hls-misc.ts +++ b/src/hls/hls-misc.ts @@ -8,6 +8,21 @@ export const HLS_MIME_TYPE = 'application/vnd.apple.mpegurl'; +export const TAG_STREAM_INF = '#EXT-X-STREAM-INF:'; +export const TAG_I_FRAME_STREAM_INF = '#EXT-X-I-FRAME-STREAM-INF:'; +export const TAG_MEDIA = '#EXT-X-MEDIA:'; +export const TAG_EXTINF = '#EXTINF:'; +export const TAG_MAP = '#EXT-X-MAP:'; +export const TAG_KEY = '#EXT-X-KEY:'; +export const TAG_MEDIA_SEQUENCE = '#EXT-X-MEDIA-SEQUENCE:'; +export const TAG_BYTERANGE = '#EXT-X-BYTERANGE:'; +export const TAG_PROGRAM_DATE_TIME = '#EXT-X-PROGRAM-DATE-TIME:'; +export const TAG_DISCONTINUITY = '#EXT-X-DISCONTINUITY'; +export const TAG_TARGETDURATION = '#EXT-X-TARGETDURATION:'; +export const TAG_ENDLIST = '#EXT-X-ENDLIST'; +export const TAG_PLAYLIST_TYPE = '#EXT-X-PLAYLIST-TYPE:'; +export const TAG_I_FRAMES_ONLY = '#EXT-X-I-FRAMES-ONLY'; + export const canIgnoreLine = (line: string) => line.length === 0 || (line.startsWith('#') && !line.startsWith('#EXT')); export class AttributeList { diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index 17ae125..977acc8 100644 --- a/src/hls/hls-segmented-input.ts +++ b/src/hls/hls-segmented-input.ts @@ -13,7 +13,20 @@ import { toDataView, joinPaths, last, assert, binarySearchLessOrEqual, arrayArgm import { readAllLines, readBytes, Reader } from '../reader'; import { PathedSource, ReadableStreamSource, SourceRef } from '../source'; import { HlsDemuxer } from './hls-demuxer'; -import { AttributeList, canIgnoreLine } from './hls-misc'; +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'; const IV_STRING_REGEX = /^0[xX][0-9a-fA-F]+$/; @@ -229,7 +242,7 @@ export class HlsSegmentedInput extends SegmentedInput { setNextSequenceNumber(nextSequenceNumber + 1); } - if (line.startsWith('#EXTINF:')) { + if (line.startsWith(TAG_EXTINF)) { if (prevLastSegment) { segmentSeen = true; continue; @@ -244,7 +257,7 @@ export class HlsSegmentedInput extends SegmentedInput { segmentSeen = true; } - const extinfContent = line.slice(8); + const extinfContent = line.slice(TAG_EXTINF.length); const commaIndex = extinfContent.indexOf(','); const durationStr = commaIndex === -1 ? extinfContent : extinfContent.slice(0, commaIndex); const duration = Number(durationStr); @@ -253,8 +266,8 @@ export class HlsSegmentedInput extends SegmentedInput { } nextSegmentDuration = duration; - } else if (line.startsWith('#EXT-X-MAP:')) { - const attributes = new AttributeList(line.slice(11)); + } 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.'); @@ -308,8 +321,8 @@ export class HlsSegmentedInput extends SegmentedInput { } else { nextByteRange = null; } - } else if (line.startsWith('#EXT-X-KEY:')) { - const attributes = new AttributeList(line.slice(11)); + } else if (line.startsWith(TAG_KEY)) { + const attributes = new AttributeList(line.slice(TAG_KEY.length)); const method = attributes.get('method'); if (method === 'NONE') { @@ -349,8 +362,8 @@ export class HlsSegmentedInput extends SegmentedInput { + ` please raise an issue.`, ); } - } else if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - const value = line.slice(22); + } 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) { @@ -358,8 +371,8 @@ export class HlsSegmentedInput extends SegmentedInput { } setNextSequenceNumber(number); - } else if (line.startsWith('#EXT-X-BYTERANGE:')) { - const parsed = parseByteRange(line.slice(17)); + } else if (line.startsWith(TAG_BYTERANGE)) { + const parsed = parseByteRange(line.slice(TAG_BYTERANGE.length)); if (parsed.offset === null) { if (lastByteRangeEnd === null) { throw new Error( @@ -371,14 +384,14 @@ export class HlsSegmentedInput extends SegmentedInput { nextByteRange = parsed as { length: number; offset: number }; lastByteRangeEnd = parsed.offset + parsed.length; - } else if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + } 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(25); + const dateTime = line.slice(TAG_PROGRAM_DATE_TIME.length); const dateTimeMs = Date.parse(dateTime); if (!Number.isFinite(dateTimeMs)) { @@ -409,11 +422,11 @@ export class HlsSegmentedInput extends SegmentedInput { lastProgramDateTimeSeconds = dateTimeSeconds; accumulatedTime = dateTimeSeconds; // Snap the accumulated time to the datetime - } else if (line === '#EXT-X-DISCONTINUITY') { + } else if (line === TAG_DISCONTINUITY) { currentFirstSegment = null; currentInitSegment = null; - } else if (line.startsWith('#EXT-X-TARGETDURATION:')) { - const value = line.slice(22); + } else if (line.startsWith(TAG_TARGETDURATION)) { + const value = line.slice(TAG_TARGETDURATION.length); const duration = Number(value); if (!Number.isFinite(duration) || duration < 0) { @@ -422,11 +435,11 @@ export class HlsSegmentedInput extends SegmentedInput { this.refreshInterval = duration; targetDuration = duration; - } else if (line === '#EXT-X-ENDLIST') { + } else if (line === TAG_ENDLIST) { this.streamHasEnded = true; break; // No need to keep reading after this - } else if (line.startsWith('#EXT-X-PLAYLIST-TYPE')) { - const type = line.slice(21); + } 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; diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts index 2bb9965..8f8ce30 100644 --- a/test/node/hls-input.test.ts +++ b/test/node/hls-input.test.ts @@ -763,3 +763,15 @@ test.concurrent('#EXT-X-STREAM-INF tags without BANDWIDTH attribute are rejected await expect(input.getTracks()).rejects.toThrow('BANDWIDTH'); }); + +test.concurrent('Missing media tag codec', async () => { + using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/index.m3u8', ALL_FORMATS); + + const tracks = await input.getTracks(); + expect(tracks).toHaveLength(7); + + expect(tracks.filter(x => x.type === 'video')).toHaveLength(4); + expect(tracks.filter(x => x.type === 'audio')).toHaveLength(3); + + expect([...new Set(await Promise.all(tracks.map(x => x.getCodec())))]).toEqual(['aac', 'avc']); +});