Skip to content

Commit

Permalink
Bugfix/1220 cleanup live back buffer (video-dev#1845)
Browse files Browse the repository at this point in the history
Implement live back buffer eviction
  • Loading branch information
vitalibozhko authored and johnBartos committed Oct 26, 2018
1 parent 0aa5a71 commit 3e6651d
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 28 deletions.
7 changes: 7 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
- [`liveSyncDuration`](#livesyncduration)
- [`liveMaxLatencyDuration`](#livemaxlatencyduration)
- [`liveDurationInfinity`](#livedurationinfinity)
- [`liveBackBufferLength`](#livebackbufferlength)
- [`enableWorker`](#enableworker)
- [`enableSoftwareAES`](#enablesoftwareaes)
- [`startLevel`](#startlevel)
Expand Down Expand Up @@ -540,6 +541,12 @@ Override current Media Source duration to `Infinity` for a live broadcast.
Useful, if you are building a player which relies on native UI capabilities in modern browsers.
If you want to have a native Live UI in environments like iOS Safari, Safari, Android Google Chrome, etc. set this value to `true`.

### `liveBackBufferLength`

(default: `Infinity`)

Sets the maximum length of the buffer, in seconds, to keep durin a live stream. Any video buffered past this time will be evicted. `Infinity` means no restriction on back buffer length; `0` keeps the minimum amount. The minimum amount is equal to the target duration of a segment to ensure that current playback is not interrupted.

### `enableWorker`

(default: `true`)
Expand Down
1 change: 1 addition & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export var hlsDefaultConfig = {
liveSyncDuration: undefined, // used by stream-controller
liveMaxLatencyDuration: undefined, // used by stream-controller
liveDurationInfinity: false, // used by buffer-controller
liveBackBufferLength: Infinity, // used by buffer-controller
maxMaxBufferLength: 600, // used by stream-controller
enableWorker: true, // used by demuxer
enableSoftwareAES: true, // used by decrypter
Expand Down
124 changes: 96 additions & 28 deletions src/controller/buffer-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ class BufferController extends EventHandler {
this._msDuration = null;
// the value that we want to set mediaSource.duration to
this._levelDuration = null;
/**
* the target duration of the current playlist
*
* @default 10
*/
this._levelTargetDuration = 10;
// current stream state: true - for live broadcast, false - for VoD content
this._live = null;
// cache the self generated object url to detect hijack of video tag
Expand Down Expand Up @@ -201,7 +207,7 @@ class BufferController extends EventHandler {
// update timestampOffset
if (this.audioTimestampOffset) {
let audioBuffer = this.sourceBuffer.audio;
logger.warn('change mpeg audio timestamp offset from ' + audioBuffer.timestampOffset + ' to ' + this.audioTimestampOffset);
logger.warn(`change mpeg audio timestamp offset from ${audioBuffer.timestampOffset} to ${this.audioTimestampOffset}`);
audioBuffer.timestampOffset = this.audioTimestampOffset;
delete this.audioTimestampOffset;
}
Expand Down Expand Up @@ -233,6 +239,11 @@ class BufferController extends EventHandler {
}

this.updateMediaElementDuration();

// appending goes first
if (pending === 0) {
this.clearLiveBackBuffer();
}
}

onSBUpdateError (event) {
Expand Down Expand Up @@ -369,9 +380,44 @@ class BufferController extends EventHandler {
this.doFlush();
}

clearLiveBackBuffer () {
// clear back buffer for live only
if (!this._live) {
return;
}

const { liveBackBufferLength } = this.hls.config;

if (isFinite(liveBackBufferLength) === false || liveBackBufferLength < 0) {
return;
}

try {
const currentTime = this.media.currentTime;
const sourceBuffer = this.sourceBuffer;
const bufferTypes = Object.keys(sourceBuffer);
const targetBackBufferPosition = currentTime - Math.max(liveBackBufferLength, this._levelTargetDuration);

for (let index = bufferTypes.length - 1; index >= 0; index--) {
const bufferType = bufferTypes[index], buffered = sourceBuffer[bufferType].buffered;

// when target buffer start exceeds actual buffer start
if (buffered.length > 0 && targetBackBufferPosition > buffered.start(0)) {
// remove buffer up until current time minus minimum back buffer length (removing buffer too close to current
// time will lead to playback freezing)
// credits for level target duration - https://github.com/videojs/http-streaming/blob/3132933b6aa99ddefab29c10447624efd6fd6e52/src/segment-loader.js#L91
this.removeBufferRange(bufferType, sourceBuffer[bufferType], 0, targetBackBufferPosition);
}
}
} catch (error) {
logger.warn('clearLiveBackBuffer failed', error);
}
}

onLevelUpdated ({ details }) {
if (details.fragments.length > 0) {
this._levelDuration = details.totalduration + details.fragments[0].start;
this._levelTargetDuration = details.targetDuration || 10;
this._live = details.live;
this.updateMediaElementDuration();
}
Expand Down Expand Up @@ -537,7 +583,7 @@ class BufferController extends EventHandler {
as sourceBuffer.remove() is asynchronous, flushBuffer will be retriggered on sourceBuffer update end
*/
flushBuffer (startOffset, endOffset, typeIn) {
let sb, i, bufStart, bufEnd, flushStart, flushEnd, sourceBuffer = this.sourceBuffer;
let sb, sourceBuffer = this.sourceBuffer;
if (Object.keys(sourceBuffer).length) {
logger.log(`flushBuffer,pos/start/end: ${this.media.currentTime.toFixed(3)}/${startOffset}/${endOffset}`);
// safeguard to avoid infinite looping : don't try to flush more than the nb of appended segments
Expand All @@ -553,32 +599,9 @@ class BufferController extends EventHandler {
// we are going to flush buffer, mark source buffer as 'not ended'
sb.ended = false;
if (!sb.updating) {
try {
for (i = 0; i < sb.buffered.length; i++) {
bufStart = sb.buffered.start(i);
bufEnd = sb.buffered.end(i);
// workaround firefox not able to properly flush multiple buffered range.
if (navigator.userAgent.toLowerCase().indexOf('firefox') !== -1 && endOffset === Number.POSITIVE_INFINITY) {
flushStart = startOffset;
flushEnd = endOffset;
} else {
flushStart = Math.max(bufStart, startOffset);
flushEnd = Math.min(bufEnd, endOffset);
}
/* sometimes sourcebuffer.remove() does not flush
the exact expected time range.
to avoid rounding issues/infinite loop,
only flush buffer range of length greater than 500ms.
*/
if (Math.min(flushEnd, bufEnd) - flushStart > 0.5) {
this.flushBufferCounter++;
logger.log(`flush ${type} [${flushStart},${flushEnd}], of [${bufStart},${bufEnd}], pos:${this.media.currentTime}`);
sb.remove(flushStart, flushEnd);
return false;
}
}
} catch (e) {
logger.warn('exception while accessing sourcebuffer, it might have been removed from MediaSource');
if (this.removeBufferRange(type, sb, startOffset, endOffset)) {
this.flushBufferCounter++;
return false;
}
} else {
// logger.log('abort ' + type + ' append in progress');
Expand All @@ -596,6 +619,51 @@ class BufferController extends EventHandler {
// everything flushed !
return true;
}

/**
* Removes first buffered range from provided source buffer that lies within given start and end offsets.
*
* @param type Type of the source buffer, logging purposes only.
* @param sb Target SourceBuffer instance.
* @param startOffset
* @param endOffset
*
* @returns {boolean} True when source buffer remove requested.
*/
removeBufferRange (type, sb, startOffset, endOffset) {
try {
let i, length, bufStart, bufEnd, removeStart, removeEnd;

for (i = 0, length = sb.buffered.length; i < length; i++) {
bufStart = sb.buffered.start(i);
bufEnd = sb.buffered.end(i);

// workaround firefox not able to properly flush multiple buffered range.
if (navigator.userAgent.toLowerCase().indexOf('firefox') !== -1 && endOffset === Number.POSITIVE_INFINITY) {
removeStart = startOffset;
removeEnd = endOffset;
} else {
removeStart = Math.max(bufStart, startOffset);
removeEnd = Math.min(bufEnd, endOffset);
}

/* sometimes sourcebuffer.remove() does not flush
the exact expected time range.
to avoid rounding issues/infinite loop,
only flush buffer range of length greater than 500ms.
*/
if (Math.min(removeEnd, bufEnd) - removeStart > 0.5) {
logger.log(`sb remove ${type} [${removeStart},${removeEnd}], of [${bufStart},${bufEnd}], pos:${this.media.currentTime}`);
sb.remove(removeStart, removeEnd);
return true;
}
}
} catch (error) {
logger.warn('removeBufferRange failed', error);
}

return false;
}
}

export default BufferController;
80 changes: 80 additions & 0 deletions tests/unit/controller/buffer-controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import assert from 'assert';
import { stub } from 'sinon';
import Hls from '../../../src/hls';
import BufferController from '../../../src/controller/buffer-controller';

describe('BufferController tests', function () {
let hls;
let bufferController;

beforeEach(function () {
hls = new Hls({});

bufferController = new BufferController(hls);
});

describe('Live back buffer enforcement', () => {
it('should trigger clean back buffer when there are no pending appends', () => {
bufferController.parent = {};
bufferController.segments = [{ parent: bufferController.parent }];

let clearStub = stub(bufferController, 'clearLiveBackBuffer');
stub(bufferController, 'doAppending');

bufferController.onSBUpdateEnd();

assert(clearStub.notCalled, 'clear live back buffer was called');

bufferController.segments = [];
bufferController.onSBUpdateEnd();

assert(clearStub.calledOnce, 'clear live back buffer was not called once');
});

it('should trigger buffer removal with valid range for live', () => {
bufferController.media = { currentTime: 360 };
hls.config.liveBackBufferLength = 60;
bufferController.sourceBuffer = {
video: {
buffered: {
start: stub().withArgs(0).returns(120),
length: 1
}
}
};

let removeBufferRangeStub = stub(bufferController, 'removeBufferRange');

bufferController._live = false;
bufferController.clearLiveBackBuffer();
assert(removeBufferRangeStub.notCalled, 'remove buffer range was called for non-live');

bufferController._live = true;
bufferController.clearLiveBackBuffer();
assert(removeBufferRangeStub.calledOnce, 'remove buffer range was not called once');

assert(
removeBufferRangeStub.calledWith(
'video',
bufferController.sourceBuffer.video,
0,
bufferController.media.currentTime - hls.config.liveBackBufferLength
),
'remove buffer range was not requested with valid data from liveBackBufferLength'
);

hls.config.liveBackBufferLength = 0;
bufferController._levelTargetDuration = 10;
bufferController.clearLiveBackBuffer();
assert(
removeBufferRangeStub.calledWith(
'video',
bufferController.sourceBuffer.video,
0,
bufferController.media.currentTime - bufferController._levelTargetDuration
),
'remove buffer range was not requested with valid data from _levelTargetDuration'
);
});
});
});

0 comments on commit 3e6651d

Please sign in to comment.