Skip to content

Commit

Permalink
feat: Add support for WebVTT style blocks (shaka-project#3071)
Browse files Browse the repository at this point in the history
  • Loading branch information
Álvaro Velad Galván authored Feb 19, 2021
1 parent c5fa627 commit 6db55e0
Show file tree
Hide file tree
Showing 3 changed files with 224 additions and 14 deletions.
4 changes: 3 additions & 1 deletion lib/text/mp4_vtt_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,9 @@ shaka.text.Mp4VttParser = class {
static assembleCue_(payload, id, settings, startTime, endTime) {
const cue = new shaka.text.Cue(startTime, endTime, '');

shaka.text.VttTextParser.parseCueStyles(payload, cue);
/** @type {!Map.<string, shaka.text.Cue>} */
const styles = new Map();
shaka.text.VttTextParser.parseCueStyles(payload, cue, styles);

if (id) {
cue.id = id;
Expand Down
154 changes: 145 additions & 9 deletions lib/text/vtt_text_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,15 @@ shaka.text.VttTextParser = class {
}
}

/** @type {!Map.<string, shaka.text.Cue>} */
const styles = new Map();

// Parse cues.
const ret = [];
for (const block of blocks.slice(1)) {
const lines = block.split('\n');
const cue = VttTextParser.parseCue_(lines, offset, regions);
VttTextParser.parseStyle_(lines, styles);
const cue = VttTextParser.parseCue_(lines, offset, regions, styles);
if (cue) {
ret.push(cue);
}
Expand Down Expand Up @@ -149,16 +153,129 @@ shaka.text.VttTextParser = class {
return region;
}

/**
* Parses a style block into a Cue object.
*
* @param {!Array.<string>} text
* @param {!Map.<string, shaka.text.Cue>} styles
* @private
*/
static parseStyle_(text, styles) {
// Skip empty blocks.
if (text.length == 1 && !text[0]) {
return;
}

// Skip comment blocks.
if (/^NOTE($|[ \t])/.test(text[0])) {
return;
}

// Only style block are allowed.
if (text[0] != 'STYLE') {
return;
}

if (!text[1].includes('::cue')) {
return;
}
let styleSelector = 'global';
// Look for what is within parentisesis. For example:
// <code>:: cue (b) {</code>, what we are looking for is <code>b</code>
const selector = text[1].match(/\((.*)\)/);
if (selector) {
styleSelector = selector.pop();
}

// We start at 2 to avoid '::cue' and end earlier to avoid '}'
let propertyLines = text.slice(2, -1);
if (text[1].includes('}')) {
const payload = /\{(.*?)\}/.exec(text[1]);
if (payload) {
propertyLines = payload[1].split(';');
}
}

const cue = new shaka.text.Cue(0, 0, '');
let validStyle = false;
for (let i = 0; i < propertyLines.length; i++) {
// We look for CSS properties. As a general rule they are separated by
// <code>:</code>. Eg: <code>color: red;</code>
const lineParts = /^\s*([^:]+):\s*(.*)/.exec(propertyLines[i]);
if (lineParts) {
const name = lineParts[1].trim();
const value = lineParts[2].trim().replace(';', '');
switch (name) {
case 'background-color':
validStyle = true;
cue.backgroundColor = value;
break;
case 'color':
validStyle = true;
cue.color = value;
break;
case 'font-family':
validStyle = true;
cue.fontFamily = value;
break;
case 'font-size':
validStyle = true;
cue.fontSize = value;
break;
case 'font-weight':
if (parseInt(value, 10) >= 700) {
validStyle = true;
cue.fontWeight = shaka.text.Cue.fontWeight.BOLD;
}
break;
case 'font-style':
switch (value) {
case 'normal':
validStyle = true;
cue.fontStyle = shaka.text.Cue.fontStyle.NORMAL;
break;
case 'italic':
validStyle = true;
cue.fontStyle = shaka.text.Cue.fontStyle.ITALIC;
break;
case 'oblique':
validStyle = true;
cue.fontStyle = shaka.text.Cue.fontStyle.OBLIQUE;
break;
}
break;
case 'opacity':
validStyle = true;
cue.opacity = parseFloat(value);
break;
case 'white-space':
validStyle = true;
cue.wrapLine = value != 'noWrap';
break;
default:
shaka.log.warning('VTT parser encountered an unsupported style: ',
lineParts);
break;
}
}
}

if (validStyle) {
styles.set(styleSelector, cue);
}
}

/**
* Parses a text block into a Cue object.
*
* @param {!Array.<string>} text
* @param {number} timeOffset
* @param {!Array.<!shaka.extern.CueRegion>} regions
* @param {!Map.<string, shaka.text.Cue>} styles
* @return {shaka.text.Cue}
* @private
*/
static parseCue_(text, timeOffset, regions) {
static parseCue_(text, timeOffset, regions, styles) {
const VttTextParser = shaka.text.VttTextParser;

// Skip empty blocks.
Expand Down Expand Up @@ -202,9 +319,17 @@ shaka.text.VttTextParser = class {
// Get the payload.
const payload = text.slice(1).join('\n').trim();

const cue = new shaka.text.Cue(start, end, '');
let cue = null;
if (styles.has('global')) {
cue = styles.get('global').clone();
cue.startTime = start;
cue.endTime = end;
cue.payload = '';
} else {
cue = new shaka.text.Cue(start, end, '');
}

VttTextParser.parseCueStyles(payload, cue);
VttTextParser.parseCueStyles(payload, cue, styles);

// Parse optional settings.
parser.skipWhitespace();
Expand All @@ -230,8 +355,9 @@ shaka.text.VttTextParser = class {
*
* @param {string} payload
* @param {!shaka.text.Cue} rootCue
* @param {!Map.<string, shaka.text.Cue>} styles
*/
static parseCueStyles(payload, rootCue) {
static parseCueStyles(payload, rootCue, styles) {
const xmlPayload = '<span>' + payload + '</span>';
const element = shaka.util.XmlUtils.parseXmlString(xmlPayload, 'span');
if (element) {
Expand All @@ -248,7 +374,8 @@ shaka.text.VttTextParser = class {
}
}
for (const childNode of childNodes) {
VttTextParser.generateCueFromElement_(childNode, rootCue, cues);
VttTextParser.generateCueFromElement_(
childNode, rootCue, cues, styles);
}
rootCue.nestedCues = cues;
} else {
Expand All @@ -259,17 +386,25 @@ shaka.text.VttTextParser = class {

/**
* @param {!Node} element
* @param {!shaka.text.Cue} rootCue
* @param {Array.<!shaka.extern.Cue>} cues
* @param {!Map.<string, shaka.text.Cue>} styles
* @private
*/
static generateCueFromElement_(element, rootCue, cues) {
const nestedCue = rootCue.clone();
static generateCueFromElement_(element, rootCue, cues, styles) {
let nestedCue = rootCue.clone();
if (element.nodeType === Node.ELEMENT_NODE && element.nodeName) {
const bold = shaka.text.Cue.fontWeight.BOLD;
const italic = shaka.text.Cue.fontStyle.ITALIC;
const underline = shaka.text.Cue.textDecoration.UNDERLINE;
const tags = element.nodeName.split(/[ .]+/);
for (const tag of tags) {
if (styles.has(tag)) {
nestedCue = styles.get(tag).clone();
nestedCue.startTime = rootCue.startTime;
nestedCue.endTime = rootCue.endTime;
nestedCue.payload = '';
}
switch (tag) {
case 'b':
nestedCue.fontWeight = bold;
Expand All @@ -290,7 +425,8 @@ shaka.text.VttTextParser = class {
} else {
const VttTextParser = shaka.text.VttTextParser;
for (const childNode of element.childNodes) {
VttTextParser.generateCueFromElement_(childNode, nestedCue, cues);
VttTextParser.generateCueFromElement_(
childNode, nestedCue, cues, styles);
}
}
}
Expand Down
80 changes: 76 additions & 4 deletions test/text/vtt_text_parser_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -578,14 +578,58 @@ describe('VttTextParser', () => {
{periodStart: 0, segmentStart: 95550, segmentEnd: 95560});
});

it('skips style blocks', () => {
it('supports global style blocks', () => {
verifyHelper(
[
{startTime: 20, endTime: 40, payload: 'Test'},
{startTime: 40, endTime: 50, payload: 'Test2'},
{
startTime: 20,
endTime: 40,
payload: 'Test',
color: 'cyan',
fontSize: '10px',
},
{
startTime: 40,
endTime: 50,
payload: 'Test2',
color: 'cyan',
fontSize: '10px',
},
],
'WEBVTT\n\n' +
'STYLE\n' +
'::cue {\n' +
'color: cyan;\n'+
'font-size: 10px;\n'+
'}\n\n' +
'00:00:20.000 --> 00:00:40.000\n' +
'Test\n\n' +
'00:00:40.000 --> 00:00:50.000\n' +
'Test2',
{periodStart: 0, segmentStart: 0, segmentEnd: 0});
});

it('supports global style blocks without blank lines', () => {
verifyHelper(
[
{
startTime: 20,
endTime: 40,
payload: 'Test',
color: 'cyan',
fontSize: '10px',
},
{
startTime: 40,
endTime: 50,
payload: 'Test2',
color: 'cyan',
fontSize: '10px',
},
],
'WEBVTT\n\n' +
'STYLE\n::cue(.cyan) { color: cyan; }\n\n' +
'STYLE\n' +
'::cue { color: cyan; font-size: 10px; }\n\n' +
'00:00:20.000 --> 00:00:40.000\n' +
'Test\n\n' +
'00:00:40.000 --> 00:00:50.000\n' +
Expand Down Expand Up @@ -734,6 +778,34 @@ describe('VttTextParser', () => {
{periodStart: 0, segmentStart: 0, segmentEnd: 0});
});

it('supports specific style blocks', () => {
verifyHelper(
[
{
startTime: 20,
endTime: 40,
payload: '',
nestedCues: [
{
startTime: 20,
endTime: 40,
payload: 'Test',
color: 'cyan',
fontWeight: Cue.fontWeight.BOLD,
},
],
},
{startTime: 40, endTime: 50, payload: 'Test2'},
],
'WEBVTT\n\n' +
'STYLE\n::cue(b) { color: cyan; }\n\n' +
'00:00:20.000 --> 00:00:40.000\n' +
'<b>Test</b>\n\n' +
'00:00:40.000 --> 00:00:50.000\n' +
'Test2',
{periodStart: 0, segmentStart: 0, segmentEnd: 0});
});

it('supports only two digits in the timestamp', () => {
verifyHelper(
[
Expand Down

0 comments on commit 6db55e0

Please sign in to comment.