From eeba482d53f41de42664c1d0355917a21c4a3abe Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 10 Feb 2021 16:12:05 -0500 Subject: [PATCH] feat(llhls): serverControl, preloadSegment, and partTargetDuration --- src/master-playlist-controller.js | 14 +++- src/playlist-loader.js | 47 +++++++---- src/playlist.js | 135 +++++++++++++++++++++++------- src/segment-loader.js | 51 +++++------ test/playlist.test.js | 93 +++++++++++++++++++- 5 files changed, 265 insertions(+), 75 deletions(-) diff --git a/src/master-playlist-controller.js b/src/master-playlist-controller.js index d82ebcda6..52c51f910 100644 --- a/src/master-playlist-controller.js +++ b/src/master-playlist-controller.js @@ -1287,8 +1287,12 @@ export class MasterPlaylistController extends videojs.EventTarget { return; } - const suggestedPresentationDelay = this.masterPlaylistLoader_.master.suggestedPresentationDelay; - const mainSeekable = Vhs.Playlist.seekable(media, expired, suggestedPresentationDelay); + const master = this.masterPlaylistLoader_.master; + const mainSeekable = Vhs.Playlist.seekable( + media, + expired, + Vhs.Playlist.getHoldBack(master, media) + ); if (mainSeekable.length === 0) { return; @@ -1302,7 +1306,11 @@ export class MasterPlaylistController extends videojs.EventTarget { return; } - audioSeekable = Vhs.Playlist.seekable(media, expired, suggestedPresentationDelay); + audioSeekable = Vhs.Playlist.seekable( + media, + expired, + Vhs.Playlist.getHoldBack(master, media) + ); if (audioSeekable.length === 0) { return; diff --git a/src/playlist-loader.js b/src/playlist-loader.js index 49964869b..116bc4bf5 100644 --- a/src/playlist-loader.js +++ b/src/playlist-loader.js @@ -61,7 +61,7 @@ export const updateSegments = (original, update, offset) => { }; export const resolveSegmentUris = (segment, baseUri) => { - // preloadSegments will not have a uri at all + // preloadSegment will not have a uri at all // as the segment isn't actually in the manifest yet, only parts if (!segment.resolvedUri && segment.uri) { segment.resolvedUri = resolveUrl(baseUri, segment.uri); @@ -125,6 +125,15 @@ export const updateMaster = (master, media, unchangedCheck = isPlaylistUnchanged const mergedPlaylist = mergeOptions(playlist, media); + media.segments = media.segments || []; + + // a preloadSegment with only preloadHints is not currently + // a usable segment, only include a preloadSegment that has + // parts. + if (media.preloadSegment && media.preloadSegment.parts) { + media.segments.push(media.preloadSegment); + } + // if the update could overlap existing segment information, merge the two segment lists if (playlist.segments) { mergedPlaylist.segments = updateSegments( @@ -166,16 +175,16 @@ export const updateMaster = (master, media, unchangedCheck = isPlaylistUnchanged */ export const refreshDelay = (media, update) => { const lastSegment = media.segments[media.segments.length - 1]; - let delay; - - if (update && lastSegment && lastSegment.duration) { - delay = lastSegment.duration * 1000; - } else { - // if the playlist is unchanged since the last reload or last segment duration - // cannot be determined, try again after half the target duration - delay = (media.targetDuration || 10) * 500; + const lastPart = lastSegment && lastSegment.parts && lastSegment.parts[lastSegment.parts - 1]; + const lastDuration = lastPart && lastPart.duration || lastSegment && lastSegment.duration; + + if (update && lastDuration) { + return lastDuration * 1000; } - return delay; + + // if the playlist is unchanged since the last reload or last segment duration + // cannot be determined, try again after half the target duration + return (media.partTargetDuration || media.targetDuration || 10) * 500; }; /** @@ -304,7 +313,7 @@ export default class PlaylistLoader extends EventTarget { // merge this playlist into the master const update = updateMaster(this.master, playlist); - this.targetDuration = playlist.targetDuration; + this.targetDuration = playlist.partTargetDuration || playlist.targetDuration; if (update) { this.master = update; @@ -383,7 +392,7 @@ export default class PlaylistLoader extends EventTarget { window.clearTimeout(this.finalRenditionTimeout); if (shouldDelay) { - const delay = (playlist.targetDuration / 2) * 1000 || 5 * 1000; + const delay = ((playlist.partTargetDuration || playlist.targetDuration) / 2) * 1000 || 5 * 1000; this.finalRenditionTimeout = window.setTimeout(this.media.bind(this, playlist, false), delay); @@ -516,7 +525,7 @@ export default class PlaylistLoader extends EventTarget { const media = this.media(); if (shouldDelay) { - const delay = media ? (media.targetDuration / 2) * 1000 : 5 * 1000; + const delay = media ? ((media.partTargetDuration || media.targetDuration) / 2) * 1000 : 5 * 1000; this.mediaUpdateTimeout = window.setTimeout(() => this.load(), delay); return; @@ -638,11 +647,15 @@ export default class PlaylistLoader extends EventTarget { // then resolve URIs in advance, as they are usually done after a playlist request, // which may not happen if the playlist is resolved. manifest.playlists.forEach((playlist) => { - if (playlist.segments) { - playlist.segments.forEach((segment) => { - resolveSegmentUris(segment, playlist.resolvedUri); - }); + playlist.segments = playlist.segments || []; + + if (playlist.preloadSegment && playlist.preloadSegment.parts) { + playlist.segments.push(playlist.preloadSegment); } + + playlist.segments.forEach((segment) => { + resolveSegmentUris(segment, playlist.resolvedUri); + }); }); this.trigger('loadedplaylist'); if (!this.request) { diff --git a/src/playlist.js b/src/playlist.js index 59a236157..ef95fa72b 100644 --- a/src/playlist.js +++ b/src/playlist.js @@ -9,6 +9,77 @@ import {TIME_FUDGE_FACTOR} from './ranges.js'; const {createTimeRange} = videojs; +/** + * A function to get a combined list of parts and segments with durations + * and indexes. + * + * @param {Playlist} playlist the playlist to get the list for. + * + * @return {Array} The part/segment list. + */ +const getPartSegments = (playlist) => ([] || playlist.segments).reduce((acc, segment, si) => { + if (segment.parts) { + segment.parts.forEach(function(part, pi) { + acc.push({duration: part.duration, segmentIndex: si, partIndex: pi}); + + }); + } else { + acc.push({duration: segment.duration, segmentIndex: si, partIndex: null}); + } + return acc; +}, []); + +/** + * Get the number of seconds to hold back from the end of a + * live playlist. + * + * @param {Playlist} master the master playlist + * @param {Playlist} media the media playlist + * @return {number} the hold back in seconds. + */ +export const getHoldBack = (master, media) => { + if (media.endList) { + return 0; + } + + const partSegments = getPartSegments(media); + const hasParts = partSegments.length && + typeof partSegments[partSegments.length - 1].partIndex === 'number'; + + let lastThreeDurations = 0; + + if (partSegments.length >= 3) { + for (let i = 0; i < 3; i++) { + lastThreeDurations += partSegments[partSegments.length - 1 - i].duration; + } + } + + // dash suggestedPresentationDelay trumps everything + if (master && master.suggestedPresentationDelay) { + return master.suggestedPresentationDelay; + + // look for "part" delays from ll-hls first + } else if (hasParts && media.serverControl && media.serverControl.partHoldBack) { + return media.serverControl.partHoldBack; + } else if (hasParts && media.partTargetDuration) { + return media.partTargetDuration * 3; + } else if (hasParts && lastThreeDurations) { + return lastThreeDurations; + + // finally look for full segment delays + } else if (media.serverControl && media.serverControl.holdBack) { + return media.serverControl.holdBack; + } else if (media.targetDuration) { + // TODO: this should probably be targetDuration * 3 + // but we use this for backwards compatability. + return partSegments[partSegments.length - 1].duration + media.targetDuration * 2; + } else if (lastThreeDurations) { + return lastThreeDurations; + } + + return 0; +}; + /** * walk backward until we find a duration we can use * or return a failure @@ -233,31 +304,29 @@ export const sumDurations = function(playlist, startIndex, endIndex) { * @function safeLiveIndex */ export const safeLiveIndex = function(playlist, liveEdgePadding) { - if (!playlist.segments.length) { + const partSegments = getPartSegments(playlist); + + if (!partSegments.length) { return 0; } - let i = playlist.segments.length; - const lastSegmentDuration = playlist.segments[i - 1].duration || playlist.targetDuration; - const safeDistance = typeof liveEdgePadding === 'number' ? - liveEdgePadding : - lastSegmentDuration + playlist.targetDuration * 2; - - if (safeDistance === 0) { - return i; + if (typeof liveEdgePadding !== 'number') { + liveEdgePadding = getHoldBack(null, playlist); } + let i = partSegments.length; let distanceFromEnd = 0; while (i--) { - distanceFromEnd += playlist.segments[i].duration; + distanceFromEnd += partSegments[i].duration; - if (distanceFromEnd >= safeDistance) { - break; + if (distanceFromEnd >= liveEdgePadding) { + return partSegments[i].segmentIndex; } } - return Math.max(0, i); + // there is nowhere in the playlist that is a safe distance from live. + return 0; }; /** @@ -347,31 +416,34 @@ export const getMediaInfoForTime = function( startIndex, startTime ) { - let i; - let segment; - const numSegments = playlist.segments.length; + const partSegments = getPartSegments(playlist); let time = currentTime - startTime; if (time < 0) { // Walk backward from startIndex in the playlist, adding durations // until we find a segment that contains `time` and return it if (startIndex > 0) { - for (i = startIndex - 1; i >= 0; i--) { - segment = playlist.segments[i]; + for (let i = startIndex - 1; i >= 0; i--) { + const segment = partSegments[i]; + time += (segment.duration + TIME_FUDGE_FACTOR); + if (time > 0) { return { - mediaIndex: i, - startTime: startTime - sumDurations(playlist, startIndex, i) + mediaIndex: segment.segmentIndex, + startTime: startTime - sumDurations(playlist, startIndex, segment.segmentIndex), + partIndex: segment.partIndex }; } } } + // We were unable to find a good segment within the playlist // so select the first segment return { - mediaIndex: 0, + mediaIndex: partSegments[0] && partSegments[0].segmentIndex || 0, + partIndex: partSegments[0] && partSegments[0].partIndex || null, startTime: currentTime }; } @@ -380,11 +452,11 @@ export const getMediaInfoForTime = function( // adding target durations. If we "run out of time" before getting to // the first segment, return the first segment if (startIndex < 0) { - for (i = startIndex; i < 0; i++) { + for (let i = startIndex; i < 0; i++) { time -= playlist.targetDuration; if (time < 0) { return { - mediaIndex: 0, + mediaIndex: partSegments[0].segmentIndex, startTime: currentTime }; } @@ -394,20 +466,24 @@ export const getMediaInfoForTime = function( // Walk forward from startIndex in the playlist, subtracting durations // until we find a segment that contains `time` and return it - for (i = startIndex; i < numSegments; i++) { - segment = playlist.segments[i]; - time -= segment.duration + TIME_FUDGE_FACTOR; + for (let i = startIndex; i < partSegments.length; i++) { + const partSegment = partSegments[i]; + + time -= partSegment.duration + TIME_FUDGE_FACTOR; + if (time < 0) { return { - mediaIndex: i, - startTime: startTime + sumDurations(playlist, startIndex, i) + mediaIndex: partSegment.segmentIndex, + startTime: startTime + sumDurations(playlist, startIndex, partSegment.segmentIndex), + partIndex: partSegment.partIndex }; } } // We are out of possible candidates so load the last one... return { - mediaIndex: numSegments - 1, + mediaIndex: partSegments[partSegments.length - 1].segmentIndex, + partIndex: partSegments[partSegments.length - 1].partIndex, startTime: currentTime }; }; @@ -543,6 +619,7 @@ export const isLowestEnabledRendition = (master, media) => { // exports export default { + getHoldBack, duration, seekable, safeLiveIndex, diff --git a/src/segment-loader.js b/src/segment-loader.js index c826390e8..2be981883 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -94,7 +94,8 @@ const segmentInfoString = (segmentInfo) => { const { segment: { start, - end + end, + parts }, playlist: { mediaSequence: seq, @@ -102,12 +103,19 @@ const segmentInfoString = (segmentInfo) => { segments = [] }, mediaIndex: index, + partIndex, timeline } = segmentInfo; + const name = segmentInfo.segment.uri ? 'segment' : 'pre-segment'; + return [ - `appending [${index}] of [${seq}, ${seq + segments.length}] from playlist [${id}]`, - `[${start} => ${end}] in timeline [${timeline}]` + `${name} [${index}/${segments.length - 1}]`, + (partIndex ? `part [${partIndex}/${parts.length - 1}]` : ''), + `msn [${seq}/${seq + segments.length - 1}]`, + `playlist [${id}]`, + `start/end [${start} => ${end}]`, + `timeline [${timeline}]` ].join(' '); }; @@ -1326,7 +1334,7 @@ export default class SegmentLoader extends videojs.EventTarget { return null; } - let nextPartIndex = typeof currentPartIndex === 'number' ? currentPartIndex + 1 : 0; + let nextPartIndex = null; let nextMediaIndex = null; let startOfSegment; let isSyncRequest = false; @@ -1347,6 +1355,7 @@ export default class SegmentLoader extends videojs.EventTarget { } else { startOfSegment = lastBufferedEnd; } + nextPartIndex = typeof currentPartIndex === 'number' ? currentPartIndex + 1 : 0; if (!segment || !segment.parts || !segment.parts.length || !segment.parts[nextPartIndex]) { nextMediaIndex = currentMediaIndex + 1; @@ -1358,28 +1367,22 @@ export default class SegmentLoader extends videojs.EventTarget { // There is a sync-point but the lack of a mediaIndex indicates that // we need to make a good conservative guess about which segment to // fetch - } else if (this.fetchAtBuffer_) { - // Find the segment containing the end of the buffer - const mediaSourceInfo = Playlist.getMediaInfoForTime( - playlist, - lastBufferedEnd, - syncPoint.segmentIndex, - syncPoint.time - ); - - nextMediaIndex = mediaSourceInfo.mediaIndex; - startOfSegment = mediaSourceInfo.startTime; } else { - // Find the segment containing currentTime + // Find the segment containing the end of the buffer or current time. const mediaSourceInfo = Playlist.getMediaInfoForTime( playlist, - currentTime, + this.fetchAtBuffer_ ? lastBufferedEnd : currentTime, syncPoint.segmentIndex, syncPoint.time ); nextMediaIndex = mediaSourceInfo.mediaIndex; startOfSegment = mediaSourceInfo.startTime; + nextPartIndex = mediaSourceInfo.partIndex; + } + + if (typeof nextPartIndex !== 'number' && playlist.segments[nextMediaIndex] && playlist.segments[nextMediaIndex].parts) { + nextPartIndex = 0; } const segmentInfo = this.generateSegmentInfo_(playlist, nextMediaIndex, startOfSegment, isSyncRequest, nextPartIndex); @@ -1399,11 +1402,8 @@ export default class SegmentLoader extends videojs.EventTarget { this.logger_(`checkBuffer_ returning ${segmentInfo.uri}`, { segmentInfo, - playlist, currentMediaIndex, - nextMediaIndex, - startOfSegment, - isSyncRequest + currentPartIndex }); return segmentInfo; @@ -1599,7 +1599,8 @@ export default class SegmentLoader extends videojs.EventTarget { this.trigger('earlyabort'); } - handleAbort_() { + handleAbort_(segmentInfo) { + this.logger_(`Aborting ${segmentInfoString(segmentInfo)}`); this.mediaRequestsAborted += 1; } @@ -2263,13 +2264,15 @@ export default class SegmentLoader extends videojs.EventTarget { segmentInfo.timeline > 0; const isEndOfTimeline = isEndOfStream || (isWalkingForward && isDiscontinuity); + this.logger_(`Requesting ${segmentInfoString(segmentInfo)}`); + segmentInfo.abortRequests = mediaSegmentRequest({ xhr: this.vhs_.xhr, xhrOptions: this.xhrOptions_, decryptionWorker: this.decrypter_, segment: simpleSegment, handlePartialData: this.handlePartialData_, - abortFn: this.handleAbort_.bind(this), + abortFn: this.handleAbort_.bind(this, segmentInfo), progressFn: this.handleProgress_.bind(this), trackInfoFn: this.handleTrackInfo_.bind(this), timingInfoFn: this.handleTimingInfo_.bind(this), @@ -2770,7 +2773,7 @@ export default class SegmentLoader extends videojs.EventTarget { }); } - this.logger_(segmentInfoString(segmentInfo)); + this.logger_(`Appended ${segmentInfoString(segmentInfo)}`); const segmentDurationMessage = getTroublesomeSegmentDurationMessage(segmentInfo, this.sourceType_); diff --git a/test/playlist.test.js b/test/playlist.test.js index c4e9a6de4..fc8ba14aa 100644 --- a/test/playlist.test.js +++ b/test/playlist.test.js @@ -574,7 +574,7 @@ QUnit.test('safeLiveIndex accounts for liveEdgePadding in simple case', function ); assert.equal( - Playlist.safeLiveIndex(playlist, 0), 6, + Playlist.safeLiveIndex(playlist, 0), 5, 'returns 6 when liveEdgePadding is 0 and duration is 6' ); }); @@ -650,11 +650,100 @@ QUnit.test('safeLiveIndex accounts for liveEdgePadding in non-simple case', func ); assert.equal( - Playlist.safeLiveIndex(playlist, 0), 6, + Playlist.safeLiveIndex(playlist, 0), 5, 'returns 6 when liveEdgePadding is 0' ); }); +QUnit.test('getHoldBack works as expected', function(assert) { + const media = { + endList: true, + targetDuration: 5, + partTargetDuration: 1.1, + serverControl: { + holdBack: 20, + partHoldBack: 2 + }, + segments: [ + {duration: 4}, + {duration: 3}, + {duration: 4, parts: [ + {duration: 1}, + {duration: 1}, + {duration: 1}, + {duration: 0.5}, + {duration: 0.5} + ]} + ] + }; + const master = { + suggestedPresentationDelay: 10 + }; + + assert.equal( + Playlist.getHoldBack(master, media), + 0, + 'returns 0 with endlist' + ); + + delete media.endList; + assert.equal( + Playlist.getHoldBack(master, media), + master.suggestedPresentationDelay, + 'uses suggestedPresentationDelay' + ); + + delete master.suggestedPresentationDelay; + assert.equal( + Playlist.getHoldBack(master, media), + media.serverControl.partHoldBack, + 'uses part hold back' + ); + + media.serverControl.partHoldBack = null; + assert.equal( + Playlist.getHoldBack(master, media), + media.partTargetDuration * 3, + 'uses part target duration * 3' + ); + + media.partTargetDuration = null; + assert.equal( + Playlist.getHoldBack(master, media), + 2, + 'uses last three part durations' + ); + + media.segments[media.segments.length - 1].parts = null; + assert.equal( + Playlist.getHoldBack(master, media), + media.serverControl.holdBack, + 'uses hold back' + ); + + media.serverControl.holdBack = null; + assert.equal( + Playlist.getHoldBack(master, media), + (media.targetDuration * 2) + media.segments[media.segments.length - 1].duration, + 'uses (targetDuration * 2) + last segment duration' + ); + + media.targetDuration = null; + assert.equal( + Playlist.getHoldBack(master, media), + 11, + 'uses last three segment durations' + ); + + media.segments.length = 0; + + assert.equal( + Playlist.getHoldBack(master, media), + 0, + 'no possible holdback can be calculated' + ); +}); + QUnit.test( 'seekable end and playlist end account for non-zero starting VOD media sequence', function(assert) {