diff --git a/README.md b/README.md index 6b2e3af01..e42829a9f 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ Video.js Compatibility: 6.0, 7.0 - [cacheEncryptionKeys](#cacheencryptionkeys) - [handlePartialData](#handlepartialdata) - [liveRangeSafeTimeDelta](#liverangesafetimedelta) + - [captionServices](#captionservices) + - [Format](#format) + - [Example](#example) - [Runtime Properties](#runtime-properties) - [vhs.playlists.master](#vhsplaylistsmaster) - [vhs.playlists.media](#vhsplaylistsmedia) @@ -471,6 +474,47 @@ This option defaults to `false`. * Default: [`SAFE_TIME_DELTA`](https://github.com/videojs/http-streaming/blob/e7cb63af010779108336eddb5c8fd138d6390e95/src/ranges.js#L17) * Allow to re-define length (in seconds) of time delta when you compare current time and the end of the buffered range. +##### captionServices +* Type: `object` +* Default: undefined +* Provide extra information, like a label or a language, for instream (608 and 708) captions. + +The captionServices options object has properties that map to the caption services. Each property is an object itself that includes several properties, like a label or language. + +For 608 captions, the service names are `CC1`, `CC2`, `CC3`, and `CC4`. For 708 captions, the service names are `SERVICEn` where `n` is a digit between `1` and `63`. +###### Format +```js +{ + vhs: { + captionServices: { + [serviceName]: { + language: String, // optional + label: String, // optional + default: boolean // optional + } + } + } +} +``` +###### Example +```js +{ + vhs: { + captionServices: { + CC1: { + language: 'en', + label: 'English' + }, + SERVICE1: { + langauge: 'kr', + label: 'Korean', + default: true + } + } + } +} +``` + ### Runtime Properties Runtime properties are attached to the tech object when HLS is in use. You can get a reference to the VHS source handler like this: diff --git a/package-lock.json b/package-lock.json index 2e8cb3bad..2b431bc43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1340,6 +1340,17 @@ "@videojs/vhs-utils": "^3.0.0", "global": "^4.4.0" } + }, + "mpd-parser": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.16.0.tgz", + "integrity": "sha512-/pOFsDbOxXFAla47rYMdIypBZVtsQ9q3OHNuKtW2CJMaCGtNDtUcLS+B2TToYmB20rgi3XIgkyc2EsIvIAS4NA==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.0", + "global": "^4.4.0", + "xmldom": "^0.5.0" + } } } }, @@ -6502,12 +6513,12 @@ "dev": true }, "mpd-parser": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.16.0.tgz", - "integrity": "sha512-/pOFsDbOxXFAla47rYMdIypBZVtsQ9q3OHNuKtW2CJMaCGtNDtUcLS+B2TToYmB20rgi3XIgkyc2EsIvIAS4NA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.17.0.tgz", + "integrity": "sha512-oKS5G0jCcHHJ3sHYlcLeM9Xcbuixl08eAx7QW0Th7ChlZiI0YvLtGaHE/L0aKUBJFNvtkeksIr8XgJgSBBsS4g==", "requires": { "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^3.0.0", + "@videojs/vhs-utils": "^3.0.2", "global": "^4.4.0", "xmldom": "^0.5.0" } diff --git a/package.json b/package.json index fbf964546..dcff33fc1 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "aes-decrypter": "3.1.2", "global": "^4.4.0", "m3u8-parser": "4.7.0", - "mpd-parser": "0.16.0", + "mpd-parser": "0.17.0", "mux.js": "5.11.0", "video.js": "^6 || ^7" }, diff --git a/src/media-groups.js b/src/media-groups.js index 1db9e8567..c88fdf44e 100644 --- a/src/media-groups.js +++ b/src/media-groups.js @@ -638,23 +638,39 @@ export const initialize = { for (const variantLabel in mediaGroups[type][groupId]) { const properties = mediaGroups[type][groupId][variantLabel]; - // We only support CEA608 captions for now, so ignore anything that - // doesn't use a CCx INSTREAM-ID - if (!properties.instreamId.match(/CC\d/)) { + // Look for either 608 (CCn) or 708 (SERVICEn) caption services + if (!/^(?:CC|SERVICE)/.test(properties.instreamId)) { continue; } + const captionServices = tech.options_.vhs && tech.options_.vhs.captionServices || {}; + + let newProps = { + label: variantLabel, + language: properties.language, + instreamId: properties.instreamId, + default: properties.default && properties.autoselect + }; + + if (captionServices[newProps.instreamId]) { + newProps = videojs.mergeOptions(newProps, captionServices[newProps.instreamId]); + } + + if (newProps.default === undefined) { + delete newProps.default; + } + // No PlaylistLoader is required for Closed-Captions because the captions are // embedded within the video stream groups[groupId].push(videojs.mergeOptions({ id: variantLabel }, properties)); if (typeof tracks[variantLabel] === 'undefined') { const track = tech.addRemoteTextTrack({ - id: properties.instreamId, + id: newProps.instreamId, kind: 'captions', - default: properties.default && properties.autoselect, - language: properties.language, - label: variantLabel + default: newProps.default, + language: newProps.language, + label: newProps.label }, false).track; tracks[variantLabel] = track; diff --git a/src/util/text-tracks.js b/src/util/text-tracks.js index f130e7b1a..cc95a3101 100644 --- a/src/util/text-tracks.js +++ b/src/util/text-tracks.js @@ -16,7 +16,15 @@ export const createCaptionsTrackIfNotExists = function(inbandTextTracks, tech, c if (!inbandTextTracks[captionStream]) { tech.trigger({type: 'usage', name: 'vhs-608'}); tech.trigger({type: 'usage', name: 'hls-608'}); - const track = tech.textTracks().getTrackById(captionStream); + + let instreamId = captionStream; + + // we need to translate SERVICEn for 708 to how mux.js currently labels them + if (/^cc708_/.test(captionStream)) { + instreamId = 'SERVICE' + captionStream.split('_')[1]; + } + + const track = tech.textTracks().getTrackById(instreamId); if (track) { // Resuse an existing track with a CC# id because this was @@ -24,12 +32,29 @@ export const createCaptionsTrackIfNotExists = function(inbandTextTracks, tech, c // in the m3u8 for us to use inbandTextTracks[captionStream] = track; } else { + // This section gets called when we have caption services that aren't specified in the manifest. + // Manifest level caption services are handled in media-groups.js under CLOSED-CAPTIONS. + const captionServices = tech.options_.vhs && tech.options_.vhs.captionServices || {}; + let label = captionStream; + let language = captionStream; + let def = false; + const captionService = captionServices[instreamId]; + + if (captionService) { + label = captionService.label; + language = captionService.language; + def = captionService.default; + } + // Otherwise, create a track with the default `CC#` label and // without a language inbandTextTracks[captionStream] = tech.addRemoteTextTrack({ kind: 'captions', - id: captionStream, - label: captionStream + id: instreamId, + // TODO: investigate why this doesn't seem to turn the caption on by default + default: def, + label, + language }, false).track; } } diff --git a/test/loader-common.js b/test/loader-common.js index b31f2ffa6..d7e8deb51 100644 --- a/test/loader-common.js +++ b/test/loader-common.js @@ -45,6 +45,7 @@ export const LoaderCommonHooks = { this.fakeVhs = { xhr: xhrFactory(), tech_: { + options_: {}, paused: () => this.paused, playbackRate: () => this.playbackRate, currentTime: () => this.currentTime, diff --git a/test/master-playlist-controller.test.js b/test/master-playlist-controller.test.js index 3e4387ffc..5fe28396c 100644 --- a/test/master-playlist-controller.test.js +++ b/test/master-playlist-controller.test.js @@ -3104,7 +3104,7 @@ QUnit.test('parses codec from muxed fmp4 init segment', function(assert) { }); QUnit.test( - 'adds only CEA608 closed-caption tracks when a master playlist is loaded', + 'adds CEA608 closed-caption tracks when a master playlist is loaded', function(assert) { this.requests.length = 0; this.player.dispose(); @@ -3143,15 +3143,77 @@ QUnit.test( .map(cap => Object.assign({name: cap.id}, cap)); assert.equal(capsArr.length, 4, '4 closed-caption tracks defined in playlist'); - assert.equal(addedCaps.length, 2, '2 CEA608 tracks added internally'); + assert.equal(addedCaps.length, 4, '4 tracks, 2 608 and 2 708 tracks, added internally'); assert.equal(addedCaps[0].instreamId, 'CC1', 'first 608 track is CC1'); - assert.equal(addedCaps[1].instreamId, 'CC3', 'second 608 track is CC3'); + assert.equal(addedCaps[2].instreamId, 'CC3', 'second 608 track is CC3'); + + const textTracks = this.player.textTracks(); + + assert.equal( + textTracks[1].id, addedCaps[0].instreamId, + 'text track 1\'s id is CC\'s instreamId' + ); + assert.equal( + textTracks[2].id, addedCaps[1].instreamId, + 'text track 2\'s id is CC\'s instreamId' + ); + assert.equal( + textTracks[1].label, addedCaps[0].name, + 'text track 1\'s label is CC\'s name' + ); + assert.equal( + textTracks[2].label, addedCaps[1].name, + 'text track 2\'s label is CC\'s name' + ); + } +); + +QUnit.test( + 'adds CEA708 closed-caption tracks when a master playlist is loaded', + function(assert) { + this.requests.length = 0; + this.player.dispose(); + this.player = createPlayer(); + this.player.src({ + src: 'manifest/master-captions.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + // wait for async player.src to complete + this.clock.tick(1); + + const masterPlaylistController = this.player.tech_.vhs.masterPlaylistController_; + + assert.equal(this.player.textTracks().length, 1, 'one text track to start'); + assert.equal( + this.player.textTracks()[0].label, + 'segment-metadata', + 'only segment-metadata text track' + ); + + // master, contains media groups for captions + this.standardXHRResponse(this.requests.shift()); + + // we wait for loadedmetadata before setting caption tracks, so we need to wait for a + // media playlist + assert.equal(this.player.textTracks().length, 1, 'only one text track after master'); + + // media + this.standardXHRResponse(this.requests.shift()); + + const master = masterPlaylistController.masterPlaylistLoader_.master; + const caps = master.mediaGroups['CLOSED-CAPTIONS'].CCs; + const capsArr = Object.keys(caps).map(key => Object.assign({name: key}, caps[key])); + const addedCaps = masterPlaylistController.mediaTypes_['CLOSED-CAPTIONS'].groups.CCs + .map(cap => Object.assign({name: cap.id}, cap)); + + assert.equal(capsArr.length, 4, '4 closed-caption tracks defined in playlist'); + assert.equal(addedCaps.length, 4, '4 tracks, 2 608 and 2 708 tracks, added internally'); + assert.equal(addedCaps[1].instreamId, 'SERVICE1', 'first 708 track is SERVICE1'); + assert.equal(addedCaps[3].instreamId, 'SERVICE3', 'second 708 track is SERVICE3'); const textTracks = this.player.textTracks(); - assert.equal(textTracks.length, 3, '2 text tracks were added'); - assert.equal(textTracks[1].mode, 'disabled', 'track starts disabled'); - assert.equal(textTracks[2].mode, 'disabled', 'track starts disabled'); assert.equal( textTracks[1].id, addedCaps[0].instreamId, 'text track 1\'s id is CC\'s instreamId' diff --git a/test/media-groups.test.js b/test/media-groups.test.js index d8b851a4c..bacbfd1a2 100644 --- a/test/media-groups.test.js +++ b/test/media-groups.test.js @@ -1078,6 +1078,7 @@ QUnit.module('MediaGroups', function() { masterPlaylistLoader: {master: this.master}, vhs: {}, tech: { + options_: {}, addRemoteTextTrack(track) { return { track }; } @@ -1258,7 +1259,18 @@ QUnit.module('MediaGroups', function() { en608: { language: 'en', default: true, autoselect: true, instreamId: 'CC1' }, en708: { language: 'en', instreamId: 'SERVICE1' }, fr608: { language: 'fr', instreamId: 'CC3' }, - fr708: { language: 'fr', instreamId: 'SERVICE3' } + fr708: { language: 'fr', instreamId: 'SERVICE3' }, + kr708: { language: 'kor', instreamId: 'SERVICE4' } + }; + + // verify that captionServices option can modify properties + this.settings.tech.options_.vhs = { + captionServices: { + SERVICE4: { + label: 'Korean', + default: true + } + } }; MediaGroups.initialize[type](type, this.settings); @@ -1268,13 +1280,23 @@ QUnit.module('MediaGroups', function() { { CCs: [ { id: 'en608', default: true, autoselect: true, language: 'en', instreamId: 'CC1' }, - { id: 'fr608', language: 'fr', instreamId: 'CC3' } + { id: 'en708', language: 'en', instreamId: 'SERVICE1' }, + { id: 'fr608', language: 'fr', instreamId: 'CC3' }, + { id: 'fr708', language: 'fr', instreamId: 'SERVICE3' }, + { id: 'kr708', language: 'kor', instreamId: 'SERVICE4' } ] }, 'creates group properties' ); assert.ok(this.mediaTypes[type].tracks.en608, 'created text track'); assert.ok(this.mediaTypes[type].tracks.fr608, 'created text track'); assert.equal(this.mediaTypes[type].tracks.en608.default, true, 'en608 track auto selected'); + assert.deepEqual(this.mediaTypes[type].tracks.kr708, { + id: 'SERVICE4', + kind: 'captions', + language: 'kor', + label: 'Korean', + default: true + }, 'kr708 fields are overriden by the options'); } ); diff --git a/test/text-tracks.test.js b/test/text-tracks.test.js index 97ab0cbc2..c39894a0d 100644 --- a/test/text-tracks.test.js +++ b/test/text-tracks.test.js @@ -11,7 +11,8 @@ import { const { module, test } = Qunit; class MockTextTrack { - constructor() { + constructor(opts = {}) { + Object.keys(opts).forEach((opt) => (this[opt] = opts[opt])); this.cues = []; } addCue(cue) { @@ -26,15 +27,16 @@ class MockTextTrack { class MockTech { constructor() { + this.options_ = {}; this.tracks = { getTrackById(id) { return this[id]; } }; } - addRemoteTextTrack({kind, id, label}) { - this.tracks[id] = new MockTextTrack(); - return { track: this.tracks[id] }; + addRemoteTextTrack(opts) { + this.tracks[opts.id] = new MockTextTrack(opts); + return { track: this.tracks[opts.id] }; } trigger() {} textTracks() { @@ -63,6 +65,49 @@ test('creates a track if it does not exist yet', function(assert) { assert.ok(inbandTracks.CC1, 'CC1 track was added'); }); +test('creates a 708 track if it does not exist yet', function(assert) { + const inbandTracks = {}; + const tech = new MockTech(); + + createCaptionsTrackIfNotExists(inbandTracks, tech, 'cc708_1'); + assert.ok(inbandTracks.cc708_1, 'cc708_1 track was added'); +}); + +test('maps mux.js 708 track name to HLS and DASH service name', function(assert) { + const inbandTracks = {}; + const tech = new MockTech(); + + createCaptionsTrackIfNotExists(inbandTracks, tech, 'cc708_1'); + assert.ok(inbandTracks.cc708_1, 'cc708_1 track was added'); + assert.equal(inbandTracks.cc708_1.id, 'SERVICE1', 'SERVICE1 created from cc708_1'); + createCaptionsTrackIfNotExists(inbandTracks, tech, 'cc708_3'); + assert.ok(inbandTracks.cc708_3, 'cc708_3 track was added'); + assert.equal(inbandTracks.cc708_3.id, 'SERVICE3', 'SERVICE3 created from cc708_3'); +}); + +test('can override caption services settings', function(assert) { + const inbandTracks = {}; + const tech = new MockTech(); + + tech.options_ = { + vhs: { + captionServices: { + SERVICE1: { + label: 'hello' + }, + CC1: { + label: 'goodbye' + } + } + } + }; + + createCaptionsTrackIfNotExists(inbandTracks, tech, 'cc708_1'); + assert.equal(inbandTracks.cc708_1.label, 'hello', 'we set a custom label for SERVICE1'); + createCaptionsTrackIfNotExists(inbandTracks, tech, 'CC1'); + assert.equal(inbandTracks.CC1.label, 'goodbye', 'we set a custom label for CC1'); +}); + test('fills inbandTextTracks if a track already exists', function(assert) { const inbandTracks = {}; const tech = new MockTech();