Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,7 @@ Maximum retry threshold used for both buffer hole skipping and playhead nudging:

(default: `true`)

Whether or not HLS.js should perform a seek nudge to flush the rendering pipeline upon traversing a gap or hole in video SourceBuffer buffered time ranges. This is only performed when audio is buffered at the point where the hole is detected. For more information see `nudgeOnVideoHole` in gap-controller and issues https://issues.chromium.org/issues/40280613#comment10 and https://github.com/video-dev/hls.js/issues/5631.
Whether or not HLS.js should perform a seek nudge to flush the rendering pipeline upon traversing a gap or hole in video SourceBuffer buffered time ranges, or after video buffer replacement removes the current playhead and the replacement video is appended over it. The video-hole nudge is only performed when audio is buffered at the point where the hole is detected. For more information see `nudgeOnVideoHole` in gap-controller and issues https://issues.chromium.org/issues/40280613#comment10, https://github.com/video-dev/hls.js/issues/5631, and https://github.com/video-dev/hls.js/issues/6355.

### `skipBufferHolePadding`

Expand Down
127 changes: 101 additions & 26 deletions src/controller/gap-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
removeEventListener,
} from '../utils/event-listener-helper';
import { stringify } from '../utils/safe-json-stringify';
import { userAgentChromeVersion } from '../utils/user-agent';
import type { InFlightData } from './base-stream-controller';
import type { InFlightFragments } from '../hls';
import type Hls from '../hls';
Expand All @@ -22,6 +23,7 @@ import type { Fragment, MediaFragment, Part } from '../loader/fragment';
import type { SourceBufferName } from '../types/buffer';
import type {
BufferAppendedData,
BufferFlushedData,
MediaAttachedData,
MediaDetachingData,
} from '../types/events';
Expand All @@ -44,6 +46,7 @@ export default class GapController extends TaskLoop {
private moved: boolean = false;
private seeking: boolean = false;
private buffered: Partial<Record<SourceBufferName, TimeRanges>> = {};
private needsPipelineFlush: boolean = false;

private lastCurrentTime: number;
public ended: number = 0;
Expand All @@ -63,6 +66,7 @@ export default class GapController extends TaskLoop {
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this);
hls.on(Events.BUFFER_FLUSHED, this.onBufferFlushed, this);
}
}

Expand All @@ -72,6 +76,7 @@ export default class GapController extends TaskLoop {
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this);
hls.off(Events.BUFFER_FLUSHED, this.onBufferFlushed, this);
}
}

Expand Down Expand Up @@ -107,13 +112,37 @@ export default class GapController extends TaskLoop {
this.media = null;
}
this.mediaSource = undefined;
this.needsPipelineFlush = false;
}

private onBufferAppended(
event: Events.BUFFER_APPENDED,
data: BufferAppendedData,
) {
this.buffered = data.timeRanges;
this.nudgeAfterVideoBufferFlush(data);
}

private onBufferFlushed(
event: Events.BUFFER_FLUSHED,
data: BufferFlushedData,
) {
const { hls, media } = this;
const { type, start, end } = data;
if (
!hls ||
!media ||
!hls.config.nudgeOnVideoHole ||
!userAgentChromeVersion() ||
!isVideoBuffer(type) ||
!Number.isFinite(media.currentTime)
) {
return;
}

if (media.currentTime >= start && media.currentTime < end) {
this.needsPipelineFlush = true;
}
}

private onMediaPlaying = () => {
Expand Down Expand Up @@ -429,40 +458,82 @@ export default class GapController extends TaskLoop {
holeEnd - holeStart < 1 && // `maxBufferHole` may be too small and setting it to 0 should not disable this feature
currentTime - holeStart < 2
) {
const error = new Error(
`nudging playhead to flush pipeline after video hole. currentTime: ${currentTime} hole: ${holeStart} -> ${holeEnd} buffered index: ${bufferedIndex}`,
);
this.warn(error.message);
// Magic number to flush the pipeline without interuption to audio playback:
this.media.currentTime += 0.000001;
let frag: MediaFragment | Part | null | undefined =
appendedFragAtPosition(currentTime, this.fragmentTracker);
if (frag && 'fragment' in frag) {
frag = frag.fragment;
} else if (!frag) {
frag = undefined;
}
const bufferInfo = BufferHelper.bufferInfo(
this.media,
this.nudgeVideoPipeline(
currentTime,
0,
`nudging playhead to flush pipeline after video hole. currentTime: ${currentTime} hole: ${holeStart} -> ${holeEnd} buffered index: ${bufferedIndex}`,
appendedFragAtPosition(currentTime, this.fragmentTracker),
);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
fatal: false,
error,
reason: error.message,
frag,
buffer: bufferInfo.len,
bufferInfo,
});
}
}
}
}
}

private nudgeAfterVideoBufferFlush(data: BufferAppendedData) {
const { media } = this;
if (!this.needsPipelineFlush || !media || !isVideoBuffer(data.type)) {
return;
}

if (
!Number.isFinite(media.currentTime) ||
media.paused ||
media.ended ||
media.seeking ||
media.playbackRate === 0 ||
media.readyState === 0
) {
this.needsPipelineFlush = false;
return;
}

const currentTime = media.currentTime;
const buffered = data.timeRanges[data.type];
if (!buffered) {
return;
}
if (!BufferHelper.isBuffered({ buffered }, currentTime)) {
return;
}

this.needsPipelineFlush = false;
this.nudgeVideoPipeline(
currentTime,
`nudging playhead to render new video after buffer flush. currentTime: ${currentTime}`,
data.part || (data.frag as MediaFragment),
);
}

