Skip to content

Commit

Permalink
feat: ttml support (#319)
Browse files Browse the repository at this point in the history
Using `html5.dash.useTTML`, display TTML captions in our own TextTrackDisplay akin to Video.js's display. This also allows TTML captions to be styled via the caption settings dialog.
  • Loading branch information
squarebracket authored Sep 17, 2021
1 parent f2f8423 commit 3859998
Show file tree
Hide file tree
Showing 5 changed files with 267 additions and 0 deletions.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Drop by our slack channel (#playback) on the [Video.js slack](http://slack.video
- [Getting Started](#getting-started)
- [Protected Content](#protected-content)
- [Captions](#captions)
- [Using TTML Captions](#using-ttml-captions)
- [Multi-Language Labels](#multi-language-labels)
- [Passing options to Dash.js](#passing-options-to-dashjs)
- [Deprecation Warning](#deprecation-warning)
Expand Down Expand Up @@ -111,6 +112,22 @@ videojs('example-video', {

A warning will be logged if this setting is not applied.

### Using TTML Captions

TTML captions require special rendering by dash.js. To enable this rendering, you must set option `useTTML` to `true`, like so:

```javascript
videojs('example-video', {
html5: {
dash: {
useTTML: true
}
}
});
```

This option is not `true` by default because it will also render CEA608 captions in the same method, and there may be some errors in their display. However, it does enable styling captions via the captions settings dialog.

## Multi-Language Labels

When labels in a playlist file are in multiple languages, the 2-character language code should be used if it exists; this allows the player to auto-select the appropriate label.
Expand Down
5 changes: 5 additions & 0 deletions src/js/setup-text-tracks.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ function attachDashTextTracksToVideojs(player, tech, tracks) {

// Add track to videojs track list
.map(({trackConfig, dashTrack}) => {
if (dashTrack.isTTML && !player.getChild('TTMLTextTrackDisplay')) {
return null;
}

const remoteTextTrack = player.addRemoteTextTrack(trackConfig, false);

trackDictionary.push({textTrack: remoteTextTrack.track, dashTrack});
Expand All @@ -76,6 +80,7 @@ function attachDashTextTracksToVideojs(player, tech, tracks) {

return remoteTextTrack;
})
.filter(el => el !== null)

;

Expand Down
228 changes: 228 additions & 0 deletions src/js/ttml-text-track-display.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import videojs from 'video.js';
import dashjs from 'dashjs';
import window from 'global/window';

const Component = videojs.getComponent('Component');

const darkGray = '#222';
const lightGray = '#ccc';
const fontMap = {
monospace: 'monospace',
sansSerif: 'sans-serif',
serif: 'serif',
monospaceSansSerif: '"Andale Mono", "Lucida Console", monospace',
monospaceSerif: '"Courier New", monospace',
proportionalSansSerif: 'sans-serif',
proportionalSerif: 'serif',
casual: '"Comic Sans MS", Impact, fantasy',
script: '"Monotype Corsiva", cursive',
smallcaps: '"Andale Mono", "Lucida Console", monospace, sans-serif'
};

/**
* Try to update the style of a DOM element. Some style changes will throw an error,
* particularly in IE8. Those should be noops.
*
* @param {Element} el
* The DOM element to be styled.
*
* @param {string} style
* The CSS property on the element that should be styled.
*
* @param {string} rule
* The style rule that should be applied to the property.
*
* @private
*/
function tryUpdateStyle(el, style, rule) {
try {
el.style[style] = rule;
} catch (e) {

// Satisfies linter.
return;
}
}

function removeStyle(el) {
if (el.style) {
el.style.left = null;
el.style.width = '100%';
}
for (const i in el.children) {
removeStyle(el.children[i]);
}
}

/**
* Construct an rgba color from a given hex color code.
*
* @param {number} color
* Hex number for color, like #f0e or #f604e2.
*
* @param {number} opacity
* Value for opacity, 0.0 - 1.0.
*
* @return {string}
* The rgba color that was created, like 'rgba(255, 0, 0, 0.3)'.
*/
export function constructColor(color, opacity) {
let hex;

if (color.length === 4) {
// color looks like "#f0e"
hex = color[1] + color[1] + color[2] + color[2] + color[3] + color[3];
} else if (color.length === 7) {
// color looks like "#f604e2"
hex = color.slice(1);
} else {
throw new Error('Invalid color code provided, ' + color + '; must be formatted as e.g. #f0e or #f604e2.');
}
return 'rgba(' +
parseInt(hex.slice(0, 2), 16) + ',' +
parseInt(hex.slice(2, 4), 16) + ',' +
parseInt(hex.slice(4, 6), 16) + ',' +
opacity + ')';
}

/**
* The component for displaying text track cues.
*
* @extends Component
*/
class TTMLTextTrackDisplay extends Component {

/**
* Creates an instance of this class.
*
* @param {Player} player
* The `Player` that this class should be attached to.
*
* @param {Object} [options]
* The key/value store of player options.
*
* @param {Component~ReadyCallback} [ready]
* The function to call when `TextTrackDisplay` is ready.
*/
constructor(player, options, ready) {
super(player, videojs.mergeOptions(options, {playerOptions: {}}), ready);
const selects = player.getChild('TextTrackSettings').$$('select');

for (let i = 0; i < selects.length; i++) {
this.on(selects[i], 'change', this.updateStyle.bind(this));
}
player.dash.mediaPlayer.on(dashjs.MediaPlayer.events.CAPTION_RENDERED,
this.updateStyle.bind(this));
}

/**
* Create the {@link Component}'s DOM element.
*
* @return {Element}
* The element that was created.
*/
createEl() {
const newEl = super.createEl('div', {
className: 'vjs-text-track-display-ttml'
}, {
'aria-live': 'off',
'aria-atomic': 'true'
});

newEl.style.position = 'absolute';
newEl.style.left = '0';
newEl.style.right = '0';
newEl.style.top = '0';
newEl.style.bottom = '0';
newEl.style.margin = '1.5%';
return newEl;
}

updateStyle({captionDiv}) {
if (!this.player_.textTrackSettings) {
return;
}

const overrides = this.player_.textTrackSettings.getValues();

captionDiv = captionDiv || this.player_.getChild('TTMLTextTrackDisplay')
.el().firstChild;
if (!captionDiv) {
return;
}

removeStyle(captionDiv);
const spans = captionDiv.getElementsByTagName('span');

for (let i = 0; i < spans.length; i++) {
const span = spans[i];

span.parentNode.style.textAlign = 'center';
if (overrides.color) {
span.style.color = overrides.color;
}
if (overrides.textOpacity) {
tryUpdateStyle(
span,
'color',
constructColor(
overrides.color || '#fff',
overrides.textOpacity
)
);
}
if (overrides.backgroundColor) {
span.style.backgroundColor = overrides.backgroundColor;
}
if (overrides.backgroundOpacity) {
tryUpdateStyle(
span,
'backgroundColor',
constructColor(
overrides.backgroundColor || '#000',
overrides.backgroundOpacity
)
);
}
if (overrides.windowColor) {
if (overrides.windowOpacity) {
tryUpdateStyle(
span.parentNode,
'backgroundColor',
constructColor(overrides.windowColor, overrides.windowOpacity)
);
} else {
span.parent.style.backgroundColor = overrides.windowColor;
}
}
if (overrides.edgeStyle) {
if (overrides.edgeStyle === 'dropshadow') {
span.style.textShadow = `2px 2px 3px ${darkGray}, 2px 2px 4px ${darkGray}, 2px 2px 5px ${darkGray}`;
} else if (overrides.edgeStyle === 'raised') {
span.style.textShadow = `1px 1px ${darkGray}, 2px 2px ${darkGray}, 3px 3px ${darkGray}`;
} else if (overrides.edgeStyle === 'depressed') {
span.style.textShadow = `1px 1px ${lightGray}, 0 1px ${lightGray}, -1px -1px ${darkGray}, 0 -1px ${darkGray}`;
} else if (overrides.edgeStyle === 'uniform') {
span.style.textShadow = `0 0 4px ${darkGray}, 0 0 4px ${darkGray}, 0 0 4px ${darkGray}, 0 0 4px ${darkGray}`;
}
}
if (overrides.fontPercent && overrides.fontPercent !== 1) {
const fontSize = window.parseFloat(span.style.fontSize);

span.style.fontSize = (fontSize * overrides.fontPercent) + 'px';
span.style.height = 'auto';
span.style.top = 'auto';
span.style.bottom = '2px';
}
if (overrides.fontFamily && overrides.fontFamily !== 'default') {
if (overrides.fontFamily === 'small-caps') {
span.style.fontVariant = 'small-caps';
} else {
span.style.fontFamily = fontMap[overrides.fontFamily];
}
}
}
}

}
videojs.registerComponent('TTMLTextTrackDisplay', TTMLTextTrackDisplay);
15 changes: 15 additions & 0 deletions src/js/videojs-dash.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import dashjs from 'dashjs';
import setupAudioTracks from './setup-audio-tracks';
import setupTextTracks from './setup-text-tracks';
import document from 'global/document';
import './ttml-text-track-display';

/**
* videojs-contrib-dash
Expand Down Expand Up @@ -204,6 +205,10 @@ class Html5DashJS {
// Apply all dash options that are set
if (options.dash) {
Object.keys(options.dash).forEach((key) => {
if (key === 'useTTML') {
return;
}

const dashOptionsKey = 'set' + key.charAt(0).toUpperCase() + key.slice(1);
let value = options.dash[key];

Expand Down Expand Up @@ -235,6 +240,11 @@ class Html5DashJS {

this.mediaPlayer_.attachView(this.el_);

if (options.dash && options.dash.useTTML) {
this.ttmlContainer_ = this.player.addChild('TTMLTextTrackDisplay');
this.mediaPlayer_.attachTTMLRenderingDiv(this.ttmlContainer_.el());
}

// Dash.js autoplays by default, video.js will handle autoplay
this.mediaPlayer_.setAutoPlay(false);

Expand Down Expand Up @@ -289,6 +299,11 @@ class Html5DashJS {
if (this.player.dash) {
delete this.player.dash;
}

if (this.ttmlContainer_) {
this.ttmlContainer_.dispose();
this.player.removeChild('TTMLTextTrackDisplay');
}
}

duration() {
Expand Down
2 changes: 2 additions & 0 deletions test/dashjs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const testHandleSource = function(assert, source, expectedKeySystemOptions, conf
startupCalled = true;
},

attachTTMLRenderingDiv() {
},
attachView() {
attachViewCalled = true;
},
Expand Down

0 comments on commit 3859998

Please sign in to comment.