mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-28 19:33:51 +02:00
Read #EXT-X-PROGRAM-DATE-TIME, add Unix epoch-offset timestamps, parallelize HLS master playlist resolution
This commit is contained in:
+127
-81
@@ -80,6 +80,8 @@ export class HlsDemuxer extends Demuxer {
|
||||
lineNumber: number;
|
||||
}[] = [];
|
||||
|
||||
// Let's first iterate through the entire file, collecting all variant streams and media tags
|
||||
|
||||
while (true) {
|
||||
let line = this.lineReader.readNextLine();
|
||||
if (line instanceof Promise) line = await line;
|
||||
@@ -170,27 +172,11 @@ export class HlsDemuxer extends Demuxer {
|
||||
.filter(tag => tag.attributes.get('type')!.toLowerCase() === 'audio')
|
||||
.map(tag => tag.attributes.get('group-id')!)),
|
||||
];
|
||||
const internalTracks: InternalTrack[] = [];
|
||||
|
||||
const addInternalTrack = (track: InternalTrack, canMerge: boolean) => {
|
||||
const existingTrack = internalTracks.find(x =>
|
||||
x.fullPath === track.fullPath && x.info.type === track.info.type && x.groupId === track.groupId,
|
||||
);
|
||||
if (existingTrack && canMerge) {
|
||||
existingTrack.pairingMask |= track.pairingMask;
|
||||
existingTrack.default ||= track.default;
|
||||
existingTrack.lineNumber = Math.min(existingTrack.lineNumber, track.lineNumber);
|
||||
// Now, let's process & resolve all variant streams in parallel, mapping each of them to tracks.
|
||||
|
||||
if (existingTrack.languageCode === UNDETERMINED_LANGUAGE) {
|
||||
existingTrack.languageCode = track.languageCode;
|
||||
}
|
||||
} else {
|
||||
internalTracks.push(track);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < variantStreams.length; i++) {
|
||||
const variantStream = variantStreams[i]!;
|
||||
const internalTracksByVariant = await Promise.all(variantStreams.map(async (variantStream, i) => {
|
||||
const result: { track: InternalTrack; canMerge: boolean }[] = [];
|
||||
|
||||
const codecsList = variantStream.attributes.get('codecs');
|
||||
let codecStrings: string[];
|
||||
@@ -198,6 +184,7 @@ export class HlsDemuxer extends Demuxer {
|
||||
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 input = segmentedInput.toInput();
|
||||
const tracks = await input.getTracks();
|
||||
@@ -211,8 +198,16 @@ export class HlsDemuxer extends Demuxer {
|
||||
|
||||
const videoGroupId = variantStream.attributes.get('video');
|
||||
const audioGroupId = variantStream.attributes.get('audio');
|
||||
const containsVideoCodecs = codecStrings.some(x =>
|
||||
VIDEO_CODECS.includes(inferCodecFromCodecString(x) as VideoCodec),
|
||||
);
|
||||
const containsAudioCodecs = codecStrings.some(x =>
|
||||
AUDIO_CODECS.includes(inferCodecFromCodecString(x) as AudioCodec),
|
||||
);
|
||||
|
||||
if (videoGroupId !== null && !containsVideoCodecs) {
|
||||
// A video group is linked but no video codec is listed, sigh. Let's resolve the video codec.
|
||||
|
||||
if (videoGroupId !== null) {
|
||||
if (!videoGroupIds.includes(videoGroupId)) {
|
||||
throw new Error(
|
||||
`Invalid M3U8 file; variant stream references video group "${videoGroupId}" which`
|
||||
@@ -220,32 +215,40 @@ export class HlsDemuxer extends Demuxer {
|
||||
);
|
||||
}
|
||||
|
||||
for (const mediaTag of mediaTags) {
|
||||
const matchingVideoMediaTags = mediaTags.filter((mediaTag) => {
|
||||
const groupId = mediaTag.attributes.get('group-id')!;
|
||||
const type = mediaTag.attributes.get('type')!;
|
||||
return groupId === videoGroupId && type.toLowerCase() === 'video';
|
||||
});
|
||||
|
||||
if (groupId !== videoGroupId || type.toLowerCase() !== 'video') {
|
||||
continue;
|
||||
const additionalCodecStrings = await Promise.all(matchingVideoMediaTags.map(async (tag) => {
|
||||
const uri = tag.attributes.get('uri');
|
||||
if (uri === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uri = mediaTag.attributes.get('uri');
|
||||
if (uri !== null) {
|
||||
const fullPath = joinPaths(this.input._entryPath, uri);
|
||||
const segmentedInput = this.getSegmentedInputForPath(fullPath);
|
||||
const input = segmentedInput.toInput();
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const fullPath = joinPaths(this.input._entryPath!, uri);
|
||||
const segmentedInput = this.getSegmentedInputForPath(fullPath);
|
||||
const input = segmentedInput.toInput();
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
|
||||
if (videoTrack && videoTrack.codec !== null) {
|
||||
const codecParameterString = await videoTrack.getCodecParameterString();
|
||||
assert(codecParameterString !== null);
|
||||
|
||||
codecStrings.push(codecParameterString);
|
||||
}
|
||||
if (!videoTrack || videoTrack.codec === null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const codecParameterString = await videoTrack.getCodecParameterString();
|
||||
assert(codecParameterString !== null);
|
||||
return codecParameterString;
|
||||
}));
|
||||
|
||||
codecStrings.push(
|
||||
...additionalCodecStrings.filter((x): x is string => x !== null),
|
||||
);
|
||||
}
|
||||
|
||||
if (audioGroupId !== null) {
|
||||
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`
|
||||
@@ -253,29 +256,35 @@ export class HlsDemuxer extends Demuxer {
|
||||
);
|
||||
}
|
||||
|
||||
for (const mediaTag of mediaTags) {
|
||||
const groupId = mediaTag.attributes.get('group-id')!;
|
||||
const type = mediaTag.attributes.get('type')!;
|
||||
const matchingAudioMediaTags = mediaTags.filter((tag) => {
|
||||
const groupId = tag.attributes.get('group-id')!;
|
||||
const type = tag.attributes.get('type')!;
|
||||
return groupId === audioGroupId && type.toLowerCase() === 'audio';
|
||||
});
|
||||
|
||||
if (groupId !== audioGroupId || type.toLowerCase() !== 'audio') {
|
||||
continue;
|
||||
const additionalCodecStrings = await Promise.all(matchingAudioMediaTags.map(async (tag) => {
|
||||
const uri = tag.attributes.get('uri');
|
||||
if (uri === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uri = mediaTag.attributes.get('uri');
|
||||
if (uri !== null) {
|
||||
const fullPath = joinPaths(this.input._entryPath, uri);
|
||||
const segmentedInput = this.getSegmentedInputForPath(fullPath);
|
||||
const input = segmentedInput.toInput();
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
const fullPath = joinPaths(this.input._entryPath!, uri);
|
||||
const segmentedInput = this.getSegmentedInputForPath(fullPath);
|
||||
const input = segmentedInput.toInput();
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
|
||||
if (audioTrack && audioTrack.codec !== null) {
|
||||
const codecParameterString = await audioTrack.getCodecParameterString();
|
||||
assert(codecParameterString !== null);
|
||||
|
||||
codecStrings.push(codecParameterString);
|
||||
}
|
||||
if (!audioTrack || audioTrack.codec === null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const codecParameterString = await audioTrack.getCodecParameterString();
|
||||
assert(codecParameterString !== null);
|
||||
return codecParameterString;
|
||||
}));
|
||||
|
||||
codecStrings.push(
|
||||
...additionalCodecStrings.filter((x): x is string => x !== null),
|
||||
);
|
||||
}
|
||||
|
||||
// Unique that shit
|
||||
@@ -290,6 +299,7 @@ export class HlsDemuxer extends Demuxer {
|
||||
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) {
|
||||
@@ -321,8 +331,8 @@ export class HlsDemuxer extends Demuxer {
|
||||
}
|
||||
}
|
||||
|
||||
addInternalTrack({
|
||||
id: internalTracks.length + 1,
|
||||
result.push({ track: {
|
||||
id: -1,
|
||||
demuxer: this,
|
||||
inputTrack: null,
|
||||
backingTrack: null,
|
||||
@@ -341,12 +351,12 @@ export class HlsDemuxer extends Demuxer {
|
||||
width,
|
||||
height,
|
||||
},
|
||||
}, false);
|
||||
}, canMerge: 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.`,
|
||||
`Invalid M3U8 file; variant stream references video group "${videoGroupId}"`
|
||||
+ ` which is not defined in any #EXT-X-MEDIA tags.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -371,8 +381,8 @@ export class HlsDemuxer extends Demuxer {
|
||||
}
|
||||
}
|
||||
|
||||
addInternalTrack({
|
||||
id: internalTracks.length + 1,
|
||||
result.push({ track: {
|
||||
id: -1,
|
||||
demuxer: this,
|
||||
inputTrack: null,
|
||||
backingTrack: null,
|
||||
@@ -391,7 +401,7 @@ export class HlsDemuxer extends Demuxer {
|
||||
width,
|
||||
height,
|
||||
},
|
||||
}, true);
|
||||
}, canMerge: true });
|
||||
}
|
||||
}
|
||||
} else if (AUDIO_CODECS.includes(inferredCodec as AudioCodec)) {
|
||||
@@ -412,8 +422,8 @@ export class HlsDemuxer extends Demuxer {
|
||||
? Number(channels)
|
||||
: null;
|
||||
|
||||
addInternalTrack({
|
||||
id: internalTracks.length + 1,
|
||||
result.push({ track: {
|
||||
id: -1,
|
||||
demuxer: this,
|
||||
inputTrack: null,
|
||||
backingTrack: null,
|
||||
@@ -430,18 +440,18 @@ export class HlsDemuxer extends Demuxer {
|
||||
info: {
|
||||
type: 'audio',
|
||||
numberOfChannels:
|
||||
parsedChannels !== null
|
||||
&& Number.isInteger(parsedChannels)
|
||||
&& parsedChannels > 0
|
||||
? parsedChannels
|
||||
: null,
|
||||
parsedChannels !== null
|
||||
&& Number.isInteger(parsedChannels)
|
||||
&& parsedChannels > 0
|
||||
? parsedChannels
|
||||
: null,
|
||||
},
|
||||
}, false);
|
||||
}, canMerge: 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.`,
|
||||
`Invalid M3U8 file; variant stream references audio group "${audioGroupId}"`
|
||||
+ ` which is not defined in any #EXT-X-MEDIA tags.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -459,8 +469,8 @@ export class HlsDemuxer extends Demuxer {
|
||||
? Number(channels)
|
||||
: null;
|
||||
|
||||
addInternalTrack({
|
||||
id: internalTracks.length + 1,
|
||||
result.push({ track: {
|
||||
id: -1,
|
||||
demuxer: this,
|
||||
inputTrack: null,
|
||||
backingTrack: null,
|
||||
@@ -477,17 +487,45 @@ export class HlsDemuxer extends Demuxer {
|
||||
info: {
|
||||
type: 'audio',
|
||||
numberOfChannels:
|
||||
parsedChannels !== null
|
||||
&& Number.isInteger(parsedChannels)
|
||||
&& parsedChannels > 0
|
||||
? parsedChannels
|
||||
: null,
|
||||
parsedChannels !== null
|
||||
&& Number.isInteger(parsedChannels)
|
||||
&& parsedChannels > 0
|
||||
? parsedChannels
|
||||
: null,
|
||||
},
|
||||
}, true);
|
||||
}, canMerge: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}));
|
||||
|
||||
const internalTracks: InternalTrack[] = [];
|
||||
const addInternalTrack = (track: InternalTrack, canMerge: boolean) => {
|
||||
const existingTrack = internalTracks.find(x =>
|
||||
x.fullPath === track.fullPath && x.info.type === track.info.type && x.groupId === track.groupId,
|
||||
);
|
||||
|
||||
if (existingTrack && canMerge) {
|
||||
existingTrack.pairingMask |= track.pairingMask;
|
||||
existingTrack.default ||= track.default;
|
||||
existingTrack.lineNumber = Math.min(existingTrack.lineNumber, track.lineNumber);
|
||||
|
||||
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.track, trackEntry.canMerge);
|
||||
}
|
||||
}
|
||||
|
||||
// Order tracks by how they appear in the file
|
||||
@@ -628,6 +666,14 @@ abstract class HlsInputTrackBacking implements InputTrackBacking {
|
||||
return this.internalTrack.backingTrack._backing.getTimeResolution();
|
||||
}
|
||||
|
||||
getTimestampsAreRelativeToUnixEpoch(): boolean {
|
||||
if (!this.internalTrack.backingTrack) {
|
||||
throw new TrackNotHydratedError();
|
||||
}
|
||||
|
||||
return this.internalTrack.backingTrack._backing.getTimestampsAreRelativeToUnixEpoch();
|
||||
}
|
||||
|
||||
getBitrate(): number | null {
|
||||
return this.internalTrack.peakBitrate;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AES_128_BLOCK_SIZE } from '../aes';
|
||||
import { Segment, SegmentEncryptionInfo, SegmentLocation } from '../segment';
|
||||
import { SegmentedInput } from '../segmented-input';
|
||||
import { toDataView, joinPaths } from '../misc';
|
||||
import { toDataView, joinPaths, last } from '../misc';
|
||||
import { LineReader, Reader } from '../reader';
|
||||
import { HlsDemuxer } from './hls-demuxer';
|
||||
import { AttributeList, canIgnoreLine } from './hls-misc';
|
||||
@@ -10,7 +10,6 @@ const IV_STRING_REGEX = /^0[xX][0-9a-fA-F]+$/;
|
||||
|
||||
export class HlsSegmentedInput extends SegmentedInput {
|
||||
demuxer: HlsDemuxer;
|
||||
segments: Segment[] = [];
|
||||
nextSegmentDuration: number | null = null;
|
||||
nextSegmentTitle: string | null = null;
|
||||
accumulatedTime = 0;
|
||||
@@ -22,6 +21,7 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
currentInitSegment: Segment | null = null;
|
||||
lastByteRangeEnd: number | null = null;
|
||||
nextByteRange: { offset: number; length: number } | null = null;
|
||||
lastProgramDateTimeSeconds: number | null = null;
|
||||
|
||||
constructor(
|
||||
demuxer: HlsDemuxer,
|
||||
@@ -43,6 +43,8 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
}
|
||||
|
||||
this.segmentsPromise ??= (async () => {
|
||||
const segments: Segment[] = [];
|
||||
|
||||
while (true) {
|
||||
let line = lineReader.readNextLine();
|
||||
if (line instanceof Promise) line = await line;
|
||||
@@ -90,13 +92,14 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
this,
|
||||
location,
|
||||
this.accumulatedTime,
|
||||
this.lastProgramDateTimeSeconds !== null,
|
||||
this.nextSegmentDuration,
|
||||
this.nextSegmentTitle,
|
||||
key,
|
||||
this.currentFirstSegment,
|
||||
this.currentInitSegment,
|
||||
);
|
||||
this.segments.push(segment);
|
||||
segments.push(segment);
|
||||
this.accumulatedTime += this.nextSegmentDuration;
|
||||
this.nextSequenceNumber++;
|
||||
this.currentFirstSegment ??= segment;
|
||||
@@ -151,6 +154,7 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
this,
|
||||
location,
|
||||
this.accumulatedTime,
|
||||
this.lastProgramDateTimeSeconds !== null,
|
||||
0,
|
||||
null,
|
||||
this.currentKey,
|
||||
@@ -218,13 +222,51 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
this.nextSequenceNumber = number;
|
||||
} else if (line.startsWith('#EXT-X-BYTERANGE:')) {
|
||||
this.parseAndUpdateByteRange(line.slice(17));
|
||||
} else if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) {
|
||||
const dateTime = line.slice(25);
|
||||
const dateTimeMs = Date.parse(dateTime);
|
||||
|
||||
if (!Number.isFinite(dateTimeMs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dateTimeSeconds = dateTimeMs / 1000;
|
||||
if (this.lastProgramDateTimeSeconds === dateTimeSeconds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.lastProgramDateTimeSeconds === null && 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(segments)!;
|
||||
const lastSegmentEnd = lastSegment.relativeTimestamp + lastSegment.duration;
|
||||
const offset = dateTimeSeconds - lastSegmentEnd;
|
||||
|
||||
for (const segment of segments) {
|
||||
segment.relativeTimestamp += offset;
|
||||
segment.relativeToUnixEpoch = true;
|
||||
}
|
||||
|
||||
this.accumulatedTime += offset;
|
||||
}
|
||||
|
||||
this.lastProgramDateTimeSeconds = dateTimeSeconds;
|
||||
|
||||
if (Math.abs(this.accumulatedTime - dateTimeSeconds) >= 1) {
|
||||
// Only snap to the datetime if the current time is sufficiently far away from it. If we always
|
||||
// snapped, we'd lose the sub-second accuracy that's often provided by precise
|
||||
// segment durations.
|
||||
this.accumulatedTime = dateTimeSeconds;
|
||||
}
|
||||
} else if (line.startsWith('#EXT-X-DISCONTINUITY')) {
|
||||
this.currentFirstSegment = null;
|
||||
this.currentInitSegment = null;
|
||||
}
|
||||
}
|
||||
|
||||
return this.segments;
|
||||
return segments;
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user