private nudgeVideoPipeline(
currentTime: number,
reason: string,
appended: MediaFragment | Part | null,
) {
const { hls, media } = this;
if (!hls || !media) {
return;
}
const error = new Error(reason);
this.warn(error.message);
// Magic number to flush the pipeline without interruption to audio playback:
media.currentTime += 0.000001;
let frag: MediaFragment | undefined;
if (appended) {
frag = 'fragment' in appended ? appended.fragment : appended;
}
const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
fatal: false,
error,
reason: error.message,
frag,
buffer: bufferInfo.len,
bufferInfo,
});
}

/**
* Detects and attempts to fix known buffer stalling issues.
* @param bufferInfo - The properties of the current buffer.
Expand Down Expand Up @@ -736,6 +807,10 @@ function getInFlightDependency(
return null;
}

function isVideoBuffer(type: SourceBufferName) {
return type === 'video' || type === 'audiovideo';
}

function inFlight(inFlightData: InFlightData | undefined): Fragment | null {
if (!inFlightData) {
return null;
Expand Down
109 changes: 108 additions & 1 deletion tests/unit/controller/gap-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import GapController from '../../../src/controller/gap-controller';
import { ErrorDetails, ErrorTypes } from '../../../src/errors';
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
import { Fragment } from '../../../src/loader/fragment';
import { PlaylistLevelType } from '../../../src/types/loader';
import { ChunkMetadata } from '../../../src/types/transmuxer';
import {
BufferHelper,
type BufferInfo,
} from '../../../src/utils/buffer-helper';
import { MockMediaElement, MockMediaSource } from '../../mocks/mock-media';
import { TimeRangesMock } from '../../mocks/time-ranges.mock';
import type { HlsConfig } from '../../../src/config';
import type StreamController from '../../../src/controller/stream-controller';
import type { Fragment, MediaFragment } from '../../../src/loader/fragment';
import type { MediaFragment } from '../../../src/loader/fragment';

use(sinonChai);

Expand Down Expand Up @@ -122,6 +126,109 @@ describe('GapController', function () {
});
});

describe('video pipeline nudge after buffer flush', function () {
let frag: MediaFragment;

function makeFragment(): MediaFragment {
const fragment = new Fragment(
PlaylistLevelType.MAIN,
'video.ts',
) as MediaFragment;
fragment.sn = 1;
fragment.level = 0;
fragment.cc = 0;
fragment.duration = 10;
fragment.setStart(10);
return fragment;
}

function appendVideo(timeRanges: TimeRanges) {
hls.trigger(Events.BUFFER_APPENDED, {
type: 'video',
frag,
part: null,
chunkMeta: new ChunkMetadata(0, 1, 1),
parent: PlaylistLevelType.MAIN,
timeRanges: { video: timeRanges },
});
}

beforeEach(function () {
frag = makeFragment();
Object.assign(media, {
buffered: new TimeRangesMock([10, 20]),
ended: false,
paused: false,
playbackRate: 1,
readyState: 4,
seeking: false,
});
media.currentTime = 12;
triggerSpy.resetHistory();
});

it('nudges when main video is appended over the playhead after a video buffer flush', function () {
hls.trigger(Events.BUFFER_FLUSHED, {
type: 'video',
start: 0,
end: Infinity,
});

triggerSpy.resetHistory();
appendVideo(new TimeRangesMock([10, 20]) as unknown as TimeRanges);

expect(media.currentTime).to.be.closeTo(12.000001, 0.0000001);

const calls = triggerSpy.getCalls();
let errorCall;
for (let i = 0; i < calls.length; i++) {
if (calls[i].args[0] === Events.ERROR) {
errorCall = calls[i];
break;
}
}
expect(errorCall).to.exist;
const errorData = errorCall!.args[1];
expect(errorData).to.include({
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
fatal: false,
frag,
});
expect(errorData.error.message).to.contain(
'render new video after buffer flush',
);
});

it('does not nudge until the appended video buffers the playhead', function () {
hls.trigger(Events.BUFFER_FLUSHED, {
type: 'video',
start: 0,
end: Infinity,
});

triggerSpy.resetHistory();
appendVideo(new TimeRangesMock([13, 20]) as unknown as TimeRanges);

expect(media.currentTime).to.equal(12);
expect(triggerSpy).to.not.have.been.calledWith(Events.ERROR);
});

it('does not nudge when the flush does not remove the playhead', function () {
hls.trigger(Events.BUFFER_FLUSHED, {
type: 'video',
start: 13,
end: Infinity,
});

triggerSpy.resetHistory();
appendVideo(new TimeRangesMock([10, 20]) as unknown as TimeRanges);

expect(media.currentTime).to.equal(12);
expect(triggerSpy).to.not.have.been.calledWith(Events.ERROR);
});
});

describe('_reportStall', function () {
it('should report a stall with the current buffer length if it has not already been reported', function () {
const bufferInfo = BufferHelper.bufferedInfo(
Expand Down
Loading