Skip to content

Commit 39782c6

Browse files
authored
feat: Caption services (608/708) metadata (#1138)
Add an option for caption services metadata in case the user wants to specify labels for 608/708 captions, override the ones provided in the manifest, or needs to add more information like character encoding (this isn't currently available but will be added some time in the future). For HLS, an EXT-X-MEDIA tag can be specified with an INSTREAM-ID attribute. We already support this. https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-08#section-4.4.6.1 This PR updated mpd-parser which uses the ANSI 214 supplemental spec section 7.2 to parse out the same information from MPD files. videojs/mpd-parser#131. Adds a property called captionServices which has properties of the caption service IDs like CC1 or SERVICE1 and allows a user to specify a language and label.
1 parent 44905d4 commit 39782c6

File tree

9 files changed

+253
-27
lines changed

9 files changed

+253
-27
lines changed

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ Video.js Compatibility: 6.0, 7.0
5858
- [cacheEncryptionKeys](#cacheencryptionkeys)
5959
- [handlePartialData](#handlepartialdata)
6060
- [liveRangeSafeTimeDelta](#liverangesafetimedelta)
61+
- [captionServices](#captionservices)
62+
- [Format](#format)
63+
- [Example](#example)
6164
- [Runtime Properties](#runtime-properties)
6265
- [vhs.playlists.master](#vhsplaylistsmaster)
6366
- [vhs.playlists.media](#vhsplaylistsmedia)
@@ -471,6 +474,47 @@ This option defaults to `false`.
471474
* Default: [`SAFE_TIME_DELTA`](https://github.com/videojs/http-streaming/blob/e7cb63af010779108336eddb5c8fd138d6390e95/src/ranges.js#L17)
472475
* Allow to re-define length (in seconds) of time delta when you compare current time and the end of the buffered range.
473476

477+
##### captionServices
478+
* Type: `object`
479+
* Default: undefined
480+
* Provide extra information, like a label or a language, for instream (608 and 708) captions.
481+
482+
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.
483+
484+
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`.
485+
###### Format
486+
```js
487+
{
488+
vhs: {
489+
captionServices: {
490+
[serviceName]: {
491+
language: String, // optional
492+
label: String, // optional
493+
default: boolean // optional
494+
}
495+
}
496+
}
497+
}
498+
```
499+
###### Example
500+
```js
501+
{
502+
vhs: {
503+
captionServices: {
504+
CC1: {
505+
language: 'en',
506+
label: 'English'
507+
},
508+
SERVICE1: {
509+
langauge: 'kr',
510+
label: 'Korean',
511+
default: true
512+
}
513+
}
514+
}
515+
}
516+
```
517+
474518
### Runtime Properties
475519
Runtime properties are attached to the tech object when HLS is in
476520
use. You can get a reference to the VHS source handler like this:

package-lock.json

Lines changed: 15 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"aes-decrypter": "3.1.2",
6262
"global": "^4.4.0",
6363
"m3u8-parser": "4.7.0",
64-
"mpd-parser": "0.16.0",
64+
"mpd-parser": "0.17.0",
6565
"mux.js": "5.11.0",
6666
"video.js": "^6 || ^7"
6767
},

src/media-groups.js

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -638,23 +638,39 @@ export const initialize = {
638638
for (const variantLabel in mediaGroups[type][groupId]) {
639639
const properties = mediaGroups[type][groupId][variantLabel];
640640

641-
// We only support CEA608 captions for now, so ignore anything that
642-
// doesn't use a CCx INSTREAM-ID
643-
if (!properties.instreamId.match(/CC\d/)) {
641+
// Look for either 608 (CCn) or 708 (SERVICEn) caption services
642+
if (!/^(?:CC|SERVICE)/.test(properties.instreamId)) {
644643
continue;
645644
}
646645

646+
const captionServices = tech.options_.vhs && tech.options_.vhs.captionServices || {};
647+
648+
let newProps = {
649+
label: variantLabel,
650+
language: properties.language,
651+
instreamId: properties.instreamId,
652+
default: properties.default && properties.autoselect
653+
};
654+
655+
if (captionServices[newProps.instreamId]) {
656+
newProps = videojs.mergeOptions(newProps, captionServices[newProps.instreamId]);
657+
}
658+
659+
if (newProps.default === undefined) {
660+
delete newProps.default;
661+
}
662+
647663
// No PlaylistLoader is required for Closed-Captions because the captions are
648664
// embedded within the video stream
649665
groups[groupId].push(videojs.mergeOptions({ id: variantLabel }, properties));
650666

651667
if (typeof tracks[variantLabel] === 'undefined') {
652668
const track = tech.addRemoteTextTrack({
653-
id: properties.instreamId,
669+
id: newProps.instreamId,
654670
kind: 'captions',
655-
default: properties.default && properties.autoselect,
656-
language: properties.language,
657-
label: variantLabel
671+
default: newProps.default,
672+
language: newProps.language,
673+
label: newProps.label
658674
}, false).track;
659675

660676
tracks[variantLabel] = track;

src/util/text-tracks.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,45 @@ export const createCaptionsTrackIfNotExists = function(inbandTextTracks, tech, c
1616
if (!inbandTextTracks[captionStream]) {
1717
tech.trigger({type: 'usage', name: 'vhs-608'});
1818
tech.trigger({type: 'usage', name: 'hls-608'});
19-
const track = tech.textTracks().getTrackById(captionStream);
19+
20+
let instreamId = captionStream;
21+
22+
// we need to translate SERVICEn for 708 to how mux.js currently labels them
23+
if (/^cc708_/.test(captionStream)) {
24+
instreamId = 'SERVICE' + captionStream.split('_')[1];
25+
}
26+
27+
const track = tech.textTracks().getTrackById(instreamId);
2028

2129
if (track) {
2230
// Resuse an existing track with a CC# id because this was
2331
// very likely created by videojs-contrib-hls from information
2432
// in the m3u8 for us to use
2533
inbandTextTracks[captionStream] = track;
2634
} else {
35+
// This section gets called when we have caption services that aren't specified in the manifest.
36+
// Manifest level caption services are handled in media-groups.js under CLOSED-CAPTIONS.
37+
const captionServices = tech.options_.vhs && tech.options_.vhs.captionServices || {};
38+
let label = captionStream;
39+
let language = captionStream;
40+
let def = false;
41+
const captionService = captionServices[instreamId];
42+
43+
if (captionService) {
44+
label = captionService.label;
45+
language = captionService.language;
46+
def = captionService.default;
47+
}
48+
2749
// Otherwise, create a track with the default `CC#` label and
2850
// without a language
2951
inbandTextTracks[captionStream] = tech.addRemoteTextTrack({
3052
kind: 'captions',
31-
id: captionStream,
32-
label: captionStream
53+
id: instreamId,
54+
// TODO: investigate why this doesn't seem to turn the caption on by default
55+
default: def,
56+
label,
57+
language
3358
}, false).track;
3459
}
3560
}

test/loader-common.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const LoaderCommonHooks = {
4545
this.fakeVhs = {
4646
xhr: xhrFactory(),
4747
tech_: {
48+
options_: {},
4849
paused: () => this.paused,
4950
playbackRate: () => this.playbackRate,
5051
currentTime: () => this.currentTime,

test/master-playlist-controller.test.js

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3104,7 +3104,7 @@ QUnit.test('parses codec from muxed fmp4 init segment', function(assert) {
31043104
});
31053105

31063106
QUnit.test(
3107-
'adds only CEA608 closed-caption tracks when a master playlist is loaded',
3107+
'adds CEA608 closed-caption tracks when a master playlist is loaded',
31083108
function(assert) {
31093109
this.requests.length = 0;
31103110
this.player.dispose();
@@ -3143,15 +3143,77 @@ QUnit.test(
31433143
.map(cap => Object.assign({name: cap.id}, cap));
31443144

31453145
assert.equal(capsArr.length, 4, '4 closed-caption tracks defined in playlist');
3146-
assert.equal(addedCaps.length, 2, '2 CEA608 tracks added internally');
3146+
assert.equal(addedCaps.length, 4, '4 tracks, 2 608 and 2 708 tracks, added internally');
31473147
assert.equal(addedCaps[0].instreamId, 'CC1', 'first 608 track is CC1');
3148-
assert.equal(addedCaps[1].instreamId, 'CC3', 'second 608 track is CC3');
3148+
assert.equal(addedCaps[2].instreamId, 'CC3', 'second 608 track is CC3');
3149+
3150+
const textTracks = this.player.textTracks();
3151+
3152+
assert.equal(
3153+
textTracks[1].id, addedCaps[0].instreamId,
3154+
'text track 1\'s id is CC\'s instreamId'
3155+
);
3156+
assert.equal(
3157+
textTracks[2].id, addedCaps[1].instreamId,
3158+
'text track 2\'s id is CC\'s instreamId'
3159+
);
3160+
assert.equal(
3161+
textTracks[1].label, addedCaps[0].name,
3162+
'text track 1\'s label is CC\'s name'
3163+
);
3164+
assert.equal(
3165+
textTracks[2].label, addedCaps[1].name,
3166+
'text track 2\'s label is CC\'s name'
3167+
);
3168+
}
3169+
);
3170+
3171+
QUnit.test(
3172+
'adds CEA708 closed-caption tracks when a master playlist is loaded',
3173+
function(assert) {
3174+
this.requests.length = 0;
3175+
this.player.dispose();
3176+
this.player = createPlayer();
3177+
this.player.src({
3178+
src: 'manifest/master-captions.m3u8',
3179+
type: 'application/vnd.apple.mpegurl'
3180+
});
3181+
3182+
// wait for async player.src to complete
3183+
this.clock.tick(1);
3184+
3185+
const masterPlaylistController = this.player.tech_.vhs.masterPlaylistController_;
3186+
3187+
assert.equal(this.player.textTracks().length, 1, 'one text track to start');
3188+
assert.equal(
3189+
this.player.textTracks()[0].label,
3190+
'segment-metadata',
3191+
'only segment-metadata text track'
3192+
);
3193+
3194+
// master, contains media groups for captions
3195+
this.standardXHRResponse(this.requests.shift());
3196+
3197+
// we wait for loadedmetadata before setting caption tracks, so we need to wait for a
3198+
// media playlist
3199+
assert.equal(this.player.textTracks().length, 1, 'only one text track after master');
3200+
3201+
// media
3202+
this.standardXHRResponse(this.requests.shift());
3203+
3204+
const master = masterPlaylistController.masterPlaylistLoader_.master;
3205+
const caps = master.mediaGroups['CLOSED-CAPTIONS'].CCs;
3206+
const capsArr = Object.keys(caps).map(key => Object.assign({name: key}, caps[key]));
3207+
const addedCaps = masterPlaylistController.mediaTypes_['CLOSED-CAPTIONS'].groups.CCs
3208+
.map(cap => Object.assign({name: cap.id}, cap));
3209+
3210+
assert.equal(capsArr.length, 4, '4 closed-caption tracks defined in playlist');
3211+
assert.equal(addedCaps.length, 4, '4 tracks, 2 608 and 2 708 tracks, added internally');
3212+
assert.equal(addedCaps[1].instreamId, 'SERVICE1', 'first 708 track is SERVICE1');
3213+
assert.equal(addedCaps[3].instreamId, 'SERVICE3', 'second 708 track is SERVICE3');
31493214

31503215
const textTracks = this.player.textTracks();
31513216

3152-
assert.equal(textTracks.length, 3, '2 text tracks were added');
3153-
assert.equal(textTracks[1].mode, 'disabled', 'track starts disabled');
3154-
assert.equal(textTracks[2].mode, 'disabled', 'track starts disabled');
31553217
assert.equal(
31563218
textTracks[1].id, addedCaps[0].instreamId,
31573219
'text track 1\'s id is CC\'s instreamId'

test/media-groups.test.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,7 @@ QUnit.module('MediaGroups', function() {
10781078
masterPlaylistLoader: {master: this.master},
10791079
vhs: {},
10801080
tech: {
1081+
options_: {},
10811082
addRemoteTextTrack(track) {
10821083
return { track };
10831084
}
@@ -1258,7 +1259,18 @@ QUnit.module('MediaGroups', function() {
12581259
en608: { language: 'en', default: true, autoselect: true, instreamId: 'CC1' },
12591260
en708: { language: 'en', instreamId: 'SERVICE1' },
12601261
fr608: { language: 'fr', instreamId: 'CC3' },
1261-
fr708: { language: 'fr', instreamId: 'SERVICE3' }
1262+
fr708: { language: 'fr', instreamId: 'SERVICE3' },
1263+
kr708: { language: 'kor', instreamId: 'SERVICE4' }
1264+
};
1265+
1266+
// verify that captionServices option can modify properties
1267+
this.settings.tech.options_.vhs = {
1268+
captionServices: {
1269+
SERVICE4: {
1270+
label: 'Korean',
1271+
default: true
1272+
}
1273+
}
12621274
};
12631275

12641276
MediaGroups.initialize[type](type, this.settings);
@@ -1268,13 +1280,23 @@ QUnit.module('MediaGroups', function() {
12681280
{
12691281
CCs: [
12701282
{ id: 'en608', default: true, autoselect: true, language: 'en', instreamId: 'CC1' },
1271-
{ id: 'fr608', language: 'fr', instreamId: 'CC3' }
1283+
{ id: 'en708', language: 'en', instreamId: 'SERVICE1' },
1284+
{ id: 'fr608', language: 'fr', instreamId: 'CC3' },
1285+
{ id: 'fr708', language: 'fr', instreamId: 'SERVICE3' },
1286+
{ id: 'kr708', language: 'kor', instreamId: 'SERVICE4' }
12721287
]
12731288
}, 'creates group properties'
12741289
);
12751290
assert.ok(this.mediaTypes[type].tracks.en608, 'created text track');
12761291
assert.ok(this.mediaTypes[type].tracks.fr608, 'created text track');
12771292
assert.equal(this.mediaTypes[type].tracks.en608.default, true, 'en608 track auto selected');
1293+
assert.deepEqual(this.mediaTypes[type].tracks.kr708, {
1294+
id: 'SERVICE4',
1295+
kind: 'captions',
1296+
language: 'kor',
1297+
label: 'Korean',
1298+
default: true
1299+
}, 'kr708 fields are overriden by the options');
12781300
}
12791301
);
12801302

0 commit comments

Comments
 (0)