Source: lib/text/mp4_ttml_parser.js

/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

goog.provide('shaka.text.Mp4TtmlParser');

goog.require('goog.asserts');
goog.require('shaka.text.TextEngine');
goog.require('shaka.text.TtmlTextParser');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.Error');
goog.require('shaka.util.Mp4BoxParsers');
goog.require('shaka.util.Mp4Parser');
goog.require('shaka.util.Uint8ArrayUtils');


/**
 * @implements {shaka.extern.TextParser}
 * @export
 */
shaka.text.Mp4TtmlParser = class {
  constructor() {
    /**
     * @type {!shaka.extern.TextParser}
     * @private
     */
    this.parser_ = new shaka.text.TtmlTextParser();

    /**
     * The media timescale, used to turn sample durations into seconds.
     *
     * @type {?number}
     * @private
     */
    this.timescale_ = null;
  }

  /**
   * @override
   * @export
   */
  parseInit(data) {
    const Mp4Parser = shaka.util.Mp4Parser;

    let sawSTPP = false;

    new Mp4Parser()
        .boxes(Mp4Parser.SAMPLE_TABLE_PATH, Mp4Parser.children)
        .fullBox('mdhd', (box) => {
          goog.asserts.assert(
              box.version == 0 || box.version == 1,
              'MDHD version can only be 0 or 1');

          const parsedMDHDBox = shaka.util.Mp4BoxParsers.parseMDHD(
              box.reader, box.version);
          this.timescale_ = parsedMDHDBox.timescale;
        })
        .fullBox('stsd', Mp4Parser.sampleDescription)
        .box('stpp', (box) => {
          sawSTPP = true;
          box.parser.stop();
        }).parse(data);

    if (!sawSTPP) {
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.TEXT,
          shaka.util.Error.Code.INVALID_MP4_TTML);
    }
  }

  /**
   * @override
   * @export
   */
  setManifestType(manifestType) {
    this.parser_.setManifestType(manifestType);
  }

  /**
   * @override
   * @export
   */
  parseMedia(data, time, uri, images) {
    const Mp4Parser = shaka.util.Mp4Parser;

    let payload = [];
    let defaultSampleSize = null;
    let defaultSampleDuration = null;

    /** @type {!Array<Uint8Array>} */
    const mdats = [];

    /* @type {!Map<number,!Array<number>>} */
    const subSampleSizesPerSample = new Map();

    /** @type {!Array<number>} */
    const sampleSizes = [];

    /**
     * The duration of each sample, in timescale units, or null where the
     * fragment does not give one.
     * @type {!Array<?number>}
     */
    const sampleDurations = [];

    /**
     * The decode time of each sample, in timescale units.  Only differences
     * between these matter, so a fragment without a tfdt simply continues
     * from the previous one.
     * @type {!Array<number>}
     */
    const sampleDecodeTimes = [];
    let decodeTime = 0;

    const parser = new Mp4Parser()
        .boxes(Mp4Parser.FRAGMENT_PATH, Mp4Parser.children)
        .fullBox('tfhd', (box) => {
          goog.asserts.assert(
              box.flags != null,
              'A TFHD box should have a valid flags value');
          const parsedTFHDBox = shaka.util.Mp4BoxParsers.parseTFHD(
              box.reader, box.flags);
          defaultSampleSize = parsedTFHDBox.defaultSampleSize;
          defaultSampleDuration = parsedTFHDBox.defaultSampleDuration;
        })
        .fullBox('tfdt', (box) => {
          goog.asserts.assert(
              box.version == 0 || box.version == 1,
              'TFDT version can only be 0 or 1');

          const parsedTFDTBox = shaka.util.Mp4BoxParsers.parseTFDTInaccurate(
              box.reader, box.version);
          decodeTime = parsedTFDTBox.baseMediaDecodeTime;
        })
        .fullBox('trun', (box) => {
          goog.asserts.assert(
              box.version != null,
              'A TRUN box should have a valid version value');
          goog.asserts.assert(
              box.flags != null,
              'A TRUN box should have a valid flags value');

          const parsedTRUNBox = shaka.util.Mp4BoxParsers.parseTRUN(
              box.reader, box.version, box.flags);

          for (const sample of parsedTRUNBox.sampleData) {
            const sampleSize =
                sample.sampleSize || defaultSampleSize || 0;
            sampleSizes.push(sampleSize);
            const sampleDuration =
                sample.sampleDuration || defaultSampleDuration;
            sampleDurations.push(sampleDuration);
            sampleDecodeTimes.push(decodeTime);
            decodeTime += sampleDuration || 0;
          }
        })
        .fullBox('subs', (box) => {
          const reader = box.reader;
          const entryCount = reader.readUint32();
          let currentSampleNum = -1;
          for (let i = 0; i < entryCount; i++) {
            const sampleDelta = reader.readUint32();
            currentSampleNum += sampleDelta;
            const subsampleCount = reader.readUint16();
            const subsampleSizes = [];
            for (let j = 0; j < subsampleCount; j++) {
              if (box.version == 1) {
                subsampleSizes.push(reader.readUint32());
              } else {
                subsampleSizes.push(reader.readUint16());
              }
              reader.readUint8(); // priority
              reader.readUint8(); // discardable
              reader.readUint32(); // codec_specific_parameters
            }
            subSampleSizesPerSample.set(currentSampleNum, subsampleSizes);
          }
        })
        .box('mdat', Mp4Parser.allData((data) => {
          // We collect all of the mdats first, before parsing any of them.
          // This is necessary in case the mp4 has multiple mdats.
          // They are views on the segment, not cloned, because they will be
          // concatenated and further parsed soon.
          mdats.push(data);
        }, /* clone= */ false));
    parser.parse(data, /* partialOkay= */ false);

    if (mdats.length == 0) {
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.TEXT,
          shaka.util.Error.Code.INVALID_MP4_TTML);
    }

    const fullData =
        shaka.util.Uint8ArrayUtils.concat(...mdats);

    const sampleTimes =
        this.getSampleTimes_(sampleDecodeTimes, sampleDurations, time);

    let sampleOffset = 0;
    for (let sampleNum = 0; sampleNum < sampleSizes.length; sampleNum++) {
      let sampleData =
          shaka.util.BufferUtils.toUint8(fullData, sampleOffset,
              sampleSizes[sampleNum]);
      sampleOffset += sampleSizes[sampleNum];

      const subSampleSizes = subSampleSizesPerSample.get(sampleNum);
      const images = [];

      if (subSampleSizes && subSampleSizes.length) {
        const contentData =
            shaka.util.BufferUtils.toUint8(sampleData, 0, subSampleSizes[0]);
        let subOffset = subSampleSizes[0];
        for (let i = 1; i < subSampleSizes.length; i++) {
          const imageData =
              shaka.util.BufferUtils.toUint8(sampleData, subOffset,
                  subSampleSizes[i]);
          const raw =
              shaka.util.Uint8ArrayUtils.toStandardBase64(imageData);
          images.push('data:image/png;base64,' + raw);
          subOffset += subSampleSizes[i];
        }
        sampleData = contentData;
      }
      payload = payload.concat(this.parser_.parseMedia(
          sampleData, sampleTimes ? sampleTimes[sampleNum] : time, uri,
          images));
    }

    return payload;
  }

  /**
   * Gives each sample of the segment the part of the timeline that is its own,
   * so that a document is clipped to its own sample rather than to the whole
   * segment, per ISO/IEC 14496-30 Section 5.9(4).  With one sample per segment
   * the two are the same thing, which is why this returns null for that case
   * and for any fragment that does not give every sample a duration: the
   * samples cannot be timed individually then, so the segment stays one unit.
   *
   * @param {!Array<number>} sampleDecodeTimes in timescale units
   * @param {!Array<?number>} sampleDurations in timescale units
   * @param {shaka.extern.TextParser.TimeContext} time
   * @return {?Array<shaka.extern.TextParser.TimeContext>}
   * @private
   */
  getSampleTimes_(sampleDecodeTimes, sampleDurations, time) {
    const timescale = this.timescale_;
    if (!timescale || sampleDurations.length < 2) {
      return null;
    }

    /** @type {!Array<number>} */
    const durations = [];
    for (const duration of sampleDurations) {
      if (!duration) {
        return null;
      }
      durations.push(duration);
    }

    const clamp = (t) =>
      Math.min(Math.max(t, time.segmentStart), time.segmentEnd);

    // The first sample starts where the segment does. Only the offsets from it
    // come from the decode times, which are on the media timeline and so
    // cannot be compared with the segment times directly.
    const baseDecodeTime = sampleDecodeTimes[0];

    /** @type {!Array<shaka.extern.TextParser.TimeContext>} */
    const sampleTimes = [];
    for (let i = 0; i < durations.length; i++) {
      const start = time.segmentStart +
          (sampleDecodeTimes[i] - baseDecodeTime) / timescale;
      const end = start + durations[i] / timescale;
      sampleTimes.push({
        periodStart: time.periodStart,
        segmentStart: clamp(start),
        segmentEnd: clamp(end),
        vttOffset: time.vttOffset,
        isMpegTs: time.isMpegTs,
      });
    }
    // The durations need not add up to the exact length of the segment, so let
    // the last sample run to the end of it, as a single sample already does.
    sampleTimes[sampleTimes.length - 1].segmentEnd = time.segmentEnd;
    return sampleTimes;
  }
};


