Optimize media tag codec parsing, add a test for it, extract tag strings into constants

This commit is contained in:
Vanilagy
2026-04-16 11:31:43 +02:00
parent 2d0aac3208
commit f99adf2208
4 changed files with 102 additions and 53 deletions
+43 -34
View File
@@ -20,7 +20,16 @@ import { TrackType } from '../output';
import { assert, joinPaths, MaybePromise, Rotation, UNDETERMINED_LANGUAGE } from '../misc'; import { assert, joinPaths, MaybePromise, Rotation, UNDETERMINED_LANGUAGE } from '../misc';
import { EncodedPacket } from '../packet'; import { EncodedPacket } from '../packet';
import { readAllLines } from '../reader'; 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 { HlsSegmentedInput } from './hls-segmented-input';
import { PathedSource } from '../source'; import { PathedSource } from '../source';
import { SegmentedInputTrackDeclaration } from '../segmented-input'; import { SegmentedInputTrackDeclaration } from '../segmented-input';
@@ -91,7 +100,7 @@ export class HlsDemuxer extends Demuxer {
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
const line = lines[i]!; const line = lines[i]!;
if (line.startsWith('#EXT-X-STREAM-INF:')) { if (line.startsWith(TAG_STREAM_INF)) {
const streamInfLineNumber = i; const streamInfLineNumber = i;
const playlistPath = lines[++i]; const playlistPath = lines[++i];
if (playlistPath === undefined) { if (playlistPath === undefined) {
@@ -99,7 +108,7 @@ export class HlsDemuxer extends Demuxer {
} }
const fullPath = joinPaths(source.rootPath, playlistPath); 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'); const bandwidth = attributes.getAsNumber('bandwidth');
if (bandwidth === null) { if (bandwidth === null) {
@@ -115,8 +124,8 @@ export class HlsDemuxer extends Demuxer {
lineNumber: streamInfLineNumber, lineNumber: streamInfLineNumber,
hasOnlyKeyPackets: false, hasOnlyKeyPackets: false,
}); });
} else if (line.startsWith('#EXT-X-I-FRAME-STREAM-INF:')) { } else if (line.startsWith(TAG_I_FRAME_STREAM_INF)) {
const attributes = new AttributeList(line.slice(26)); const attributes = new AttributeList(line.slice(TAG_I_FRAME_STREAM_INF.length));
const playlistPath = attributes.get('uri'); const playlistPath = attributes.get('uri');
if (playlistPath === null) { if (playlistPath === null) {
@@ -141,8 +150,8 @@ export class HlsDemuxer extends Demuxer {
lineNumber: i, lineNumber: i,
hasOnlyKeyPackets: true, hasOnlyKeyPackets: true,
}); });
} else if (line.startsWith('#EXT-X-MEDIA:')) { } else if (line.startsWith(TAG_MEDIA)) {
const attributes = new AttributeList(line.slice(13)); const attributes = new AttributeList(line.slice(TAG_MEDIA.length));
const type = attributes.get('type'); const type = attributes.get('type');
if (type === null) { if (type === null) {
@@ -165,9 +174,9 @@ export class HlsDemuxer extends Demuxer {
} }
mediaTags.push({ fullPath, attributes, lineNumber: i }); mediaTags.push({ fullPath, attributes, lineNumber: i });
} else if (line === '#EXT-X-I-FRAMES-ONLY') { } else if (line === TAG_I_FRAMES_ONLY) {
// iFramesOnlyTagFound = true; // iFramesOnlyTagFound = true;
} else if (line.startsWith('#EXTINF:')) { } else if (line.startsWith(TAG_EXTINF)) {
// This is a media playlist, not a master playlist // This is a media playlist, not a master playlist
const segmentedInput = new HlsSegmentedInput(this, source.rootPath, null, lines); 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 groupId = mediaTag.attributes.get('group-id')!;
const type = mediaTag.attributes.get('type')!; const type = mediaTag.attributes.get('type')!;
return groupId === videoGroupId && type.toLowerCase() === 'video'; return groupId === videoGroupId && type.toLowerCase() === 'video';
}); });
const additionalCodecStrings = await Promise.all(matchingVideoMediaTags.map(async (tag) => { outer:
const uri = tag.attributes.get('uri'); if (matchingVideoMediaTag) {
const uri = matchingVideoMediaTag.attributes.get('uri');
if (uri === null) { if (uri === null) {
return null; break outer;
} }
const fullPath = joinPaths(source.rootPath, uri); const fullPath = joinPaths(source.rootPath, uri);
@@ -252,17 +264,14 @@ export class HlsDemuxer extends Demuxer {
const videoTrack = trackBackings.find(x => x.getType() === 'video'); const videoTrack = trackBackings.find(x => x.getType() === 'video');
if (!videoTrack || (await videoTrack.getCodec()) === null) { if (!videoTrack || (await videoTrack.getCodec()) === null) {
return null; break outer;
} }
const codecParameterString = await videoTrack.getDecoderConfig().then(x => x?.codec ?? null); const additionalCodecString = await videoTrack.getDecoderConfig().then(x => x?.codec ?? null);
assert(codecParameterString !== null); assert(additionalCodecString !== null);
return codecParameterString;
}));
codecStrings.push( codecStrings.push(additionalCodecString);
...additionalCodecStrings.filter((x): x is string => x !== null), }
);
} }
if (audioGroupId !== null && !containsAudioCodecs) { 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 groupId = tag.attributes.get('group-id')!;
const type = tag.attributes.get('type')!; const type = tag.attributes.get('type')!;
return groupId === audioGroupId && type.toLowerCase() === 'audio'; return groupId === audioGroupId && type.toLowerCase() === 'audio';
}); });
const additionalCodecStrings = await Promise.all(matchingAudioMediaTags.map(async (tag) => { outer:
const uri = tag.attributes.get('uri'); if (matchingAudioMediaTag) {
const uri = matchingAudioMediaTag.attributes.get('uri');
if (uri === null) { if (uri === null) {
return null; break outer;
} }
const fullPath = joinPaths(source.rootPath, uri); const fullPath = joinPaths(source.rootPath, uri);
@@ -293,17 +305,14 @@ export class HlsDemuxer extends Demuxer {
const audioTrack = trackBackings.find(x => x.getType() === 'audio'); const audioTrack = trackBackings.find(x => x.getType() === 'audio');
if (!audioTrack || (await audioTrack.getCodec()) === null) { if (!audioTrack || (await audioTrack.getCodec()) === null) {
return null; break outer;
} }
const codecParameterString = await audioTrack.getDecoderConfig().then(x => x?.codec ?? null); const additionalCodecString = await audioTrack.getDecoderConfig().then(x => x?.codec ?? null);
assert(codecParameterString !== null); assert(additionalCodecString !== null);
return codecParameterString;
}));
codecStrings.push( codecStrings.push(additionalCodecString);
...additionalCodecStrings.filter((x): x is string => x !== null), }
);
} }
// Unique that shit // Unique that shit
@@ -440,7 +449,7 @@ export class HlsDemuxer extends Demuxer {
if (audioGroupId === null) { if (audioGroupId === null) {
const channels = variantStream.attributes.get('channels'); const channels = variantStream.attributes.get('channels');
const parsedChannels = channels !== null const parsedChannels = channels !== null
? Number(channels) ? Number(channels.split('/')[0]!)
: null; : null;
result.push({ result.push({
+15
View File
@@ -8,6 +8,21 @@
export const HLS_MIME_TYPE = 'application/vnd.apple.mpegurl'; 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 const canIgnoreLine = (line: string) => line.length === 0 || (line.startsWith('#') && !line.startsWith('#EXT'));
export class AttributeList { export class AttributeList {
+32 -19
View File
@@ -13,7 +13,20 @@ import { toDataView, joinPaths, last, assert, binarySearchLessOrEqual, arrayArgm
import { readAllLines, readBytes, Reader } from '../reader'; import { readAllLines, readBytes, Reader } from '../reader';
import { PathedSource, ReadableStreamSource, SourceRef } from '../source'; import { PathedSource, ReadableStreamSource, SourceRef } from '../source';
import { HlsDemuxer } from './hls-demuxer'; 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]+$/; const IV_STRING_REGEX = /^0[xX][0-9a-fA-F]+$/;
@@ -229,7 +242,7 @@ export class HlsSegmentedInput extends SegmentedInput {
setNextSequenceNumber(nextSequenceNumber + 1); setNextSequenceNumber(nextSequenceNumber + 1);
} }
if (line.startsWith('#EXTINF:')) { if (line.startsWith(TAG_EXTINF)) {
if (prevLastSegment) { if (prevLastSegment) {
segmentSeen = true; segmentSeen = true;
continue; continue;
@@ -244,7 +257,7 @@ export class HlsSegmentedInput extends SegmentedInput {
segmentSeen = true; segmentSeen = true;
} }
const extinfContent = line.slice(8); const extinfContent = line.slice(TAG_EXTINF.length);
const commaIndex = extinfContent.indexOf(','); const commaIndex = extinfContent.indexOf(',');
const durationStr = commaIndex === -1 ? extinfContent : extinfContent.slice(0, commaIndex); const durationStr = commaIndex === -1 ? extinfContent : extinfContent.slice(0, commaIndex);
const duration = Number(durationStr); const duration = Number(durationStr);
@@ -253,8 +266,8 @@ export class HlsSegmentedInput extends SegmentedInput {
} }
nextSegmentDuration = duration; nextSegmentDuration = duration;
} else if (line.startsWith('#EXT-X-MAP:')) { } else if (line.startsWith(TAG_MAP)) {
const attributes = new AttributeList(line.slice(11)); const attributes = new AttributeList(line.slice(TAG_MAP.length));
const uri = attributes.get('uri'); const uri = attributes.get('uri');
if (!uri) { if (!uri) {
throw new Error('Invalid #EXT-X-MAP tag; missing URI attribute.'); throw new Error('Invalid #EXT-X-MAP tag; missing URI attribute.');
@@ -308,8 +321,8 @@ export class HlsSegmentedInput extends SegmentedInput {
} else { } else {
nextByteRange = null; nextByteRange = null;
} }
} else if (line.startsWith('#EXT-X-KEY:')) { } else if (line.startsWith(TAG_KEY)) {
const attributes = new AttributeList(line.slice(11)); const attributes = new AttributeList(line.slice(TAG_KEY.length));
const method = attributes.get('method'); const method = attributes.get('method');
if (method === 'NONE') { if (method === 'NONE') {
@@ -349,8 +362,8 @@ export class HlsSegmentedInput extends SegmentedInput {
+ ` please raise an issue.`, + ` please raise an issue.`,
); );
} }
} else if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) { } else if (line.startsWith(TAG_MEDIA_SEQUENCE)) {
const value = line.slice(22); const value = line.slice(TAG_MEDIA_SEQUENCE.length);
const number = Number(value); const number = Number(value);
if (!Number.isInteger(number) || number < 0) { if (!Number.isInteger(number) || number < 0) {
@@ -358,8 +371,8 @@ export class HlsSegmentedInput extends SegmentedInput {
} }
setNextSequenceNumber(number); setNextSequenceNumber(number);
} else if (line.startsWith('#EXT-X-BYTERANGE:')) { } else if (line.startsWith(TAG_BYTERANGE)) {
const parsed = parseByteRange(line.slice(17)); const parsed = parseByteRange(line.slice(TAG_BYTERANGE.length));
if (parsed.offset === null) { if (parsed.offset === null) {
if (lastByteRangeEnd === null) { if (lastByteRangeEnd === null) {
throw new Error( throw new Error(
@@ -371,14 +384,14 @@ export class HlsSegmentedInput extends SegmentedInput {
nextByteRange = parsed as { length: number; offset: number }; nextByteRange = parsed as { length: number; offset: number };
lastByteRangeEnd = parsed.offset + parsed.length; lastByteRangeEnd = parsed.offset + parsed.length;
} else if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { } else if (line.startsWith(TAG_PROGRAM_DATE_TIME)) {
if (prevLastSegment) { if (prevLastSegment) {
// No need to spend effort parsing dates if we're gonna discard it anyway. Also would be wrong to do // No need to spend effort parsing dates if we're gonna discard it anyway. Also would be wrong to do
// the segment shifting! // the segment shifting!
continue; continue;
} }
const dateTime = line.slice(25); const dateTime = line.slice(TAG_PROGRAM_DATE_TIME.length);
const dateTimeMs = Date.parse(dateTime); const dateTimeMs = Date.parse(dateTime);
if (!Number.isFinite(dateTimeMs)) { if (!Number.isFinite(dateTimeMs)) {
@@ -409,11 +422,11 @@ export class HlsSegmentedInput extends SegmentedInput {
lastProgramDateTimeSeconds = dateTimeSeconds; lastProgramDateTimeSeconds = dateTimeSeconds;
accumulatedTime = dateTimeSeconds; // Snap the accumulated time to the datetime accumulatedTime = dateTimeSeconds; // Snap the accumulated time to the datetime
} else if (line === '#EXT-X-DISCONTINUITY') { } else if (line === TAG_DISCONTINUITY) {
currentFirstSegment = null; currentFirstSegment = null;
currentInitSegment = null; currentInitSegment = null;
} else if (line.startsWith('#EXT-X-TARGETDURATION:')) { } else if (line.startsWith(TAG_TARGETDURATION)) {
const value = line.slice(22); const value = line.slice(TAG_TARGETDURATION.length);
const duration = Number(value); const duration = Number(value);
if (!Number.isFinite(duration) || duration < 0) { if (!Number.isFinite(duration) || duration < 0) {
@@ -422,11 +435,11 @@ export class HlsSegmentedInput extends SegmentedInput {
this.refreshInterval = duration; this.refreshInterval = duration;
targetDuration = duration; targetDuration = duration;
} else if (line === '#EXT-X-ENDLIST') { } else if (line === TAG_ENDLIST) {
this.streamHasEnded = true; this.streamHasEnded = true;
break; // No need to keep reading after this break; // No need to keep reading after this
} else if (line.startsWith('#EXT-X-PLAYLIST-TYPE')) { } else if (line.startsWith(TAG_PLAYLIST_TYPE)) {
const type = line.slice(21); const type = line.slice(TAG_PLAYLIST_TYPE.length);
if (type.toLowerCase() === 'vod') { if (type.toLowerCase() === 'vod') {
// A VOD playlist cannot be updated per spec so we can be sure the stream has ended // A VOD playlist cannot be updated per spec so we can be sure the stream has ended
this.streamHasEnded = true; this.streamHasEnded = true;
+12
View File
@@ -763,3 +763,15 @@ test.concurrent('#EXT-X-STREAM-INF tags without BANDWIDTH attribute are rejected
await expect(input.getTracks()).rejects.toThrow('BANDWIDTH'); 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']);
});