From d4101b8cf79118775913d7a80f3775cf1e822838 Mon Sep 17 00:00:00 2001 From: Roberto Balestrieri Date: Thu, 21 May 2026 14:42:18 +0200 Subject: [PATCH 1/2] fix: align WebVTT segments missing X-TIMESTAMP-MAP with media timestamps --- src/controller/timeline-controller.ts | 11 +- src/utils/webvtt-parser.ts | 46 ++++-- tests/unit/utils/vttparser.ts | 198 ++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 21 deletions(-) diff --git a/src/controller/timeline-controller.ts b/src/controller/timeline-controller.ts index 7f8e38240fd..1e6568a5e0f 100644 --- a/src/controller/timeline-controller.ts +++ b/src/controller/timeline-controller.ts @@ -603,18 +603,17 @@ export class TimelineController implements ComponentAPI { }); }, (error) => { - const missingInitPTS = - error.message === 'Missing initPTS for VTT MPEGTS'; + const missingInitPTS = error.message.startsWith('Missing initPTS'); + hls.logger.log( + `${missingInitPTS ? 'Deferred parsing of' : 'Cannot parse'} VTT cue (sn: ${frag.sn} @${frag.start}): ${error}`, + ); if (missingInitPTS) { unparsedVttFrags.push(data); + return; } else { this._fallbackToIMSC1(frag, payload); } // Something went wrong while parsing. Trigger event with success false. - hls.logger.log(`Failed to parse VTT cue: ${error}`); - if (missingInitPTS && maxAvCC > frag.cc) { - return; - } hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, { success: false, frag: frag, diff --git a/src/utils/webvtt-parser.ts b/src/utils/webvtt-parser.ts index 57a683ee518..76567d01bb4 100644 --- a/src/utils/webvtt-parser.ts +++ b/src/utils/webvtt-parser.ts @@ -7,6 +7,7 @@ import type { TimestampOffset } from './timescale-conversion'; import type { VTTCCs } from '../types/vtt'; const LINEBREAKS = /\r\n|\n\r|\n|\r/g; +const missingInitPTSErrorStartsWith = 'Missing initPTS for VTT '; // String.prototype.startsWith is not supported in IE11 const startsWith = function ( @@ -101,7 +102,8 @@ export function parseWebVTT( let cueTime = '00:00.000'; let timestampMapMPEGTS = 0; let timestampMapLOCAL = 0; - let parsingError: Error; + let hasTimestampMap = false; + let parsingError: Error | undefined; let inHeader = true; parser.oncue = function (cue: VTTCue) { @@ -112,22 +114,32 @@ export function parseWebVTT( // Calculate subtitle PTS offset const webVttMpegTsMapOffset = (timestampMapMPEGTS - init90kHz) / 90000; - // Update offsets for new discontinuities - if (currCC?.new) { - if (timestampMapLOCAL !== undefined) { - // When local time is provided, offset = discontinuity start time - local time - cueOffset = vttCCs.ccOffset = currCC.start; - } else { - calculateOffset(vttCCs, cc, webVttMpegTsMapOffset); + if (hasTimestampMap) { + // Update offsets for new discontinuities + if (currCC?.new) { + if (timestampMapLOCAL !== undefined) { + // When local time is provided, offset = discontinuity start time - local time + cueOffset = vttCCs.ccOffset = currCC.start; + } else { + calculateOffset(vttCCs, cc, webVttMpegTsMapOffset); + } } - } - if (webVttMpegTsMapOffset) { - if (!initPTS) { - parsingError = new Error('Missing initPTS for VTT MPEGTS'); - return; + if (webVttMpegTsMapOffset) { + if (!initPTS) { + parsingError = new Error(missingInitPTSErrorStartsWith + 'MPEGTS'); + return; + } + // If we have MPEGTS, offset = presentation time + discontinuity offset + cueOffset = webVttMpegTsMapOffset - vttCCs.presentationOffset; } - // If we have MPEGTS, offset = presentation time + discontinuity offset - cueOffset = webVttMpegTsMapOffset - vttCCs.presentationOffset; + } else if (!initPTS) { + // Without X-TIMESTAMP-MAP, cue time 0 maps to MPEGTS 0 per HLS spec + parsingError = new Error( + missingInitPTSErrorStartsWith + 'without X-TIMESTAMP-MAP', + ); + return; + } else { + cueOffset = -initPTS.baseTime / initPTS.timescale; } const duration = cue.endTime - cue.startTime; @@ -148,6 +160,9 @@ export function parseWebVTT( // If the cue was not assigned an id from the VTT file (line above the content), create one. if (!cue.id) { cue.id = generateCueId(cue.startTime, cue.endTime, text); + } else if (cc) { + // Prevent same id in cues across different discontinuities + cue.id = `hlsjscc${cc}_${cue.id}`; } if (cue.endTime > 0) { @@ -174,6 +189,7 @@ export function parseWebVTT( if (startsWith(line, 'X-TIMESTAMP-MAP=')) { // Once found, no more are allowed anyway, so stop searching. inHeader = false; + hasTimestampMap = true; // Extract LOCAL and MPEGTS. line .slice(16) diff --git a/tests/unit/utils/vttparser.ts b/tests/unit/utils/vttparser.ts index bb8638d957f..f83789936a7 100644 --- a/tests/unit/utils/vttparser.ts +++ b/tests/unit/utils/vttparser.ts @@ -1,6 +1,9 @@ import chai from 'chai'; +import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { parseTimeStamp } from '../../../src/utils/vttparser'; +import { parseWebVTT } from '../../../src/utils/webvtt-parser'; +import type { VTTCCs } from '../../../src/types/vtt'; chai.use(sinonChai); const expect = chai.expect; @@ -35,3 +38,198 @@ describe('VTTParser', function () { }); }); }); + +describe('parseWebVTT', function () { + function toArrayBuffer(str: string): ArrayBuffer { + return new TextEncoder().encode(str).buffer; + } + + describe('WebVTT with X-TIMESTAMP-MAP across discontinuities', function () { + // Subtitle playlist has 3 discontinuity sequences: + // cc=0: preroll1, 31.135s duration + // cc=1: preroll2, 31.086s duration + // cc=2: main content, starts at 62.221s on presentation timeline + const initPTS = { baseTime: 900000, timescale: 90000, trackId: 0 }; + + const preroll1Vtt = `WEBVTT +X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 + +1 +00:00:01.668 --> 00:00:03.961 +preroll1 +`; + const preroll2Vtt = `WEBVTT +X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 + +1 +00:00:03.879 --> 00:00:05.547 +preroll2 +`; + const mainContentVtt = `WEBVTT +X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 + +1 +00:00:09.426 --> 00:00:11.345 +main content +`; + + it('should map cues correctly for cc=0 (first discontinuity, preroll1)', function () { + const cc = 0; + const vttCCs: VTTCCs = { + ccOffset: 0, + presentationOffset: 0, + 0: { start: 0, prevCC: -1, new: true }, + }; + const parsedCallback = sinon.spy(); + const errorCallback = sinon.spy(); + + parseWebVTT( + toArrayBuffer(preroll1Vtt), + initPTS, + vttCCs, + cc, + 0, + parsedCallback, + errorCallback, + ); + + expect(errorCallback).to.not.have.been.called; + expect(parsedCallback).to.have.been.calledOnce; + const cues = parsedCallback.getCall(0).firstArg; + expect(cues).to.have.lengthOf(1); + expect(cues[0].startTime).to.be.closeTo(1.668, 0.001); + expect(cues[0].endTime).to.be.closeTo(3.961, 0.001); + }); + + it('should map cues correctly for cc=1 (second discontinuity, preroll2)', function () { + const cc = 1; + const fragStart = 31.135; + const vttCCs: VTTCCs = { + ccOffset: 0, + presentationOffset: 0, + 0: { start: 0, prevCC: -1, new: false }, + 1: { start: fragStart, prevCC: 0, new: true }, + }; + const parsedCallback = sinon.spy(); + const errorCallback = sinon.spy(); + + parseWebVTT( + toArrayBuffer(preroll2Vtt), + initPTS, + vttCCs, + cc, + fragStart, + parsedCallback, + errorCallback, + ); + + expect(errorCallback).to.not.have.been.called; + expect(parsedCallback).to.have.been.calledOnce; + const cues = parsedCallback.getCall(0).firstArg; + expect(cues).to.have.lengthOf(1); + // cueOffset = currCC.start = 31.135; startTime = 3.879 + 31.135 = 35.014 + expect(cues[0].startTime).to.be.closeTo(35.014, 0.001); + expect(cues[0].endTime).to.be.closeTo(36.682, 0.001); + }); + + it('should map cues correctly for cc=2 (main content after prerolls)', function () { + const cc = 2; + const fragStart = 62.221; + const vttCCs: VTTCCs = { + ccOffset: 0, + presentationOffset: 0, + 0: { start: 0, prevCC: -1, new: false }, + 1: { start: 31.135, prevCC: 0, new: false }, + 2: { start: fragStart, prevCC: 1, new: true }, + }; + const parsedCallback = sinon.spy(); + const errorCallback = sinon.spy(); + + parseWebVTT( + toArrayBuffer(mainContentVtt), + initPTS, + vttCCs, + cc, + fragStart, + parsedCallback, + errorCallback, + ); + + expect(errorCallback).to.not.have.been.called; + expect(parsedCallback).to.have.been.calledOnce; + const cues = parsedCallback.getCall(0).firstArg; + expect(cues).to.have.lengthOf(1); + // cueOffset = currCC.start = 62.221; startTime = 9.426 + 62.221 = 71.647 + expect(cues[0].startTime).to.be.closeTo(71.647, 0.001); + expect(cues[0].endTime).to.be.closeTo(73.566, 0.001); + }); + }); + + describe('WebVTT segments without X-TIMESTAMP-MAP must assume cue times map to media timestamps (#7850)', function () { + const vttContent = `WEBVTT + +1 +00:22:16.000 --> 00:22:19.000 +Hello after ad +`; + const cc = 4; + const fragStart = 1335.066; + + function makeVTTCCs(): VTTCCs { + return { + ccOffset: 0, + presentationOffset: 0, + 0: { start: 0, prevCC: -1, new: false }, + [cc]: { start: fragStart, prevCC: 3, new: true }, + }; + } + + it('should not parse WEBVTT without X-TIMESTAMP-MAP when initPTS is undefined', function () { + const parsedCallback = sinon.spy(); + const errorCallback = sinon.spy(); + + parseWebVTT( + toArrayBuffer(vttContent), + undefined, + makeVTTCCs(), + cc, + fragStart, + parsedCallback, + errorCallback, + ); + + expect(parsedCallback, 'parsing callback should be deferred').to.not.have + .been.called; + expect(errorCallback, 'error until initPTS is known').to.have.been + .calledOnce; + const error = errorCallback.getCall(0).firstArg; + expect(error) + .to.have.property('message') + .that.eqls('Missing initPTS for VTT without X-TIMESTAMP-MAP'); + }); + + it('should produce correct cue timing when initPTS is available', function () { + const initPTS = { baseTime: 10000, timescale: 1000, trackId: 0 }; + const parsedCallback = sinon.spy(); + const errorCallback = sinon.spy(); + + parseWebVTT( + toArrayBuffer(vttContent), + initPTS, + makeVTTCCs(), + cc, + fragStart, + parsedCallback, + errorCallback, + ); + + expect(errorCallback, 'parsed without error').to.not.have.been.called; + expect(parsedCallback, 'parsed cue').to.have.been.calledOnce; + const cues = parsedCallback.getCall(0).firstArg; + expect(cues).to.have.lengthOf(1); + const mediaTimestamp = initPTS.baseTime / initPTS.timescale; + expect(cues[0].startTime).to.be.closeTo(fragStart - mediaTimestamp, 1); + expect(cues[0].endTime).to.be.closeTo(fragStart + 3 - mediaTimestamp, 1); + }); + }); +}); From 50bf1e46eb5b3c3a15754d0f1c5c5a781264d240 Mon Sep 17 00:00:00 2001 From: grabofus Date: Fri, 29 May 2026 15:23:25 +0100 Subject: [PATCH 2/2] 1.6.18 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 306c471a782..699d5847e08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hls.js", - "version": "1.6.17", + "version": "1.6.18", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "hls.js", - "version": "1.6.17", + "version": "1.6.18", "license": "Apache-2.0", "devDependencies": { "@babel/core": "7.28.0", diff --git a/package.json b/package.json index 8ca6bf02b85..b9a2628ef8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hls.js", - "version": "1.6.17", + "version": "1.6.18", "license": "Apache-2.0", "description": "JavaScript HLS client using MediaSourceExtension", "homepage": "https://github.com/video-dev/hls.js",