Source: lib/media/vtt_text_parser.js

/**
 * @license
 * Copyright 2016 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

goog.provide('shaka.media.VttTextParser');

goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.media.TextEngine');
goog.require('shaka.util.Error');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.TextParser');



/**
 * @constructor
 * @implements {shakaExtern.TextParser}
 */
shaka.media.VttTextParser = function() { };


/** @override */
shaka.media.VttTextParser.prototype.parseInit = function(data) {
  goog.asserts.assert(false, 'VTT does not have init segments');
};


/**
 * @override
 * @throws {shaka.util.Error}
 */
shaka.media.VttTextParser.prototype.parseMedia = function(data, time) {
  var VttTextParser = shaka.media.VttTextParser;
  // Get the input as a string.  Normalize newlines to \n.
  var str = shaka.util.StringUtils.fromUTF8(data);
  str = str.replace(/\r\n|\r(?=[^\n]|$)/gm, '\n');
  var 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);
  }

  var offset = time.segmentStart;
  // Parse X-TIMESTAMP-MAP metadata header if it's present to get
  // time offset information.
  // https://tools.ietf.org/html/draft-pantos-http-live-streaming-20#section-3.5
  if (blocks[0].indexOf('X-TIMESTAMP-MAP') >= 0) {
    // '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.
    var cueTimeMatch =
        blocks[0].match(/LOCAL:((?:(\d{1,}):)?(\d{2}):(\d{2})\.(\d{3}))/m);

    var mpegTimeMatch = blocks[0].match(/MPEGTS:(\d+)/m);
    if (cueTimeMatch && mpegTimeMatch) {
      var parser = new shaka.util.TextParser(cueTimeMatch[1]);
      var cueTime = shaka.media.VttTextParser.parseTime_(parser);
      var mpegTime = Number(mpegTimeMatch[1]);
      var mpegTimescale = shaka.media.VttTextParser.MPEG_TIMESCALE_;
      // Apple-encoded HLS content uses absolute timestamps, so assume
      // the presence of the map tag means the content uses absolute
      // timestamps.
      offset = time.periodStart + (mpegTime / mpegTimescale - cueTime);
    }
  }

  var ret = [];
  for (var i = 1; i < blocks.length; i++) {
    var lines = blocks[i].split('\n');
    var cue = VttTextParser.parseCue_(lines, offset);
    if (cue)
      ret.push(cue);
  }

  return ret;
};


/**
 * Parses a text block into a Cue object.
 *
 * @param {!Array.<string>} text
 * @param {number} timeOffset
 * @return {?TextTrackCue}
 * @private
 */
shaka.media.VttTextParser.parseCue_ = function(text, timeOffset) {
  // Skip empty blocks.
  if (text.length == 1 && !text[0])
    return null;

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

  var id = null;
  var index = text[0].indexOf('-->');
  if (index < 0) {
    id = text[0];
    text.splice(0, 1);
  }

  // Parse the times.
  var parser = new shaka.util.TextParser(text[0]);
  var start = shaka.media.VttTextParser.parseTime_(parser);
  var expect = parser.readRegex(/[ \t]+-->[ \t]+/g);
  var end = shaka.media.VttTextParser.parseTime_(parser);

  if (start == null || expect == null || end == null) {
    throw new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.TEXT,
        shaka.util.Error.Code.INVALID_TEXT_CUE);
  }

  start += timeOffset;
  end += timeOffset;

  // Get the payload.
  var payload = text.slice(1).join('\n').trim();

  var cue = shaka.media.TextEngine.makeCue(start, end, payload);
  if (!cue)
    return null;

  // Parse optional settings.
  parser.skipWhitespace();
  var word = parser.readWord();
  while (word) {
    if (!shaka.media.VttTextParser.parseSetting(cue, word)) {
      shaka.log.warning('VTT parser encountered an invalid VTT setting: ',
                        word,
                        ' The setting will be ignored.');
    }
    parser.skipWhitespace();
    word = parser.readWord();
  }

  if (id != null)
    cue.id = id;
  return cue;
};


/**
 * Parses a WebVTT setting from the given word.
 *
 * @param {!TextTrackCue} cue
 * @param {string} word
 * @return {boolean} True on success.
 */
shaka.media.VttTextParser.parseSetting = function(cue, word) {
  // NOTE: positionAlign and lineAlign settings are not supported by Chrome
  // at the moment, so setting them will have no effect.
  // The bug on chromium to implement them:
  // https://bugs.chromium.org/p/chromium/issues/detail?id=633690

  var results = null;
  if ((results = /^align:(start|middle|center|end|left|right)$/.exec(word))) {
    cue.align = results[1];
    if (results[1] == 'center' && cue.align != 'center') {
      // Workaround for a Chrome bug http://crbug.com/663797
      // Chrome does not support align = 'center'
      cue.position = 'auto';
      cue.align = 'middle';
    }
  } else if ((results = /^vertical:(lr|rl)$/.exec(word))) {
    cue.vertical = results[1];
  } else if ((results = /^size:(\d{1,2}|100)%$/.exec(word))) {
    cue.size = Number(results[1]);
  }
  // There was a disagreement between a working draft and an editor draft of
  // the WebVTT spec. According to the former, optional position alignment
  // options are 'start', 'end' and 'center'. According to the latter -
  // 'line-left', 'center' and 'line-right'.
  // We are going to support both options for now.
  else if ((results =
      /^position:(\d{1,2}|100)%(?:,(line-left|line-right|center|start|end))?$/
      .exec(word))) {
    cue.position = Number(results[1]);
    if (results[2])
      cue.positionAlign = results[2];
  } else if ((results =
      /^line:(\d{1,2}|100)%(?:,(start|end|center))?$/.exec(word))) {
    cue.snapToLines = false;
    cue.line = Number(results[1]);
    if (results[2])
      cue.lineAlign = results[2];
  } else if ((results = /^line:(-?\d+)(?:,(start|end|center))?$/.exec(word))) {
    cue.snapToLines = true;
    cue.line = Number(results[1]);
    if (results[2])
      cue.lineAlign = results[2];
  } else {
    return false;
  }

  return true;
};


/**
 * Parses a WebVTT time from the given parser.
 *
 * @param {!shaka.util.TextParser} parser
 * @return {?number}
 * @private
 */
shaka.media.VttTextParser.parseTime_ = function(parser) {
  // 00:00.000 or 00:00:00.000 or 0:00:00.000
  var results = parser.readRegex(/(?:(\d{1,}):)?(\d{2}):(\d{2})\.(\d{3})/g);
  if (results == null)
    return null;
  // This capture is optional, but will still be in the array as undefined,
  // default to 0.
  var hours = Number(results[1]) || 0;
  var minutes = Number(results[2]);
  var seconds = Number(results[3]);
  var miliseconds = Number(results[4]);
  if (minutes > 59 || seconds > 59)
    return null;

  return (miliseconds / 1000) + seconds + (minutes * 60) + (hours * 3600);
};


/**
 * @const {number}
 * @private
 */
shaka.media.VttTextParser.MPEG_TIMESCALE_ = 90000;

shaka.media.TextEngine.registerParser(
    'text/vtt',
    shaka.media.VttTextParser);

shaka.media.TextEngine.registerParser(
    'text/vtt; codecs="vtt"',
    shaka.media.VttTextParser);