Align WebVTT segments missing X-TIMESTAMP-MAP header with media timestamps (#7852)

* Require WebVTT subtitles missing X-TIMESTAMP-MAP header to align with media timestamps
Fixes #7850

* Improve VTT parsing error and deferral logging
This commit is contained in:
Rob Walch
2026-05-20 07:07:06 -07:00
committed by GitHub
parent 959235ce9e
commit 12366c816f
4 changed files with 262 additions and 20 deletions
+6 -2
View File
@@ -176,8 +176,12 @@ class SubtitleTrackController extends BasePlaylistController {
if (this.hls.config.renderTextTracksNatively) {
this.tracksInGroup.forEach((track) => {
if (track.trackNode) {
track.trackNode.remove();
const trackNode = track.trackNode;
if (trackNode) {
// Chrome displays cached track state after re-adding tracks.
// Hide track to avoid re-rendering captions from this session.
trackNode.track.mode = 'hidden';
trackNode.remove();
track.trackNode = undefined;
}
});
+8 -7
View File
@@ -459,7 +459,9 @@ export class TimelineController implements ComponentAPI {
});
},
(error) => {
hls.logger.log(`Failed to parse IMSC1: ${error}`);
hls.logger.log(
`Cannot parse IMSC1 (sn: ${frag.sn} @${frag.start}): ${error}`,
);
hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: false,
frag,
@@ -502,18 +504,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 if (this.config.enableIMSC1) {
this._fallbackToIMSC1(data);
}
// 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,
+27 -11
View File
@@ -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 ';
const cueString2millis = function (timeString: string) {
let ts = parseInt(timeString.slice(-3));
@@ -66,6 +67,7 @@ export function parseWebVTT(
let cueTime = '00:00.000';
let timestampMapMPEGTS = 0;
let timestampMapLOCAL = 0;
let hasTimestampMap = false;
let parsingError: Error | undefined;
let inHeader = true;
@@ -77,18 +79,28 @@ export function parseWebVTT(
// Calculate subtitle PTS offset
const webVttMpegTsMapOffset = (timestampMapMPEGTS - init90kHz) / 90000;
// Update offsets for new discontinuities
if (currCC?.new) {
// When local time is provided, offset = discontinuity start time - local time
cueOffset = vttCCs.ccOffset = currCC.start;
}
if (webVttMpegTsMapOffset) {
if (!initPTS) {
parsingError = new Error('Missing initPTS for VTT MPEGTS');
return;
if (hasTimestampMap) {
// Update offsets for new discontinuities
if (currCC?.new) {
// When local time is provided, offset = discontinuity start time - local time
cueOffset = vttCCs.ccOffset = currCC.start;
}
// If we have MPEGTS, offset = presentation time + discontinuity offset
cueOffset = webVttMpegTsMapOffset - vttCCs.presentationOffset;
if (webVttMpegTsMapOffset) {
if (!initPTS) {
parsingError = new Error(missingInitPTSErrorStartsWith + 'MPEGTS');
return;
}
// 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;
@@ -109,6 +121,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 accross different discontinuities
cue.id = `hlsjscc${cc}_${cue.id}`;
}
if (cue.endTime > 0) {
@@ -135,6 +150,7 @@ export function parseWebVTT(
if (line.startsWith('X-TIMESTAMP-MAP=')) {
// Once found, no more are allowed anyway, so stop searching.
inHeader = false;
hasTimestampMap = true;
// Extract LOCAL and MPEGTS.
line
.slice(16)
+221
View File
@@ -1,6 +1,9 @@
import { expect, use } 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';
use(sinonChai);
@@ -34,3 +37,221 @@ 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
// X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000
// initPTS = 900000 for all discontinuities (PTS resets at each disc)
const initPTS = { baseTime: 900000, timescale: 90000, trackId: 0 };
// cc=0: preroll1, starts at presentation time 0
const preroll1Vtt = `WEBVTT
X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000
1
00:00:01.668 --> 00:00:03.961
preroll1
`;
// cc=1: preroll2, starts at presentation time 31.135
const preroll2Vtt = `WEBVTT
X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000
1
00:00:03.879 --> 00:00:05.547
preroll2
`;
// cc=2: main content, starts at presentation time 62.221
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 fragStart = 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,
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);
// Cue at LOCAL 1.668s, MPEGTS:900000 maps to initPTS 900000
// webVttMpegTsMapOffset = (900000 - 900000) / 90000 = 0
// cueOffset = 0, startTime = 1.668
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);
// Cue at LOCAL 3.879s should appear at presentation time 31.135 + 3.879 = 35.014
// webVttMpegTsMapOffset = (900000 - 900000) / 90000 = 0
// cueOffset = currCC.start = 31.135 (from currCC.new)
// 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);
// Cue at LOCAL 9.426s should appear at presentation time 62.221 + 9.426 = 71.647
// webVttMpegTsMapOffset = (900000 - 900000) / 90000 = 0
// cueOffset = currCC.start = 62.221 (from currCC.new)
// 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
`;
// 00:22:16 = 1336 seconds (presentation time of the cue)
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 vttCCs = makeVTTCCs();
const parsedCallback = sinon.spy();
const errorCallback = sinon.spy();
parseWebVTT(
toArrayBuffer(vttContent),
undefined, // initPTS not yet available
vttCCs,
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, 'parsing `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 vttCCs = makeVTTCCs();
const initPTS = { baseTime: 10000, timescale: 1000, trackId: 0 };
const parsedCallback = sinon.spy();
const errorCallback = sinon.spy();
parseWebVTT(
toArrayBuffer(vttContent),
initPTS,
vttCCs,
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);
// Cue presentation time close to fragStart
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);
});
});
});