src/utils/discontinuities.ts
import { logger } from './logger';
import type { Fragment } from '../loader/fragment';
import type { LevelDetails } from '../loader/level-details';
import type { Level } from '../types/level';
import type { RequiredProperties } from '../types/general';
import { adjustSliding } from '../controller/level-helper';
export function findFirstFragWithCC(fragments: Fragment[], cc: number) {
let firstFrag: Fragment | null = null;
for (let i = 0, len = fragments.length; i < len; i++) {
const currentFrag = fragments[i];
if (currentFrag && currentFrag.cc === cc) {
firstFrag = currentFrag;
break;
}
}
return firstFrag;
}
export function shouldAlignOnDiscontinuities(
lastFrag: Fragment | null,
lastLevel: Level,
details: LevelDetails
): lastLevel is RequiredProperties<Level, 'details'> {
if (lastLevel.details) {
if (
details.endCC > details.startCC ||
(lastFrag && lastFrag.cc < details.startCC)
) {
return true;
}
}
return false;
}
// Find the first frag in the previous level which matches the CC of the first frag of the new level
export function findDiscontinuousReferenceFrag(
prevDetails: LevelDetails,
curDetails: LevelDetails
) {
const prevFrags = prevDetails.fragments;
const curFrags = curDetails.fragments;
if (!curFrags.length || !prevFrags.length) {
logger.log('No fragments to align');
return;
}
const prevStartFrag = findFirstFragWithCC(prevFrags, curFrags[0].cc);
if (!prevStartFrag || (prevStartFrag && !prevStartFrag.startPTS)) {
logger.log('No frag in previous level to align on');
return;
}
return prevStartFrag;
}
function adjustFragmentStart(frag: Fragment, sliding: number) {
if (frag) {
const start = frag.start + sliding;
frag.start = frag.startPTS = start;
frag.endPTS = start + frag.duration;
}
}
export function adjustSlidingStart(sliding: number, details: LevelDetails) {
// Update segments
const fragments = details.fragments;
for (let i = 0, len = fragments.length; i < len; i++) {
adjustFragmentStart(fragments[i], sliding);
}
// Update LL-HLS parts at the end of the playlist
if (details.fragmentHint) {
adjustFragmentStart(details.fragmentHint, sliding);
}
details.alignedSliding = true;
}
/**
* Using the parameters of the last level, this function computes PTS' of the new fragments so that they form a
* contiguous stream with the last fragments.
* The PTS of a fragment lets Hls.js know where it fits into a stream - by knowing every PTS, we know which fragment to
* download at any given time. PTS is normally computed when the fragment is demuxed, so taking this step saves us time
* and an extra download.
* @param lastFrag
* @param lastLevel
* @param details
*/
export function alignStream(
lastFrag: Fragment | null,
lastLevel: Level | null,
details: LevelDetails
) {
if (!lastLevel) {
return;
}
alignDiscontinuities(lastFrag, details, lastLevel);
if (!details.alignedSliding && lastLevel.details) {
// If the PTS wasn't figured out via discontinuity sequence that means there was no CC increase within the level.
// Aligning via Program Date Time should therefore be reliable, since PDT should be the same within the same
// discontinuity sequence.
alignPDT(details, lastLevel.details);
}
if (
!details.alignedSliding &&
lastLevel.details &&
!details.skippedSegments
) {
// Try to align on sn so that we pick a better start fragment.
// Do not perform this on playlists with delta updates as this is only to align levels on switch
// and adjustSliding only adjusts fragments after skippedSegments.
adjustSliding(lastLevel.details, details);
}
}
/**
* Computes the PTS if a new level's fragments using the PTS of a fragment in the last level which shares the same
* discontinuity sequence.
* @param lastFrag - The last Fragment which shares the same discontinuity sequence
* @param lastLevel - The details of the last loaded level
* @param details - The details of the new level
*/
function alignDiscontinuities(
lastFrag: Fragment | null,
details: LevelDetails,
lastLevel: Level
) {
if (shouldAlignOnDiscontinuities(lastFrag, lastLevel, details)) {
const referenceFrag = findDiscontinuousReferenceFrag(
lastLevel.details,
details
);
if (referenceFrag && Number.isFinite(referenceFrag.start)) {
logger.log(
`Adjusting PTS using last level due to CC increase within current level ${details.url}`
);
adjustSlidingStart(referenceFrag.start, details);
}
}
}
/**
* Computes the PTS of a new level's fragments using the difference in Program Date Time from the last level.
* @param details - The details of the new level
* @param lastDetails - The details of the last loaded level
*/
export function alignPDT(details: LevelDetails, lastDetails: LevelDetails) {
// This check protects the unsafe "!" usage below for null program date time access.
if (
!lastDetails.fragments.length ||
!details.hasProgramDateTime ||
!lastDetails.hasProgramDateTime
) {
return;
}
// if last level sliding is 1000 and its first frag PROGRAM-DATE-TIME is 2017-08-20 1:10:00 AM
// and if new details first frag PROGRAM DATE-TIME is 2017-08-20 1:10:08 AM
// then we can deduce that playlist B sliding is 1000+8 = 1008s
const lastPDT = lastDetails.fragments[0].programDateTime!; // hasProgramDateTime check above makes this safe.
const newPDT = details.fragments[0].programDateTime!;
// date diff is in ms. frag.start is in seconds
const sliding = (newPDT - lastPDT) / 1000 + lastDetails.fragments[0].start;
if (sliding && Number.isFinite(sliding)) {
logger.log(
`Adjusting PTS using programDateTime delta ${
newPDT - lastPDT
}ms, sliding:${sliding.toFixed(3)} ${details.url} `
);
adjustSlidingStart(sliding, details);
}
}
export function alignFragmentByPDTDelta(frag: Fragment, delta: number) {
const { programDateTime } = frag;
if (!programDateTime) return;
const start = (programDateTime - delta) / 1000;
frag.start = frag.startPTS = start;
frag.endPTS = start + frag.duration;
}
/**
* Ensures appropriate time-alignment between renditions based on PDT. Unlike `alignPDT`, which adjusts
* the timeline based on the delta between PDTs of the 0th fragment of two playlists/`LevelDetails`,
* this function assumes the timelines represented in `refDetails` are accurate, including the PDTs,
* and uses the "wallclock"/PDT timeline as a cross-reference to `details`, adjusting the presentation
* times/timelines of `details` accordingly.
* Given the asynchronous nature of fetches and initial loads of live `main` and audio/subtitle tracks,
* the primary purpose of this function is to ensure the "local timelines" of audio/subtitle tracks
* are aligned to the main/video timeline, using PDT as the cross-reference/"anchor" that should
* be consistent across playlists, per the HLS spec.
* @param details - The details of the rendition you'd like to time-align (e.g. an audio rendition).
* @param refDetails - The details of the reference rendition with start and PDT times for alignment.
*/
export function alignMediaPlaylistByPDT(
details: LevelDetails,
refDetails: LevelDetails
) {
// This check protects the unsafe "!" usage below for null program date time access.
if (
!refDetails.fragments.length ||
!details.hasProgramDateTime ||
!refDetails.hasProgramDateTime
) {
return;
}
const refPDT = refDetails.fragments[0].programDateTime!; // hasProgramDateTime check above makes this safe.
const refStart = refDetails.fragments[0].start;
// Use the delta between the reference details' presentation timeline's start time and its PDT
// to align the other rendtion's timeline.
const delta = refPDT - refStart * 1000;
// Per spec: "If any Media Playlist in a Master Playlist contains an EXT-X-PROGRAM-DATE-TIME tag, then all
// Media Playlists in that Master Playlist MUST contain EXT-X-PROGRAM-DATE-TIME tags with consistent mappings
// of date and time to media timestamps."
// So we should be able to use each rendition's PDT as a reference time and use the delta to compute our relevant
// start and end times.
// NOTE: This code assumes each level/details timelines have already been made "internally consistent"
details.fragments.forEach((frag) => {
alignFragmentByPDTDelta(frag, delta);
});
if (details.fragmentHint) {
alignFragmentByPDTDelta(details.fragmentHint, delta);
}
details.alignedSliding = true;
}