Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
144 changes: 118 additions & 26 deletions src/controller/gap-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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 @@ -21,6 +22,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 @@ -43,6 +45,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 @@ -62,6 +65,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 @@ -71,6 +75,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 @@ -106,13 +111,42 @@ 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) ||
!isFiniteNumber(media.currentTime) ||
media.paused ||
media.ended ||
media.seeking ||
media.playbackRate === 0 ||
media.readyState === 0
Comment thread
Jumaron marked this conversation as resolved.
Outdated
) {
return;
}

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

private onMediaPlaying = () => {
Expand Down Expand Up @@ -419,40 +453,90 @@ 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 { hls, media } = this;
if (
!this.needsPipelineFlush ||
!hls ||
!media ||
!hls.config.nudgeOnVideoHole ||
!isVideoBuffer(data.type) ||
data.parent !== PlaylistLevelType.MAIN
Comment thread
Jumaron marked this conversation as resolved.
Outdated
) {
return;
}

if (
!userAgentChromeVersion() ||
Comment thread
Jumaron marked this conversation as resolved.
Outdated
!isFiniteNumber(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 @@ -726,6 +810,14 @@ function getInFlightDependency(
return null;
}

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

function isFiniteNumber(value: number) {
return Number.isFinite(value);
}
Comment thread
Jumaron marked this conversation as resolved.
Outdated

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