From 283b343a8212920eb8bdb558ddfdeecccdc29c46 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Sat, 26 Jul 2025 14:52:54 -0700 Subject: [PATCH] Improve playlist alignment when PDT across playlists is inconsistent --- api-extractor/report/hls.js.api.md | 2 +- src/controller/audio-stream-controller.ts | 12 +-- src/controller/subtitle-stream-controller.ts | 35 ++++---- src/types/track.ts | 2 +- src/utils/discontinuities.ts | 8 +- .../controller/audio-stream-controller.ts | 80 ++++++++++--------- tests/unit/utils/discontinuities.ts | 12 ++- 7 files changed, 80 insertions(+), 71 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index efcc3d551ee..34ebf54de53 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -4824,7 +4824,7 @@ export interface Track extends BaseTrack { // (undocumented) buffer?: SourceBuffer; // (undocumented) - initSegment?: Uint8Array; + initSegment?: Uint8Array; } // Warning: (ae-missing-release-tag) "TrackLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index d4cc58c5c70..09d82459324 100644 --- a/src/controller/audio-stream-controller.ts +++ b/src/controller/audio-stream-controller.ts @@ -9,10 +9,7 @@ import { ElementaryStreamTypes, isMediaFragment } from '../loader/fragment'; import { Level } from '../types/level'; import { PlaylistContextType, PlaylistLevelType } from '../types/loader'; import { ChunkMetadata } from '../types/transmuxer'; -import { - alignDiscontinuities, - alignMediaPlaylistByPDT, -} from '../utils/discontinuities'; +import { alignStream } from '../utils/discontinuities'; import { audioMatchPredicate, matchesOption, @@ -573,10 +570,7 @@ class AudioStreamController if (!newDetails.alignedSliding) { // Align audio rendition with the "main" playlist on discontinuity change // or program-date-time (PDT) - alignDiscontinuities(newDetails, mainDetails); - if (!newDetails.alignedSliding) { - alignMediaPlaylistByPDT(newDetails, mainDetails); - } + alignStream(mainDetails, newDetails); sliding = newDetails.fragmentStart; } } @@ -1012,7 +1006,7 @@ class AudioStreamController mainDetails && mainDetails.fragmentStart !== track.details.fragmentStart ) { - alignMediaPlaylistByPDT(track.details, mainDetails); + alignStream(mainDetails, track.details); } } else { super.loadFragment(frag, track, targetBufferTime); diff --git a/src/controller/subtitle-stream-controller.ts b/src/controller/subtitle-stream-controller.ts index 93d9ea9dcca..b6627f5f702 100644 --- a/src/controller/subtitle-stream-controller.ts +++ b/src/controller/subtitle-stream-controller.ts @@ -11,7 +11,7 @@ import { import { Level } from '../types/level'; import { PlaylistLevelType } from '../types/loader'; import { BufferHelper } from '../utils/buffer-helper'; -import { alignMediaPlaylistByPDT } from '../utils/discontinuities'; +import { alignStream } from '../utils/discontinuities'; import { getAesModeFromFullSegmentMethod, isFullSegmentEncryption, @@ -295,46 +295,39 @@ export class SubtitleStreamController },duration:${newDetails.totalduration}`, ); this.mediaBuffer = this.mediaBufferTimeRanges; + + const mainDetails = this.mainDetails; let sliding = 0; if (newDetails.live || track.details?.live) { if (newDetails.deltaUpdateFailed) { return; } - const mainDetails = this.mainDetails; if (!mainDetails) { this.startFragRequested = false; return; } - const mainSlidingStartFragment = mainDetails.fragments[0]; - if (!track.details) { - if (newDetails.hasProgramDateTime && mainDetails.hasProgramDateTime) { - alignMediaPlaylistByPDT(newDetails, mainDetails); - sliding = newDetails.fragmentStart; - } else if (mainSlidingStartFragment) { - // line up live playlist with main so that fragments in range are loaded - sliding = mainSlidingStartFragment.start; - addSliding(newDetails, sliding); - } - } else { + if (track.details) { sliding = this.alignPlaylists( newDetails, track.details, this.levelLastLoaded?.details, ); - if (sliding === 0 && mainSlidingStartFragment) { - // realign with main when there is no overlap with last refresh - sliding = mainSlidingStartFragment.start; - addSliding(newDetails, sliding); - } } - // compute start position if we are aligned with the main playlist - if (mainDetails && !this.startFragRequested) { - this.setStartPosition(mainDetails, sliding); + if (!newDetails.alignedSliding) { + // line up live playlist with main so that fragments in range are loaded + alignStream(mainDetails, newDetails); + sliding = newDetails.fragmentStart; } } + track.details = newDetails; this.levelLastLoaded = track; + // compute start position if we are aligned with the main playlist + if (mainDetails && !this.startFragRequested) { + this.setStartPosition(mainDetails, sliding); + } + if (trackId !== currentTrackId) { return; } diff --git a/src/types/track.ts b/src/types/track.ts index af6d2466870..ec099f5b2f3 100644 --- a/src/types/track.ts +++ b/src/types/track.ts @@ -8,5 +8,5 @@ export interface TrackSet { export interface Track extends BaseTrack { buffer?: SourceBuffer; // eslint-disable-line no-restricted-globals - initSegment?: Uint8Array; + initSegment?: Uint8Array; } diff --git a/src/utils/discontinuities.ts b/src/utils/discontinuities.ts index a6abea9ef71..e2ae9075212 100644 --- a/src/utils/discontinuities.ts +++ b/src/utils/discontinuities.ts @@ -154,6 +154,12 @@ export function alignMediaPlaylistByPDT( return; } - const delta = (targetPDT - refPDT) / 1000 - (frag.start - refFrag.start); + const dateDifference = (targetPDT - refPDT) / 1000; + if (Math.abs(dateDifference) > Math.max(60, details.totalduration)) { + // Do not align on PDT if ranges differ significantly + return; + } + + const delta = dateDifference - (frag.start - refFrag.start); adjustSlidingStart(delta, details); } diff --git a/tests/unit/controller/audio-stream-controller.ts b/tests/unit/controller/audio-stream-controller.ts index d1c24b81d68..19e9bc0ac15 100644 --- a/tests/unit/controller/audio-stream-controller.ts +++ b/tests/unit/controller/audio-stream-controller.ts @@ -9,12 +9,13 @@ import { Events } from '../../../src/events'; import Hls from '../../../src/hls'; import { Fragment } from '../../../src/loader/fragment'; import KeyLoader from '../../../src/loader/key-loader'; +import { LevelDetails } from '../../../src/loader/level-details'; import { LoadStats } from '../../../src/loader/load-stats'; import { Level } from '../../../src/types/level'; import { PlaylistLevelType } from '../../../src/types/loader'; import { AttrList } from '../../../src/utils/attr-list'; import { adjustSlidingStart } from '../../../src/utils/discontinuities'; -import type { LevelDetails } from '../../../src/loader/level-details'; +import type { MediaFragment } from '../../../src/loader/fragment'; import type { AudioTrackLoadedData, AudioTrackSwitchingData, @@ -135,6 +136,12 @@ describe('AudioStreamController', function () { let audioStreamController: AudioStreamControllerTestable; let tracks: Level[]; + function cloneLevelDetails(options: Partial & { url: string }) { + return Object.assign(new LevelDetails(options.url), { + ...options, + }); + } + beforeEach(function () { sandbox = sinon.createSandbox(); hls = new Hls(); @@ -185,32 +192,33 @@ describe('AudioStreamController', function () { live: boolean, ) { const targetduration = 10; - const fragments: Fragment[] = Array.from(new Array(endSN - startSN)).map( - (u, i) => { - const frag = new Fragment(type, ''); - frag.sn = i + startSN; - frag.cc = Math.floor((i + startSN) / 10); - frag.setStart(i * targetduration); - frag.duration = targetduration; - return frag; - }, - ); + const fragments: MediaFragment[] = Array.from( + new Array(endSN - startSN), + ).map((u, i) => { + const frag = new Fragment(type, '') as MediaFragment; + frag.sn = i + startSN; + frag.cc = Math.floor((i + startSN) / 10); + frag.setStart(i * targetduration); + frag.duration = targetduration; + return frag; + }); + const details: LevelDetails = new LevelDetails(''); + details.live = live; + details.advanced = true; + details.updated = true; + details.fragments = fragments; + details.targetduration = targetduration; + details.totalduration = targetduration * fragments.length; + details.startSN = startSN; + details.endSN = endSN; + Object.defineProperty(details, 'startCC', { + get: () => fragments[0].cc, + }); + Object.defineProperty(details, 'endCC', { + get: () => fragments[fragments.length - 1].cc, + }); return { - details: { - live, - advanced: true, - updated: true, - fragments, - get endCC(): number { - return fragments[fragments.length - 1].cc; - }, - get startCC(): number { - return fragments[0].cc; - }, - targetduration, - startSN, - endSN, - } as unknown as LevelDetails, + details, id: 0, networkDetails: {}, stats: new LoadStats(), @@ -263,7 +271,9 @@ describe('AudioStreamController', function () { it('should update the audio track LevelDetails from the track loaded data', function () { audioStreamController.levels = tracks; - audioStreamController.mainDetails = mainLoadedData.details; + audioStreamController.mainDetails = cloneLevelDetails( + mainLoadedData.details, + ); audioStreamController.onAudioTrackLoaded( Events.AUDIO_TRACK_LOADED, @@ -317,9 +327,9 @@ describe('AudioStreamController', function () { // Audio track ends on DISCONTINUITY-SEQUENCE 1 (main ends at 0) trackLoadedData = getTrackLoadedData(7, 12, true); mainLoadedData = getLevelLoadedData(1, 6, true); - audioStreamController.mainDetails = { - ...mainLoadedData.details, - } as unknown as LevelDetails; + audioStreamController.mainDetails = cloneLevelDetails( + mainLoadedData.details, + ); expect(trackLoadedData.details.endCC).to.equal(1); expect(audioStreamController.mainDetails.endCC).to.equal(0); @@ -358,10 +368,10 @@ describe('AudioStreamController', function () { trackLoadedData.details.live = mainLoadedData.details.live = true; trackLoadedData.details.updated = mainLoadedData.details.updated = true; // Main live details are present but expired (see LevelDetails `get expired()` and `get age()`) - audioStreamController.mainDetails = { + audioStreamController.mainDetails = cloneLevelDetails({ ...mainLoadedData.details, - expired: true, - } as unknown as LevelDetails; + advancedDateTime: 1, // expired date time (must be > 0) + }); audioStreamController.onAudioTrackLoaded( Events.AUDIO_TRACK_LOADED, @@ -395,9 +405,7 @@ describe('AudioStreamController', function () { trackLoadedData = getTrackLoadedData(7, 12, true); mainLoadedData = getLevelLoadedData(1, 6, true); - audioStreamController.mainDetails = { - ...mainLoadedData.details, - } as unknown as LevelDetails; + audioStreamController.mainDetails = mainLoadedData.details; expect(trackLoadedData.details.endCC).to.equal(1); expect(audioStreamController.mainDetails.endCC).to.equal(0); diff --git a/tests/unit/utils/discontinuities.ts b/tests/unit/utils/discontinuities.ts index 28c86f0b626..801ad22bb0f 100644 --- a/tests/unit/utils/discontinuities.ts +++ b/tests/unit/utils/discontinuities.ts @@ -316,7 +316,7 @@ describe('discontinuities', function () { expect(details).to.deep.equal(detailsExpected); }); - it('adjusts level fragments without overlapping CC range but with programDateTime info', function () { + it('adjusts level fragments without overlapping CC range but with programDateTime info no more than one minute or the playlist duration apart', function () { const lastLevel = { details: objToLevelDetails({ PTSKnown: true, @@ -421,7 +421,11 @@ describe('discontinuities', function () { endCC: 3, }); alignMediaPlaylistByPDT(details, lastLevel.details); - expect(detailsExpected).to.deep.equal(details, JSON.stringify(details)); + + expect(detailsExpected).to.deep.equal( + details, + JSON.stringify(details, null, 2), + ); }); describe('alignDiscontinuities', function () { @@ -596,6 +600,10 @@ function objToLevelDetails(object: Partial): LevelDetails { details.startCC = details.fragments[0].cc; details.endCC = details.fragments[fragCount - 1].cc; } + details.totalduration = details.fragments.reduce( + (acc, { duration }) => acc + duration, + 0, + ); return details; }