Browse Source

use last segment duration + 2*targetDuration for safe live point instead of 3 segments (#1271)

* do not let back buffer trimming remove within target duration of current time
* increase threshold for stuck playlist checking
pull/6/head
Matthew Neil 8 years ago
committed by GitHub
parent
commit
4a642a6378
  1. 10
      src/master-playlist-controller.js
  2. 11
      src/playback-watcher.js
  3. 35
      src/playlist-loader.js
  4. 75
      src/playlist.js
  5. 7
      src/ranges.js
  6. 48
      src/segment-loader.js
  7. 41
      test/playlist-loader.test.js
  8. 108
      test/playlist.test.js
  9. 24
      test/segment-loader.test.js
  10. 1
      test/videojs-contrib-hls.test.js

10
src/master-playlist-controller.js

@ -820,14 +820,14 @@ export class MasterPlaylistController extends videojs.EventTarget {
if (!buffered.length) {
// return true if the playhead reached the absolute end of the playlist
return absolutePlaylistEnd - currentTime <= Ranges.TIME_FUDGE_FACTOR;
return absolutePlaylistEnd - currentTime <= Ranges.SAFE_TIME_DELTA;
}
let bufferedEnd = buffered.end(buffered.length - 1);
// return true if there is too little buffer left and
// buffer has reached absolute end of playlist
return bufferedEnd - currentTime <= Ranges.TIME_FUDGE_FACTOR &&
absolutePlaylistEnd - bufferedEnd <= Ranges.TIME_FUDGE_FACTOR;
// return true if there is too little buffer left and buffer has reached absolute
// end of playlist
return bufferedEnd - currentTime <= Ranges.SAFE_TIME_DELTA &&
absolutePlaylistEnd - bufferedEnd <= Ranges.SAFE_TIME_DELTA;
}
/**

11
src/playback-watcher.js

@ -107,7 +107,8 @@ export default class PlaybackWatcher {
let buffered = this.tech_.buffered();
if (this.lastRecordedTime === currentTime &&
(!buffered.length || currentTime + 0.1 >= buffered.end(buffered.length - 1))) {
(!buffered.length ||
currentTime + Ranges.SAFE_TIME_DELTA >= buffered.end(buffered.length - 1))) {
// If current time is at the end of the final buffered region, then any playback
// stall is most likely caused by buffering in a low bandwidth environment. The tech
// should fire a `waiting` event in this scenario, but due to browser and tech
@ -171,7 +172,7 @@ export default class PlaybackWatcher {
// sync to the beginning of the live window
// provide a buffer of .1 seconds to handle rounding/imprecise numbers
seekTo = seekableStart + 0.1;
seekTo = seekableStart + Ranges.SAFE_TIME_DELTA;
}
if (typeof seekTo !== 'undefined') {
@ -298,8 +299,7 @@ export default class PlaybackWatcher {
return false;
}
// provide a buffer of .1 seconds to handle rounding/imprecise numbers
if (currentTime > seekable.end(seekable.length - 1) + 0.1) {
if (currentTime > seekable.end(seekable.length - 1) + Ranges.SAFE_TIME_DELTA) {
return true;
}
@ -310,8 +310,7 @@ export default class PlaybackWatcher {
if (seekable.length &&
// can't fall before 0 and 0 seekable start identifies VOD stream
seekable.start(0) > 0 &&
// provide a buffer of .1 seconds to handle rounding/imprecise numbers
currentTime < seekable.start(0) - 0.1) {
currentTime < seekable.start(0) - Ranges.SAFE_TIME_DELTA) {
return true;
}

35
src/playlist-loader.js

@ -141,6 +141,30 @@ export const resolveMediaGroupUris = (master) => {
});
};
/**
* Calculates the time to wait before refreshing a live playlist
*
* @param {Object} media
* The current media
* @param {Boolean} update
* True if there were any updates from the last refresh, false otherwise
* @return {Number}
* The time in ms to wait before refreshing the live playlist
*/
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;
}
return delay;
};
/**
* Load a playlist from a remote location
*
@ -230,16 +254,13 @@ export default class PlaylistLoader extends EventTarget {
// merge this playlist into the master
const update = updateMaster(this.master, parser.manifest);
let refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
this.targetDuration = parser.manifest.targetDuration;
if (update) {
this.master = update;
this.media_ = this.master.playlists[parser.manifest.uri];
} else {
// if the playlist is unchanged since the last reload,
// try again after half the target duration
refreshDelay /= 2;
this.trigger('playlistunchanged');
}
@ -248,7 +269,7 @@ export default class PlaylistLoader extends EventTarget {
window.clearTimeout(this.mediaUpdateTimeout);
this.mediaUpdateTimeout = window.setTimeout(() => {
this.trigger('mediaupdatetimeout');
}, refreshDelay);
}, refreshDelay(this.media(), !!update));
}
this.trigger('loadedplaylist');
@ -450,9 +471,9 @@ export default class PlaylistLoader extends EventTarget {
const media = this.media();
if (isFinalRendition) {
const refreshDelay = media ? (media.targetDuration / 2) * 1000 : 5 * 1000;
const delay = media ? (media.targetDuration / 2) * 1000 : 5 * 1000;
this.mediaUpdateTimeout = window.setTimeout(() => this.load(), refreshDelay);
this.mediaUpdateTimeout = window.setTimeout(() => this.load(), delay);
return;
}

75
src/playlist.js

@ -6,16 +6,6 @@
import {createTimeRange} from 'video.js';
import window from 'global/window';
let Playlist = {
/**
* The number of segments that are unsafe to start playback at in
* a live stream. Changing this value can cause playback stalls.
* See HTTP Live Streaming, "Playing the Media Playlist File"
* https://tools.ietf.org/html/draft-pantos-http-live-streaming-18#section-6.3.3
*/
UNSAFE_LIVE_SEGMENTS: 3
};
/**
* walk backward until we find a duration we can use
* or return a failure
@ -219,6 +209,38 @@ export const sumDurations = function(playlist, startIndex, endIndex) {
return durations;
};
/**
* Determines the media index of the segment corresponding to the safe edge of the live
* window which is the duration of the last segment plus 2 target durations from the end
* of the playlist.
*
* @param {Object} playlist
* a media playlist object
* @return {Number}
* The media index of the segment at the safe live point. 0 if there is no "safe"
* point.
* @function safeLiveIndex
*/
export const safeLiveIndex = function(playlist) {
if (!playlist.segments.length) {
return 0;
}
let i = playlist.segments.length - 1;
let distanceFromEnd = playlist.segments[i].duration || playlist.targetDuration;
const safeDistance = distanceFromEnd + playlist.targetDuration * 2;
while (i--) {
distanceFromEnd += playlist.segments[i].duration;
if (distanceFromEnd >= safeDistance) {
break;
}
}
return Math.max(0, i);
};
/**
* Calculates the playlist end time
*
@ -246,9 +268,7 @@ export const playlistEnd = function(playlist, expired, useSafeLiveEnd) {
expired = expired || 0;
let endSequence = useSafeLiveEnd ?
Math.max(0, playlist.segments.length - Playlist.UNSAFE_LIVE_SEGMENTS) :
Math.max(0, playlist.segments.length);
const endSequence = useSafeLiveEnd ? safeLiveIndex(playlist) : playlist.segments.length;
return intervalDuration(playlist,
playlist.mediaSequence + endSequence,
@ -506,18 +526,19 @@ export const estimateSegmentRequestTime = function(segmentDuration,
return (size - (bytesReceived * 8)) / bandwidth;
};
Playlist.duration = duration;
Playlist.seekable = seekable;
Playlist.getMediaInfoForTime = getMediaInfoForTime;
Playlist.isEnabled = isEnabled;
Playlist.isDisabled = isDisabled;
Playlist.isBlacklisted = isBlacklisted;
Playlist.isIncompatible = isIncompatible;
Playlist.playlistEnd = playlistEnd;
Playlist.isAes = isAes;
Playlist.isFmp4 = isFmp4;
Playlist.hasAttribute = hasAttribute;
Playlist.estimateSegmentRequestTime = estimateSegmentRequestTime;
// exports
export default Playlist;
export default {
duration,
seekable,
safeLiveIndex,
getMediaInfoForTime,
isEnabled,
isDisabled,
isBlacklisted,
isIncompatible,
playlistEnd,
isAes,
isFmp4,
hasAttribute,
estimateSegmentRequestTime
};

7
src/ranges.js

@ -9,6 +9,12 @@ import videojs from 'video.js';
// Fudge factor to account for TimeRanges rounding
const TIME_FUDGE_FACTOR = 1 / 30;
// Comparisons between time values such as current time and the end of the buffered range
// can be misleading because of precision differences or when the current media has poorly
// aligned audio and video, which can cause values to be slightly off from what you would
// expect. This value is what we consider to be safe to use in such comparisons to account
// for these scenarios.
const SAFE_TIME_DELTA = TIME_FUDGE_FACTOR * 3;
/**
* Clamps a value to within a range
@ -365,6 +371,7 @@ export default {
findSoleUncommonTimeRangesEnd,
getSegmentBufferedPercent,
TIME_FUDGE_FACTOR,
SAFE_TIME_DELTA,
printableRange,
timeUntilRebuffer
};

48
src/segment-loader.js

@ -73,6 +73,38 @@ export const illegalMediaSwitch = (loaderType, startingMedia, newSegmentMedia) =
return null;
};
/**
* Calculates a time value that is safe to remove from the back buffer without interupting
* playback.
*
* @param {TimeRange} seekable
* The current seekable range
* @param {Number} currentTime
* The current time of the player
* @param {Number} targetDuration
* The target duration of the current playlist
* @return {Number}
* Time that is safe to remove from the back buffer without interupting playback
*/
export const safeBackBufferTrimTime = (seekable, currentTime, targetDuration) => {
let removeToTime;
if (seekable.length &&
seekable.start(0) > 0 &&
seekable.start(0) < currentTime) {
// If we have a seekable range use that as the limit for what can be removed safely
removeToTime = seekable.start(0);
} else {
// otherwise remove anything older than 30 seconds before the current play head
removeToTime = currentTime - 30;
}
// Don't allow removing from the buffer within target duration of current time
// to avoid the possibility of removing the GOP currently being played which could
// cause playback stalls.
return Math.min(removeToTime, currentTime - targetDuration);
};
/**
* An object that manages segment loading and appending.
*
@ -906,9 +938,9 @@ export default class SegmentLoader extends videojs.EventTarget {
* @param {Object} segmentInfo - the current segment
*/
trimBackBuffer_(segmentInfo) {
const seekable = this.seekable_();
const currentTime = this.currentTime_();
let removeToTime = 0;
const removeToTime = safeBackBufferTrimTime(this.seekable_(),
this.currentTime_(),
this.playlist_.targetDuration || 10);
// Chrome has a hard limit of 150MB of
// buffer and a very conservative "garbage collector"
@ -916,16 +948,6 @@ export default class SegmentLoader extends videojs.EventTarget {
// we don't trigger the QuotaExceeded error
// on the source buffer during subsequent appends
// If we have a seekable range use that as the limit for what can be removed safely
// otherwise remove anything older than 30 seconds before the current play head
if (seekable.length &&
seekable.start(0) > 0 &&
seekable.start(0) < currentTime) {
removeToTime = seekable.start(0);
} else {
removeToTime = currentTime - 30;
}
if (removeToTime > 0) {
this.remove(0, removeToTime);
}

41
test/playlist-loader.test.js

@ -4,7 +4,8 @@ import {
updateSegments,
updateMaster,
setupMediaPlaylists,
resolveMediaGroupUris
resolveMediaGroupUris,
refreshDelay
} from '../src/playlist-loader';
import xhrFactory from '../src/xhr';
import { useFakeEnvironment } from './test-helpers';
@ -773,6 +774,23 @@ QUnit.test('resolveMediaGroupUris resolves media group URIs', function(assert) {
}, 'resolved URIs of certain media groups');
});
QUnit.test('uses last segment duration for refresh delay', function(assert) {
const media = { targetDuration: 7, segments: [] };
assert.equal(refreshDelay(media, true), 3500,
'used half targetDuration when no segments');
media.segments = [ { duration: 6}, { duration: 4 }, { } ];
assert.equal(refreshDelay(media, true), 3500,
'used half targetDuration when last segment duration cannot be determined');
media.segments = [ { duration: 6}, { duration: 4}, { duration: 5 } ];
assert.equal(refreshDelay(media, true), 5000, 'used last segment duration for delay');
assert.equal(refreshDelay(media, false), 3500,
'used half targetDuration when update is false');
});
QUnit.test('throws if the playlist url is empty or undefined', function(assert) {
assert.throws(function() {
PlaylistLoader();
@ -1180,6 +1198,27 @@ QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function(as
assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
QUnit.test('refreshes the playlist after last segment duration', function(assert) {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
let refreshes = 0;
loader.on('mediaupdatetimeout', () => refreshes++);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-TARGETDURATION:10\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:4\n' +
'1.ts\n');
// 4s, last segment duration
this.clock.tick(4 * 1000);
assert.equal(refreshes, 1, 'refreshed playlist after last segment duration');
});
QUnit.test('emits an error when an initial playlist request fails', function(assert) {
let errors = [];
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

108
test/playlist.test.js

@ -356,7 +356,7 @@ function(assert) {
QUnit.test('seekable end and playlist end account for non-standard target durations',
function(assert) {
let playlist = {
const playlist = {
targetDuration: 2,
mediaSequence: 0,
syncInfo: {
@ -385,11 +385,113 @@ function(assert) {
assert.equal(seekable.start(0), 0, 'starts at the earliest available segment');
assert.equal(seekable.end(0),
9 - (2 + 2 + 1),
'allows seeking no further than three segments from the end');
// Playlist duration is 9s. Target duration 2s. Seekable end should be at
// least 6s from end. Adding segment durations starting from the end to get
// that 6s target
9 - (2 + 2 + 1 + 2),
'allows seeking no further than the start of the segment 2 target' +
'durations back from the beginning of the last segment');
assert.equal(playlistEnd, 9, 'playlist end at the last segment');
});
QUnit.test('safeLiveIndex is correct for standard segment durations', function(assert) {
const playlist = {
targetDuration: 6,
mediaSequence: 10,
syncInfo: {
time: 0,
mediaSequence: 10
},
segments: [
{
duration: 6
},
{
duration: 6
},
{
duration: 6
},
{
duration: 6
},
{
duration: 6
},
{
duration: 6
}
]
};
assert.equal(Playlist.safeLiveIndex(playlist), 3,
'correct media index for standard durations');
});
QUnit.test('safeLiveIndex is correct for variable segment durations', function(assert) {
const playlist = {
targetDuration: 6,
mediaSequence: 10,
syncInfo: {
time: 0,
mediaSequence: 10
},
segments: [
{
duration: 6
},
{
duration: 4
},
{
duration: 5
},
{
// this segment is 16 seconds from the end of playlist, the safe live point
duration: 6
},
{
duration: 3
},
{
duration: 4
},
{
duration: 3
}
]
};
// safe live point is no less than 15 seconds (3s + 2 * 6s) from the end of the playlist
assert.equal(Playlist.safeLiveIndex(playlist), 3,
'correct media index for variable segment durations');
});
QUnit.test('safeLiveIndex is 0 when no safe live point', function(assert) {
const playlist = {
targetDuration: 6,
mediaSequence: 10,
syncInfo: {
time: 0,
mediaSequence: 10
},
segments: [
{
duration: 6
},
{
duration: 3
},
{
duration: 3
}
]
};
assert.equal(Playlist.safeLiveIndex(playlist), 0,
'returns media index 0 when playlist has no safe live point');
});
QUnit.test(
'seekable end and playlist end account for non-zero starting VOD media sequence',
function(assert) {

24
test/segment-loader.test.js

@ -1,7 +1,8 @@
import QUnit from 'qunit';
import {
default as SegmentLoader,
illegalMediaSwitch
illegalMediaSwitch,
safeBackBufferTrimTime
} from '../src/segment-loader';
import videojs from 'video.js';
import mp4probe from 'mux.js/lib/mp4/probe';
@ -107,6 +108,27 @@ QUnit.test('illegalMediaSwitch detects illegal media switches', function(assert)
'error when video only to audio only');
});
QUnit.test('safeBackBufferTrimTime determines correct safe removeToTime',
function(assert) {
let seekable = videojs.createTimeRanges([[75, 120]]);
let targetDuration = 10;
let currentTime = 70;
assert.equal(safeBackBufferTrimTime(seekable, currentTime, targetDuration), 40,
'uses 30s before current time if currentTime is before seekable start');
currentTime = 110;
assert.equal(safeBackBufferTrimTime(seekable, currentTime, targetDuration), 75,
'uses seekable start if currentTime is after seekable start');
currentTime = 80;
assert.equal(safeBackBufferTrimTime(seekable, currentTime, targetDuration), 70,
'uses target duration before currentTime if currentTime is after seekable but' +
'within target duration');
});
QUnit.module('SegmentLoader', function(hooks) {
hooks.beforeEach(LoaderCommonHooks.beforeEach);
hooks.afterEach(LoaderCommonHooks.afterEach);

1
test/videojs-contrib-hls.test.js

@ -451,6 +451,7 @@ QUnit.test('translates seekable by the starting time for live playlists', functi
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:15\n' +
'#EXT-X-TARGETDURATION:10\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +

Loading…
Cancel
Save