diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 2ef051373b8..79c57bc8cbb 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -715,6 +715,8 @@ export class Fragment extends BaseSegment { // (undocumented) endPTS?: number; // (undocumented) + initSegment: Fragment | null; + // (undocumented) level: number; // (undocumented) levelkey?: LevelKey; @@ -1344,8 +1346,6 @@ export class LevelDetails { // (undocumented) holdBack: number; // (undocumented) - initSegment: Fragment | null; - // (undocumented) get lastPartIndex(): number; // (undocumented) get lastPartSn(): number; diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index 2fc46ead6a6..865731f4f22 100644 --- a/src/controller/audio-stream-controller.ts +++ b/src/controller/audio-stream-controller.ts @@ -285,66 +285,59 @@ class AudioStreamController return; } - let frag = trackDetails.initSegment; let targetBufferTime = 0; - if (!frag || frag.data) { - const mediaBuffer = this.mediaBuffer ? this.mediaBuffer : this.media; - const videoBuffer = this.videoBuffer ? this.videoBuffer : this.media; - const maxBufferHole = - pos < config.maxBufferHole - ? Math.max(MAX_START_GAP_JUMP, config.maxBufferHole) - : config.maxBufferHole; - const bufferInfo = BufferHelper.bufferInfo( - mediaBuffer, - pos, - maxBufferHole - ); - const mainBufferInfo = BufferHelper.bufferInfo( - videoBuffer, - pos, - maxBufferHole - ); - const bufferLen = bufferInfo.len; - const maxConfigBuffer = Math.min( - config.maxBufferLength, - config.maxMaxBufferLength - ); - const maxBufLen = Math.max(maxConfigBuffer, mainBufferInfo.len); - const audioSwitch = this.audioSwitch; + const mediaBuffer = this.mediaBuffer ? this.mediaBuffer : this.media; + const videoBuffer = this.videoBuffer ? this.videoBuffer : this.media; + const maxBufferHole = + pos < config.maxBufferHole + ? Math.max(MAX_START_GAP_JUMP, config.maxBufferHole) + : config.maxBufferHole; + const bufferInfo = BufferHelper.bufferInfo(mediaBuffer, pos, maxBufferHole); + const mainBufferInfo = BufferHelper.bufferInfo( + videoBuffer, + pos, + maxBufferHole + ); + const bufferLen = bufferInfo.len; + const maxConfigBuffer = Math.min( + config.maxBufferLength, + config.maxMaxBufferLength + ); + const maxBufLen = Math.max(maxConfigBuffer, mainBufferInfo.len); + const audioSwitch = this.audioSwitch; - // if buffer length is less than maxBufLen try to load a new fragment - if (bufferLen >= maxBufLen && !audioSwitch) { - return; - } + // if buffer length is less than maxBufLen try to load a new fragment + if (bufferLen >= maxBufLen && !audioSwitch) { + return; + } - if (!audioSwitch && this._streamEnded(bufferInfo, trackDetails)) { - hls.trigger(Events.BUFFER_EOS, { type: 'audio' }); - this.state = State.ENDED; - return; - } + if (!audioSwitch && this._streamEnded(bufferInfo, trackDetails)) { + hls.trigger(Events.BUFFER_EOS, { type: 'audio' }); + this.state = State.ENDED; + return; + } - const fragments = trackDetails.fragments; - const start = fragments[0].start; - targetBufferTime = bufferInfo.end; - - if (audioSwitch) { - targetBufferTime = pos; - // if currentTime (pos) is less than alt audio playlist start time, it means that alt audio is ahead of currentTime - if (trackDetails.PTSKnown && pos < start) { - // if everything is buffered from pos to start or if audio buffer upfront, let's seek to start - if (bufferInfo.end > start || bufferInfo.nextStart) { - this.log( - 'Alt audio track ahead of main track, seek to start of alt audio track' - ); - media.currentTime = start + 0.05; - } + const fragments = trackDetails.fragments; + const start = fragments[0].start; + targetBufferTime = bufferInfo.end; + + if (audioSwitch) { + targetBufferTime = pos; + // if currentTime (pos) is less than alt audio playlist start time, it means that alt audio is ahead of currentTime + if (trackDetails.PTSKnown && pos < start) { + // if everything is buffered from pos to start or if audio buffer upfront, let's seek to start + if (bufferInfo.end > start || bufferInfo.nextStart) { + this.log( + 'Alt audio track ahead of main track, seek to start of alt audio track' + ); + media.currentTime = start + 0.05; } } + } - frag = this.getNextFragment(targetBufferTime, trackDetails); - if (!frag) { - return; - } + const frag = this.getNextFragment(targetBufferTime, trackDetails); + if (!frag) { + return; } if (frag.decryptdata?.keyFormat === 'identity' && !frag.decryptdata?.key) { @@ -510,7 +503,7 @@ class AudioStreamController // Check if we have video initPTS // If not we need to wait for it const initPTS = this.initPTS[frag.cc]; - const initSegmentData = details.initSegment?.data; + const initSegmentData = frag.initSegment?.data; if (initPTS !== undefined) { // this.log(`Transmuxing ${sn} of [${details.startSN} ,${details.endSN}],track ${trackId}`); // time Offset is accurate if level PTS is known, or if playlist is not sliding (not live) diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index f0b3cc11491..2a166893019 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -435,16 +435,11 @@ export default class BaseStreamController details, 'Level details are defined when init segment is loaded' ); - const initSegment = details.initSegment as Fragment; - console.assert( - initSegment, - 'Fragment initSegment is defined when init segment is loaded' - ); const stats = frag.stats; this.state = State.IDLE; this.fragLoadError = 0; - initSegment.data = new Uint8Array(data.payload); + frag.data = new Uint8Array(data.payload); stats.parsing.start = stats.buffering.start = self.performance.now(); stats.parsing.end = stats.buffering.end = self.performance.now(); @@ -762,14 +757,7 @@ export default class BaseStreamController const start = fragments[0].start; let frag; - // If an initSegment is present, it must be buffered first - if ( - levelDetails.initSegment && - !levelDetails.initSegment.data && - !this.bitrateTest - ) { - frag = levelDetails.initSegment; - } else if (levelDetails.live) { + if (levelDetails.live) { const initialLiveManifestSize = config.initialLiveManifestSize; if (fragLen < initialLiveManifestSize) { this.warn( @@ -804,6 +792,11 @@ export default class BaseStreamController frag = this.getFragmentAtPosition(pos, end, levelDetails); } + // If an initSegment is present, it must be buffered first + if (frag?.initSegment && !frag?.initSegment.data && !this.bitrateTest) { + frag = frag.initSegment; + } + return frag; } diff --git a/src/controller/level-helper.ts b/src/controller/level-helper.ts index 49386fe1547..f43a7f56611 100644 --- a/src/controller/level-helper.ts +++ b/src/controller/level-helper.ts @@ -165,9 +165,15 @@ export function mergeDetails( oldDetails: LevelDetails, newDetails: LevelDetails ): void { - // potentially retrieve cached initsegment - if (newDetails.initSegment && oldDetails.initSegment) { - newDetails.initSegment = oldDetails.initSegment; + // Track the last initSegment processed. Initialize it to the last one on the timeline. + let currentInitSegment: Fragment | null = null; + const oldFragments = oldDetails.fragments; + for (let i = oldFragments.length - 1; i >= 0; i--) { + const oldInit = oldFragments[i].initSegment; + if (oldInit) { + currentInitSegment = oldInit; + break; + } } if (oldDetails.fragmentHint) { @@ -214,6 +220,15 @@ export function mergeDetails( newFrag.loader = oldFrag.loader; newFrag.stats = oldFrag.stats; newFrag.urlId = oldFrag.urlId; + if (oldFrag.initSegment) { + newFrag.initSegment = oldFrag.initSegment; + currentInitSegment = oldFrag.initSegment; + } else if ( + !newFrag.initSegment || + newFrag.initSegment.relurl == currentInitSegment?.relurl + ) { + newFrag.initSegment = currentInitSegment; + } } ); @@ -239,9 +254,6 @@ export function mergeDetails( } } if (newDetails.skippedSegments) { - if (!newDetails.initSegment) { - newDetails.initSegment = oldDetails.initSegment; - } newDetails.startCC = newDetails.fragments[0].cc; } diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 9c500b7d4c7..d1d81358474 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -249,77 +249,77 @@ export default class StreamController return; } - let frag = levelDetails.initSegment; let targetBufferTime = 0; - if (!frag || frag.data || this.bitrateTest) { - // compute max Buffer Length that we could get from this load level, based on level bitrate. don't buffer more than 60 MB and more than 30s - const levelBitrate = levelInfo.maxBitrate; - let maxBufLen; - if (levelBitrate) { - maxBufLen = Math.max( - (8 * config.maxBufferSize) / levelBitrate, - config.maxBufferLength - ); - } else { - maxBufLen = config.maxBufferLength; - } - maxBufLen = Math.min(maxBufLen, config.maxMaxBufferLength); - - // determine next candidate fragment to be loaded, based on current position and end of buffer position - // ensure up to `config.maxMaxBufferLength` of buffer upfront - const maxBufferHole = - pos < config.maxBufferHole - ? Math.max(MAX_START_GAP_JUMP, config.maxBufferHole) - : config.maxBufferHole; - const bufferInfo = BufferHelper.bufferInfo( - this.mediaBuffer ? this.mediaBuffer : media, - pos, - maxBufferHole + // compute max Buffer Length that we could get from this load level, based on level bitrate. don't buffer more than 60 MB and more than 30s + const levelBitrate = levelInfo.maxBitrate; + let maxBufLen; + if (levelBitrate) { + maxBufLen = Math.max( + (8 * config.maxBufferSize) / levelBitrate, + config.maxBufferLength ); - const bufferLen = bufferInfo.len; - // Stay idle if we are still with buffer margins - if (bufferLen >= maxBufLen) { - return; - } - - if (this._streamEnded(bufferInfo, levelDetails)) { - const data: BufferEOSData = {}; - if (this.altAudio) { - data.type = 'video'; - } + } else { + maxBufLen = config.maxBufferLength; + } + maxBufLen = Math.min(maxBufLen, config.maxMaxBufferLength); + + // determine next candidate fragment to be loaded, based on current position and end of buffer position + // ensure up to `config.maxMaxBufferLength` of buffer upfront + const maxBufferHole = + pos < config.maxBufferHole + ? Math.max(MAX_START_GAP_JUMP, config.maxBufferHole) + : config.maxBufferHole; + const bufferInfo = BufferHelper.bufferInfo( + this.mediaBuffer ? this.mediaBuffer : media, + pos, + maxBufferHole + ); + const bufferLen = bufferInfo.len; + // Stay idle if we are still with buffer margins + if (bufferLen >= maxBufLen) { + return; + } - this.hls.trigger(Events.BUFFER_EOS, data); - this.state = State.ENDED; - return; + if (this._streamEnded(bufferInfo, levelDetails)) { + const data: BufferEOSData = {}; + if (this.altAudio) { + data.type = 'video'; } - targetBufferTime = bufferInfo.end; - frag = this.getNextFragment(targetBufferTime, levelDetails); - // Avoid backtracking after seeking or switching by loading an earlier segment in streams that could backtrack - if ( - this.couldBacktrack && - !this.fragPrevious && - frag && - frag.sn !== 'initSegment' - ) { - const fragIdx = frag.sn - levelDetails.startSN; - if (fragIdx > 1) { - frag = levelDetails.fragments[fragIdx - 1]; - this.fragmentTracker.removeFragment(frag); - } - } - // Avoid loop loading by using nextLoadPosition set for backtracking - if ( - frag && - this.fragmentTracker.getState(frag) === FragmentState.OK && - this.nextLoadPosition > targetBufferTime - ) { - frag = this.getNextFragment(this.nextLoadPosition, levelDetails); - } - if (!frag) { - return; + this.hls.trigger(Events.BUFFER_EOS, data); + this.state = State.ENDED; + return; + } + + targetBufferTime = bufferInfo.end; + let frag = this.getNextFragment(targetBufferTime, levelDetails); + // Avoid backtracking after seeking or switching by loading an earlier segment in streams that could backtrack + if ( + this.couldBacktrack && + !this.fragPrevious && + frag && + frag.sn !== 'initSegment' + ) { + const fragIdx = frag.sn - levelDetails.startSN; + if (fragIdx > 1) { + frag = levelDetails.fragments[fragIdx - 1]; + this.fragmentTracker.removeFragment(frag); } } + // Avoid loop loading by using nextLoadPosition set for backtracking + if ( + frag && + this.fragmentTracker.getState(frag) === FragmentState.OK && + this.nextLoadPosition > targetBufferTime + ) { + frag = this.getNextFragment(this.nextLoadPosition, levelDetails); + } + if (!frag) { + return; + } + if (frag.initSegment && !frag.initSegment.data && !this.bitrateTest) { + frag = frag.initSegment; + } // We want to load the key if we're dealing with an identity key, because we will decrypt // this content using the key we fetch. Other keys will be handled by the DRM CDM via EME. @@ -689,7 +689,7 @@ export default class StreamController // time Offset is accurate if level PTS is known, or if playlist is not sliding (not live) const accurateTimeOffset = details.PTSKnown || !details.live; - const initSegmentData = details.initSegment?.data; + const initSegmentData = frag.initSegment?.data; const audioCodec = this._getAudioCodec(currentLevel); // transmux the MPEG-TS data to ISO-BMFF segments diff --git a/src/loader/fragment.ts b/src/loader/fragment.ts index 235555dfb85..2782b07240f 100644 --- a/src/loader/fragment.ts +++ b/src/loader/fragment.ts @@ -139,6 +139,8 @@ export class Fragment extends BaseSegment { public bitrateTest: boolean = false; // #EXTINF segment title public title: string | null = null; + // The Media Initialization Section for this segment + public initSegment: Fragment | null = null; constructor(type: PlaylistLevelType, baseurl: string) { super(baseurl); diff --git a/src/loader/level-details.ts b/src/loader/level-details.ts index faee20f7363..d42e08e392e 100644 --- a/src/loader/level-details.ts +++ b/src/loader/level-details.ts @@ -13,7 +13,6 @@ export class LevelDetails { public fragments: Fragment[]; public fragmentHint?: Fragment; public partList: Part[] | null = null; - public initSegment: Fragment | null = null; public live: boolean = true; public ageHeader: number = 0; public advancedDateTime?: number; diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index f3e699f2d16..0095081213a 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -199,6 +199,8 @@ export default class M3U8Parser { ): LevelDetails { const level = new LevelDetails(baseurl); const fragments: M3U8ParserFragments = level.fragments; + // The most recent init segment seen (applies to all subsequent segments) + let currentInitSegment: Fragment | null = null; let currentSN = 0; let currentPart = 0; let totalduration = 0; @@ -209,11 +211,26 @@ export default class M3U8Parser { let i: number; let levelkey: LevelKey | undefined; let firstPdtIndex = -1; + let createNextFrag = false; LEVEL_PLAYLIST_REGEX_FAST.lastIndex = 0; level.m3u8 = string; while ((result = LEVEL_PLAYLIST_REGEX_FAST.exec(string)) !== null) { + if (createNextFrag) { + createNextFrag = false; + frag = new Fragment(type, baseurl); + // setup the next fragment for part loading + frag.start = totalduration; + frag.sn = currentSN; + frag.cc = discontinuityCounter; + frag.level = id; + if (currentInitSegment) { + frag.initSegment = currentInitSegment; + frag.rawProgramDateTime = currentInitSegment.rawProgramDateTime; + } + } + const duration = result[1]; if (duration) { // INF @@ -241,13 +258,7 @@ export default class M3U8Parser { totalduration += frag.duration; currentSN++; currentPart = 0; - - frag = new Fragment(type, baseurl); - // setup the next fragment for part loading - frag.start = totalduration; - frag.sn = currentSN; - frag.cc = discontinuityCounter; - frag.level = id; + createNextFrag = true; } } else if (result[4]) { // X-BYTERANGE @@ -423,9 +434,9 @@ export default class M3U8Parser { if (levelkey) { frag.levelkey = levelkey; } - level.initSegment = frag; - frag = new Fragment(type, baseurl); - frag.rawProgramDateTime = level.initSegment.rawProgramDateTime; + frag.initSegment = null; + currentInitSegment = frag; + createNextFrag = true; break; } case 'SERVER-CONTROL': { @@ -507,7 +518,7 @@ export default class M3U8Parser { level.endSN = lastSn !== 'initSegment' ? lastSn : 0; if (firstFragment) { level.startCC = firstFragment.cc; - if (!level.initSegment) { + if (!firstFragment.initSegment) { // this is a bit lurky but HLS really has no other way to tell us // if the fragments are TS or MP4, except if we download them :/ // but this is to be able to handle SIDX. @@ -523,7 +534,7 @@ export default class M3U8Parser { frag.relurl = lastFragment.relurl; frag.level = id; frag.sn = 'initSegment'; - level.initSegment = frag; + firstFragment.initSegment = frag; level.needSidxRanges = true; } } diff --git a/src/loader/playlist-loader.ts b/src/loader/playlist-loader.ts index 0eb16f62a3a..75928a0fdfa 100644 --- a/src/loader/playlist-loader.ts +++ b/src/loader/playlist-loader.ts @@ -25,7 +25,6 @@ import type { } from '../types/loader'; import { PlaylistContextType, PlaylistLevelType } from '../types/loader'; import { LevelDetails } from './level-details'; -import { Fragment } from './fragment'; import type Hls from '../hls'; import { AttrList } from '../utils/attr-list'; import type { @@ -517,7 +516,7 @@ class PlaylistLoader { // return early after calling load for // the SIDX box. if (levelDetails.needSidxRanges) { - const sidxUrl = (levelDetails.initSegment as Fragment).url as string; + const sidxUrl = levelDetails.fragments[0].initSegment?.url as string; this.load({ url: sidxUrl, isSidxRequest: true, @@ -564,10 +563,10 @@ class PlaylistLoader { String(segRefInfo.start) ); } + if (frag.initSegment) { + frag.initSegment.setByteRange(String(sidxInfo.moovEndOffset) + '@0'); + } }); - (levelDetails.initSegment as Fragment).setByteRange( - String(sidxInfo.moovEndOffset) + '@0' - ); } private handleManifestParsingError( diff --git a/tests/unit/loader/playlist-loader.js b/tests/unit/loader/playlist-loader.js index c1b06603035..fcf102c1fc1 100644 --- a/tests/unit/loader/playlist-loader.js +++ b/tests/unit/loader/playlist-loader.js @@ -355,10 +355,13 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ const result = M3U8Parser.parseLevelPlaylist( level, 'http://example.invalid/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); - expect(result.initSegment).to.be.ok; - expect(result.initSegment.relurl).to.equal('/something.mp4?abc'); + const initSegment = result.fragments[0].initSegment; + expect(initSegment).to.be.ok; + expect(initSegment.relurl).to.equal('/something.mp4?abc'); }); it('parse level with single char fragment URI', function () { @@ -950,12 +953,43 @@ main.mp4`; 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', 0 ); - expect(result.initSegment.url).to.equal( + const initSegment = result.fragments[0].initSegment; + expect(initSegment.url).to.equal( 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/main.mp4' ); - expect(result.initSegment.byteRangeStartOffset).to.equal(0); - expect(result.initSegment.byteRangeEndOffset).to.equal(718); - expect(result.initSegment.sn).to.equal('initSegment'); + expect(initSegment.byteRangeStartOffset).to.equal(0); + expect(initSegment.byteRangeEndOffset).to.equal(718); + expect(initSegment.sn).to.equal('initSegment'); + }); + + it('parses multiple #EXT-X-MAP URI', function () { + const level = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-VERSION:7 +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-MAP:URI="main.mp4" +#EXTINF:6.00600, +frag1.mp4 +#EXT-X-DISCONTINUITY +#EXT-X-MAP:URI="alt.mp4" +#EXTINF:4.0 +frag2.mp4 +`; + const result = M3U8Parser.parseLevelPlaylist( + level, + 'http://video.example.com/disc.m3u8', + 0 + ); + expect(result.fragments[0].initSegment.url).to.equal( + 'http://video.example.com/main.mp4' + ); + expect(result.fragments[0].initSegment.sn).to.equal('initSegment'); + expect(result.fragments[1].initSegment.url).to.equal( + 'http://video.example.com/alt.mp4' + ); + expect(result.fragments[1].initSegment.sn).to.equal('initSegment'); }); describe('PDT calculations', function () {