shaka.text.TextEngine.registerParser(
    'application/mp4; codecs="stpp"', () => new shaka.text.Mp4TtmlParser());
shaka.text.TextEngine.registerParser(
    'application/mp4; codecs="stpp.ttml"',
    () => new shaka.text.Mp4TtmlParser());
shaka.text.TextEngine.registerParser(
    'application/mp4; codecs="stpp.ttml.im1i"',
    () => new shaka.text.Mp4TtmlParser());
shaka.text.TextEngine.registerParser(
    'application/mp4; codecs="stpp.ttml.im1t"',
    () => new shaka.text.Mp4TtmlParser());
shaka.text.TextEngine.registerParser(
    'application/mp4; codecs="stpp.ttml.im2i"',
    () => new shaka.text.Mp4TtmlParser());
shaka.text.TextEngine.registerParser(
    'application/mp4; codecs="stpp.ttml.im2t"',
    () => new shaka.text.Mp4TtmlParser());
shaka.text.TextEngine.registerParser(
    'application/mp4; codecs="stpp.ttml.etd1"',
    () => new shaka.text.Mp4TtmlParser());
shaka.text.TextEngine.registerParser(
    'application/mp4; codecs="stpp.ttml.etd1|im1t"',
    () => new shaka.text.Mp4TtmlParser());
shaka.text.TextEngine.registerParser(
    'application/mp4; codecs="stpp.ttml.im1t|etd1"',
    () => new shaka.text.Mp4TtmlParser());

// Legacy codec string uses capital "TTML", i.e.: prior to HLS rfc8216bis:
//   Note that if a Variant Stream specifies one or more Renditions that
//   include IMSC subtitles, the CODECS attribute MUST indicate this with a
//   format identifier such as "stpp.ttml.im1t".
// (https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-05#section-4.4.5.2)
shaka.text.TextEngine.registerParser(
    'application/mp4; codecs="stpp.TTML.im1t"',
    () => new shaka.text.Mp4TtmlParser());