Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 5 additions & 6 deletions src/controller/timeline-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 31 additions & 15 deletions src/utils/webvtt-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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)
Expand Down
198 changes: 198 additions & 0 deletions tests/unit/utils/vttparser.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
});
});
});
Loading