From 89382876624f5fc39e76d3a55b9d3b22c2982988 Mon Sep 17 00:00:00 2001 From: Jumaron Date: Sat, 27 Jun 2026 10:17:32 +0200 Subject: [PATCH 1/2] fix: nudge video pipeline after currentLevel flush --- docs/API.md | 2 +- src/controller/gap-controller.ts | 144 +++++++++++++++++++----- tests/unit/controller/gap-controller.ts | 109 +++++++++++++++++- 3 files changed, 227 insertions(+), 28 deletions(-) diff --git a/docs/API.md b/docs/API.md index be92360d095..4abfef43052 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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` diff --git a/src/controller/gap-controller.ts b/src/controller/gap-controller.ts index 90960aacf4c..d97dc4f1b17 100644 --- a/src/controller/gap-controller.ts +++ b/src/controller/gap-controller.ts @@ -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'; @@ -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'; @@ -43,6 +45,7 @@ export default class GapController extends TaskLoop { private moved: boolean = false; private seeking: boolean = false; private buffered: Partial> = {}; + private needsPipelineFlush: boolean = false; private lastCurrentTime: number; public ended: number = 0; @@ -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); } } @@ -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); } } @@ -106,6 +111,7 @@ export default class GapController extends TaskLoop { this.media = null; } this.mediaSource = undefined; + this.needsPipelineFlush = false; } private onBufferAppended( @@ -113,6 +119,34 @@ export default class GapController extends TaskLoop { 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 + ) { + return; + } + + if (media.currentTime >= start && media.currentTime < end) { + this.needsPipelineFlush = true; + } } private onMediaPlaying = () => { @@ -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 + ) { + return; + } + + if ( + !userAgentChromeVersion() || + !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. @@ -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); +} + function inFlight(inFlightData: InFlightData | undefined): Fragment | null { if (!inFlightData) { return null; diff --git a/tests/unit/controller/gap-controller.ts b/tests/unit/controller/gap-controller.ts index daf95afa6ed..bb42a3ecbda 100644 --- a/tests/unit/controller/gap-controller.ts +++ b/tests/unit/controller/gap-controller.ts @@ -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); @@ -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( From 3c7134fbcfb11d1e3861d8db0a0bedab6aa29b2b Mon Sep 17 00:00:00 2001 From: Jumaron Date: Mon, 29 Jun 2026 15:35:44 +0200 Subject: [PATCH 2/2] Cleanup --- src/controller/gap-controller.ts | 29 ++++++----------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/src/controller/gap-controller.ts b/src/controller/gap-controller.ts index d97dc4f1b17..ea2a4d3b4bf 100644 --- a/src/controller/gap-controller.ts +++ b/src/controller/gap-controller.ts @@ -134,12 +134,7 @@ export default class GapController extends TaskLoop { !hls.config.nudgeOnVideoHole || !userAgentChromeVersion() || !isVideoBuffer(type) || - !isFiniteNumber(media.currentTime) || - media.paused || - media.ended || - media.seeking || - media.playbackRate === 0 || - media.readyState === 0 + !Number.isFinite(media.currentTime) ) { return; } @@ -465,21 +460,13 @@ export default class GapController extends TaskLoop { } private nudgeAfterVideoBufferFlush(data: BufferAppendedData) { - const { hls, media } = this; - if ( - !this.needsPipelineFlush || - !hls || - !media || - !hls.config.nudgeOnVideoHole || - !isVideoBuffer(data.type) || - data.parent !== PlaylistLevelType.MAIN - ) { + const { media } = this; + if (!this.needsPipelineFlush || !media || !isVideoBuffer(data.type)) { return; } if ( - !userAgentChromeVersion() || - !isFiniteNumber(media.currentTime) || + !Number.isFinite(media.currentTime) || media.paused || media.ended || media.seeking || @@ -810,12 +797,8 @@ function getInFlightDependency( return null; } -function isVideoBuffer(type: SourceBufferName | null) { - return type === null || type === 'video' || type === 'audiovideo'; -} - -function isFiniteNumber(value: number) { - return Number.isFinite(value); +function isVideoBuffer(type: SourceBufferName) { + return type === 'video' || type === 'audiovideo'; } function inFlight(inFlightData: InFlightData | undefined): Fragment | null {