UNPKG

shaka-player

Version:
850 lines (769 loc) • 25.8 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.text.VttTextParser'); goog.require('goog.asserts'); goog.require('shaka.log'); goog.require('shaka.media.ManifestParser'); goog.require('shaka.text.Cue'); goog.require('shaka.text.CueRegion'); goog.require('shaka.text.TextEngine'); goog.require('shaka.util.Error'); goog.require('shaka.util.StringUtils'); goog.require('shaka.util.TextParser'); /** * @implements {shaka.extern.TextParser} * @export */ shaka.text.VttTextParser = class { /** Constructs a VTT parser. */ constructor() { /** @private {string} */ this.manifestType_ = shaka.media.ManifestParser.UNKNOWN; } /** * @override * @export */ parseInit(data) { goog.asserts.assert(false, 'VTT does not have init segments'); } /** * @override * @export */ setManifestType(manifestType) { this.manifestType_ = manifestType; } /** * @override * @export */ parseMedia(data, time, uri, images) { const VttTextParser = shaka.text.VttTextParser; // Get the input as a string. Normalize newlines to \n. let str = shaka.util.StringUtils.fromUTF8(data); str = str.replace(/\r\n|\r(?=[^\n]|$)/gm, '\n'); const blocks = str.split(/\n{2,}/m); if (!/^WEBVTT($|[ \t\n])/m.test(blocks[0])) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.TEXT, shaka.util.Error.Code.INVALID_TEXT_HEADER); } // Depending on "segmentRelativeVttTiming" configuration, // "vttOffset" will correspond to either "periodStart" (default) // or "segmentStart", for segmented VTT where timings are relative // to the beginning of each segment. // NOTE: "periodStart" is the timestamp offset applied via TextEngine. // It is no longer closely tied to periods, but the name stuck around. // NOTE: This offset and the flag choosing its meaning have no effect on // HLS content, which should use X-TIMESTAMP-MAP and periodStart instead. let offset = time.vttOffset; if (this.manifestType_ == shaka.media.ManifestParser.HLS) { // Only use 'X-TIMESTAMP-MAP' with HLS. if (blocks[0].includes('X-TIMESTAMP-MAP')) { offset = this.computeHlsOffset_(blocks[0], time, blocks.slice(1)); } } /** @type {!Array<!shaka.text.CueRegion>} */ const regions = VttTextParser.parseLegacyRegions_(blocks[0]); /** @type {!Map<string, !shaka.text.Cue>} */ const styles = new Map(); shaka.text.Cue.addDefaultTextColor(styles); // Parse cues. const ret = []; let seenCue = false; for (const block of blocks.slice(1)) { const lines = block.split('\n'); if (VttTextParser.isRegionBlock_(lines)) { // Region definitions are only allowed before the first cue. if (!seenCue) { regions.push(VttTextParser.parseRegionBlock_(lines)); } continue; } VttTextParser.parseStyle_(lines, styles); const cue = VttTextParser.parseCue_(lines, offset, regions, styles); if (cue) { seenCue = true; ret.push(cue); } } return ret; } /** * Parses the region definitions of a WebVTT header: everything before the * first cue, such as the contents of the WebVTTConfigurationBox of WebVTT in * MP4. * * @param {string} header * @return {!Array<!shaka.text.CueRegion>} */ static parseRegions(header) { const VttTextParser = shaka.text.VttTextParser; const str = header.replace(/\r\n|\r(?=[^\n]|$)/gm, '\n'); const blocks = str.split(/\n{2,}/m); const regions = VttTextParser.parseLegacyRegions_(blocks[0]); // The header might not start with the WEBVTT signature, so its first block // can be a region definition too. for (const block of blocks) { const lines = block.split('\n'); if (lines.some((line) => line.includes('-->'))) { // A cue: no region definitions after it. break; } if (VttTextParser.isRegionBlock_(lines)) { regions.push(VttTextParser.parseRegionBlock_(lines)); } } return regions; } /** * @param {string} headerBlock Contains X-TIMESTAMP-MAP. * @param {shaka.extern.TextParser.TimeContext} time * @param {!Array<string>} cueBlocks * @return {number} * @private */ computeHlsOffset_(headerBlock, time, cueBlocks) { // https://bit.ly/2K92l7y // The 'X-TIMESTAMP-MAP' header is used in HLS to align text with // the rest of the media. // The header format is 'X-TIMESTAMP-MAP=MPEGTS:n,LOCAL:m' // (the attributes can go in any order) // where n is MPEG-2 time and m is cue time it maps to. // For example 'X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:900000' // means an offset of 10 seconds // 900000/MPEG_TIMESCALE - cue time. const cueTimeMatch = headerBlock.match( /LOCAL:((?:(\d{1,}):)?(\d{2}):(\d{2})\.(\d{3}))/m); const mpegTimeMatch = headerBlock.match(/MPEGTS:(\d+)/m); if (!cueTimeMatch || !mpegTimeMatch) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.TEXT, shaka.util.Error.Code.INVALID_TEXT_HEADER); } const cueTime = shaka.util.TextParser.parseTime(cueTimeMatch[1]); if (cueTime == null) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.TEXT, shaka.util.Error.Code.INVALID_TEXT_HEADER); } let mpegTime = Number(mpegTimeMatch[1]); const mpegTimescale = shaka.text.VttTextParser.MPEG_TIMESCALE_; const rolloverSeconds = shaka.text.VttTextParser.TS_ROLLOVER_ / mpegTimescale; // The MPEGTS value is a 33-bit timestamp that may have rolled over // while the media timeline has not. Count the rollovers to add by // comparing it with the media time of the LOCAL anchor, estimated from // the segment start and corrected by how far the first cue sits from // LOCAL. Without that correction, a packager that writes absolute cue // times with MPEGTS:0,LOCAL:00:00:00.000 next to fMP4 media with // wall-clock based timestamps gets thousands of rollovers added, and // every cue lands far in the future. let anchorTime = time.segmentStart - time.periodStart; const firstCueTime = shaka.text.VttTextParser.firstCueStartTime_(cueBlocks); if (firstCueTime != null) { anchorTime -= firstCueTime - cueTime; } const rollovers = Math.max(0, Math.round((anchorTime - mpegTime / mpegTimescale) / rolloverSeconds)); mpegTime += rollovers * shaka.text.VttTextParser.TS_ROLLOVER_; return time.periodStart + mpegTime / mpegTimescale - cueTime; } /** * @param {!Array<string>} cueBlocks * @return {?number} The start time of the first cue, in cue time. * @private */ static firstCueStartTime_(cueBlocks) { const match = cueBlocks.join('\n').match( /^[ \t]*((?:\d+:)?\d{2}:\d{2}\.\d{3})[ \t]+-->/m); return match ? shaka.util.TextParser.parseTime(match[1]) : null; } /** * Parses the regions defined with the syntax of early WebVTT drafts, as * header lines like: * Region: id=fred width=50% lines=3 regionanchor=0%,100% * viewportanchor=10%,90% scroll=up * * @param {string} headerBlock * @return {!Array<!shaka.text.CueRegion>} * @private */ static parseLegacyRegions_(headerBlock) { const VttTextParser = shaka.text.VttTextParser; const regions = []; for (const line of headerBlock.split('\n')) { if (/^Region:/.test(line)) { // Skip 'Region:' const settings = line.substring('Region:'.length); regions.push(VttTextParser.parseRegionSettings_(settings, '=')); } } return regions; } /** * @param {!Array<string>} lines * @return {boolean} True if the block is a REGION definition block. * @private */ static isRegionBlock_(lines) { // A block starting with "REGION" whose second line has a timing is a cue // with "REGION" as its identifier. return /^REGION[ \t]*$/.test(lines[0]) && !(lines.length > 1 && lines[1].includes('-->')); } /** * Parses a REGION definition block: * REGION * id:fred * width:40% * lines:3 * regionanchor:0%,100% * viewportanchor:10%,90% * scroll:up * * @param {!Array<string>} lines * @return {!shaka.text.CueRegion} * @private */ static parseRegionBlock_(lines) { // Settings can be separated by spaces, tabs or line terminators. return shaka.text.VttTextParser.parseRegionSettings_( lines.slice(1).join('\n'), ':'); } /** * Parses a list of region settings into a Region object. * See https://www.w3.org/TR/webvtt1/#collect-webvtt-region-settings * * @param {string} settings * @param {string} separator The separator between the name and the value of * each setting. * @return {!shaka.text.CueRegion} * @private */ static parseRegionSettings_(settings, separator) { const region = new shaka.text.CueRegion(); // WebVTT region defaults, which differ from the CueRegion ones. region.height = 3; region.heightUnits = shaka.text.CueRegion.units.LINES; region.regionAnchorX = 0; region.regionAnchorY = 100; region.viewportAnchorX = 0; region.viewportAnchorY = 100; for (const setting of settings.split(/[ \t\n]+/)) { if (!setting) { continue; } if (!shaka.text.VttTextParser.parseRegionSetting_( region, setting, separator)) { shaka.log.warning( 'VTT parser encountered an invalid VTTRegion setting: ', setting, ' The setting will be ignored.'); } } 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 (shaka.text.VttTextParser.REGEX_NOTE_.test(text[0])) { return; } // Only style block are allowed. if (text[0] != 'STYLE') { return; } /** @type {!Array<!Array<string>>} */ const styleBlocks = []; let lastBlockIndex = -1; for (let i = 1; i < text.length; i++) { if (text[i].includes('::cue')) { styleBlocks.push([]); lastBlockIndex = styleBlocks.length - 1; } if (lastBlockIndex == -1) { continue; } styleBlocks[lastBlockIndex].push(text[i]); if (text[i].includes('}')) { lastBlockIndex = -1; } } for (const styleBlock of styleBlocks) { let styleSelector = 'global'; // Look for what is within parentheses. For example: // <code>:: cue (b) {</code>, what we are looking for is <code>b</code> const selector = styleBlock[0].match(/\((.*)\)/); if (selector) { styleSelector = selector.pop(); } // We start at 1 to avoid '::cue' and end earlier to avoid '}' let propertyLines = styleBlock.slice(1, -1); if (styleBlock[0].includes('}')) { const payload = /\{(.*?)\}/.exec(styleBlock[0]); if (payload) { propertyLines = payload[1].split(';'); } } // Continue styles over multiple selectors if necessary. // For example, // ::cue(b) { background: white; } ::cue(b) { color: blue; } // should set both the background and foreground of bold tags. let cue = styles.get(styleSelector); if (!cue) { 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': case 'background': 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 || value == 'bold') { 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 'text-combine-upright': validStyle = true; cue.textCombineUpright = value; break; case 'text-shadow': validStyle = true; cue.textShadow = 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.text.CueRegion>} regions * @param {!Map<string, !shaka.text.Cue>} styles * @return {shaka.text.Cue} * @private */ static parseCue_(text, timeOffset, regions, styles) { const VttTextParser = shaka.text.VttTextParser; // Skip empty blocks. if (text.length == 1 && !text[0]) { return null; } // Skip comment blocks. if (shaka.text.VttTextParser.REGEX_NOTE_.test(text[0])) { return null; } // Skip style and region blocks. if (text[0] == 'STYLE' || shaka.text.VttTextParser.isRegionBlock_(text)) { return null; } let id = null; let lineIndex = 0; if (!text[0].includes('-->')) { id = text[0]; lineIndex = 1; } // Parse the times. const parser = new shaka.util.TextParser(text[lineIndex]); let start = parser.parseTime(); const expect = parser.readRegex(/[ \t]+-->[ \t]+/g); let end = parser.parseTime(); if (start == null || expect == null || end == null) { shaka.log.alwaysWarn( 'Failed to parse VTT time code. Cue skipped:', id, text); return null; } start += timeOffset; end += timeOffset; if (start < 0) { start = 0; } // Get the payload. let payload = ''; for (let i = lineIndex + 1; i < text.length; i++) { if (i > lineIndex + 1) { payload += '\n'; } payload += text[i]; } payload = payload.trim(); let cue; if (styles.has('global')) { cue = styles.get('global').clone(); cue.startTime = start; cue.endTime = end; cue.payload = payload; } else { cue = new shaka.text.Cue(start, end, payload); } // Parse optional settings. parser.skipWhitespace(); VttTextParser.parseCueSettings(cue, parser, regions); shaka.text.Cue.parseCuePayload(cue, styles); if (id != null) { cue.id = id; } return cue; } /** * Parses all the WebVTT settings of a cue. * * @param {!shaka.text.Cue} cue * @param {!shaka.util.TextParser} parser Positioned at the first setting. * @param {!Array<!shaka.text.CueRegion>} regions */ static parseCueSettings(cue, parser, regions) { parser.forEachWord((word) => { if (!shaka.text.VttTextParser.parseCueSetting(cue, word, regions)) { shaka.log.warning('VTT parser encountered an invalid VTT setting: ', word, ' The setting will be ignored.'); } }); // A cue explicitly positioned with a line, sized, or vertical drops out of // its region. This is checked once all the settings are parsed, so the // result doesn't depend on their order. // See https://www.w3.org/TR/webvtt1/#parse-the-webvtt-cue-settings const horizontal = shaka.text.Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM; if (cue.region.id && (cue.line != null || (cue.size && cue.size != 100) || cue.writingMode != horizontal)) { cue.region = new shaka.text.CueRegion(); } } /** * Parses a WebVTT setting from the given word. * * @param {!shaka.text.Cue} cue * @param {string} word * @param {!Array<!shaka.text.CueRegion>} regions * @return {boolean} True on success. */ static parseCueSetting(cue, word, regions) { const VttTextParser = shaka.text.VttTextParser; let results = null; if ((results = VttTextParser.REGEX_ALIGN_.exec(word))) { VttTextParser.setTextAlign_(cue, results[1]); } else if ((results = VttTextParser.REGEX_VERTICAL_.exec(word))) { VttTextParser.setVerticalWritingMode_(cue, results[1]); } else if ((results = VttTextParser.REGEX_SIZE_.exec(word))) { cue.size = Number(results[1]); } else if ((results = VttTextParser.REGEX_POSITION_.exec(word))) { cue.position = Number(results[1]); if (results[2]) { VttTextParser.setPositionAlign_(cue, results[2]); } } else if ((results = VttTextParser.REGEX_REGION_.exec(word))) { const region = VttTextParser.getRegionById_(regions, results[1]); if (region) { cue.region = region; } } else { return VttTextParser.parsedLineValueAndInterpretation_(cue, word); } return true; } /** * * @param {!Array<!shaka.text.CueRegion>} regions * @param {string} id * @return {?shaka.text.CueRegion} * @private */ static getRegionById_(regions, id) { // If several regions share the id, the last one wins. for (let i = regions.length - 1; i >= 0; i--) { if (regions[i].id == id) { return regions[i]; } } shaka.log.warning('VTT parser could not find a region with id: ', id, ' The region will be ignored.'); return null; } /** * Parses a WebVTTRegion setting from the given word. * * @param {!shaka.text.CueRegion} region * @param {string} word * @param {string} separator The separator between the name and the value. * @return {boolean} True on success. * @private */ static parseRegionSetting_(region, word, separator) { const VttTextParser = shaka.text.VttTextParser; const index = word.indexOf(separator); if (index <= 0 || index == word.length - 1) { return false; } const name = word.substring(0, index); const value = word.substring(index + 1); switch (name) { case 'id': region.id = value; return true; case 'width': { const width = VttTextParser.parsePercentage_(value); if (width == null) { return false; } region.width = width; return true; } case 'lines': if (!/^\d+$/.test(value)) { return false; } region.height = Number(value); region.heightUnits = shaka.text.CueRegion.units.LINES; return true; case 'regionanchor': { const anchor = VttTextParser.parsePercentagePair_(value); if (!anchor) { return false; } region.regionAnchorX = anchor[0]; region.regionAnchorY = anchor[1]; return true; } case 'viewportanchor': { const anchor = VttTextParser.parsePercentagePair_(value); if (!anchor) { return false; } region.viewportAnchorX = anchor[0]; region.viewportAnchorY = anchor[1]; return true; } case 'scroll': if (value != 'up') { return false; } region.scroll = shaka.text.CueRegion.scrollMode.UP; return true; } return false; } /** * Parses two comma separated WebVTT percentages, such as "10%,90%". * * @param {string} value * @return {?Array<number>} * @private */ static parsePercentagePair_(value) { const VttTextParser = shaka.text.VttTextParser; const index = value.indexOf(','); if (index < 0) { return null; } const x = VttTextParser.parsePercentage_(value.substring(0, index)); const y = VttTextParser.parsePercentage_(value.substring(index + 1)); if (x == null || y == null) { return null; } return [x, y]; } /** * Parses a WebVTT percentage, such as "12.5%". * See https://www.w3.org/TR/webvtt1/#parse-a-percentage-string * * @param {string} value * @return {?number} A number between 0 and 100, or null if invalid. * @private */ static parsePercentage_(value) { if (!/^\d+(?:\.\d+)?%$/.test(value)) { return null; } const percentage = parseFloat(value); if (percentage > 100) { return null; } return percentage; } /** * @param {!shaka.text.Cue} cue * @param {string} align * @private */ static setTextAlign_(cue, align) { const Cue = shaka.text.Cue; if (align == 'middle') { cue.textAlign = Cue.textAlign.CENTER; } else { goog.asserts.assert(align.toUpperCase() in Cue.textAlign, align.toUpperCase() + ' Should be in Cue.textAlign values!'); cue.textAlign = Cue.textAlign[align.toUpperCase()]; } } /** * @param {!shaka.text.Cue} cue * @param {string} align * @private */ static setPositionAlign_(cue, align) { const Cue = shaka.text.Cue; if (align == 'line-left' || align == 'start') { cue.positionAlign = Cue.positionAlign.LEFT; } else if (align == 'line-right' || align == 'end') { cue.positionAlign = Cue.positionAlign.RIGHT; } else if (align == 'center' || align == 'middle') { cue.positionAlign = Cue.positionAlign.CENTER; } else { cue.positionAlign = Cue.positionAlign.AUTO; } } /** * @param {!shaka.text.Cue} cue * @param {string} value * @private */ static setVerticalWritingMode_(cue, value) { const Cue = shaka.text.Cue; if (value == 'lr') { cue.writingMode = Cue.writingMode.VERTICAL_LEFT_TO_RIGHT; } else { cue.writingMode = Cue.writingMode.VERTICAL_RIGHT_TO_LEFT; } } /** * @param {!shaka.text.Cue} cue * @param {string} word * @return {boolean} * @private */ static parsedLineValueAndInterpretation_(cue, word) { const Cue = shaka.text.Cue; let results = null; if ((results = shaka.text.VttTextParser.REGEX_LINE_PCT_.exec(word))) { cue.lineInterpretation = Cue.lineInterpretation.PERCENTAGE; } else if ((results = shaka.text.VttTextParser.REGEX_LINE_NUM_.exec(word))) { cue.lineInterpretation = Cue.lineInterpretation.LINE_NUMBER; } else { return false; } cue.line = Number(results[1]); if (results[2]) { goog.asserts.assert( results[2].toUpperCase() in Cue.lineAlign, results[2].toUpperCase() + ' Should be in Cue.lineAlign values!'); cue.lineAlign = Cue.lineAlign[results[2].toUpperCase()]; } return true; } }; /** * @const {number} * @private */ shaka.text.VttTextParser.MPEG_TIMESCALE_ = 90000; /** * At this value, timestamps roll over in TS content. * @const {number} * @private */ shaka.text.VttTextParser.TS_ROLLOVER_ = 0x200000000; /** @private @const {!RegExp} */ shaka.text.VttTextParser.REGEX_NOTE_ = /^NOTE($|[ \t])/; /** @private @const {!RegExp} */ shaka.text.VttTextParser.REGEX_ALIGN_ = /^align:(start|middle|center|end|left|right)$/; /** @private @const {!RegExp} */ shaka.text.VttTextParser.REGEX_VERTICAL_ = /^vertical:(lr|rl)$/; /** @private @const {!RegExp} */ shaka.text.VttTextParser.REGEX_SIZE_ = /^size:([\d.]+)%$/; /** @private @const {!RegExp} */ shaka.text.VttTextParser.REGEX_POSITION_ = // eslint-disable-next-line @stylistic/max-len /^position:([\d.]+)%(?:,(line-left|line-right|middle|center|start|end|auto))?$/; /** @private @const {!RegExp} */ shaka.text.VttTextParser.REGEX_REGION_ = /^region:(.+)$/; /** @private @const {!RegExp} */ shaka.text.VttTextParser.REGEX_LINE_PCT_ = /^line:([\d.]+)%(?:,(start|end|center))?$/; /** @private @const {!RegExp} */ shaka.text.VttTextParser.REGEX_LINE_NUM_ = /^line:(-?\d+)(?:,(start|end|center))?$/; shaka.text.TextEngine.registerParser( 'text/vtt', () => new shaka.text.VttTextParser()); shaka.text.TextEngine.registerParser( 'text/vtt; codecs="vtt"', () => new shaka.text.VttTextParser()); shaka.text.TextEngine.registerParser( 'text/vtt; codecs="wvtt"', () => new shaka.text.VttTextParser());