Source: lib/media/content_workarounds.js

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

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

goog.require('goog.asserts');
goog.require('shaka.device.DeviceFactory');
goog.require('shaka.log');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.Error');
goog.require('shaka.util.Mp4BoxParsers');
goog.require('shaka.util.Mp4Generator');
goog.require('shaka.util.Mp4Parser');
goog.require('shaka.util.Uint8ArrayUtils');


/**
 * @summary
 * A collection of methods to work around content issues on various platforms.
 */
shaka.media.ContentWorkarounds = class {
  /**
   * For EAC-3 audio, the "enca" box ChannelCount field
   * MUST always be set to 2.
   * See ETSI TS 102 366 V1.2.1 Sec F.3
   *
   * Clients SHOULD ignore this value; however, it has been discovered that
   * some user agents don't, and instead, invalid values here can cause decoder
   * failures to be thrown (this has been observed with Chromecast).
   *
   * Given that this value MUST be hard-coded to 2, and that clients "SHALL"
   * ignore the value, it seems safe to manipulate the value to be 2 even when
   * the packager has provided a different value.
   * @param {!BufferSource} initSegmentBuffer
   * @return {!Uint8Array}
   */
  static correctEnca(initSegmentBuffer) {
    const initSegment = shaka.util.BufferUtils.toUint8(initSegmentBuffer);
    const modifiedInitSegment = initSegment;

    /**
     * Skip reserved bytes: 6
     * Skip data_reference_index: 2
     * Skip reserved bytes: 8
     */
    const encaChannelCountOffset = 16;

    /** @type {?DataView} */
    let encaBoxDataView = null;

    new shaka.util.Mp4Parser()
        .boxes(shaka.util.Mp4Parser.SAMPLE_TABLE_PATH,
            shaka.util.Mp4Parser.children)
        .fullBox('stsd', shaka.util.Mp4Parser.sampleDescription)
        .box('enca', (box) => {
          encaBoxDataView = box.reader.getDataView();
          return shaka.util.Mp4Parser.audioSampleEntry(box);
        })
        .box('sinf', shaka.util.Mp4Parser.children)
        .box('frma', (box) => {
          box.parser.stop();
          const {codec} = shaka.util.Mp4BoxParsers.parseFRMA(box.reader);
          if (codec === 'ec-3' &&
                encaBoxDataView &&
                encaBoxDataView.getUint16(encaChannelCountOffset) !== 2
          ) {
            encaBoxDataView.setUint16(encaChannelCountOffset, 2);
          }
        })
        .parse(initSegment);

    return modifiedInitSegment;
  }

  /**
   * Change the box type of the dvvC box from `dvvC` to `free`. This is a
   * content workaround suggested by Dolby for Dolby Vision streams on some
   * chromium-based devices.
   * See https://professionalsupport.dolby.com/s/article/Guidelines-to-developers-building-DASH-HLS-player-apps-for-LG-WebOS-Chromecast-and-other-Chromium-based-app-development-platforms?language=en_US
   * @param {!BufferSource} initSegmentBuffer
   * @return {!Uint8Array}
   */
  static freeDvvcBox(initSegmentBuffer) {
    const initSegment = shaka.util.BufferUtils.toUint8(initSegmentBuffer);

    /** @type {?DataView} */
    let hvcDataView = null;
    let hvcStart = -1;

    new shaka.util.Mp4Parser()
        .boxes(shaka.util.Mp4Parser.SAMPLE_TABLE_PATH,
            shaka.util.Mp4Parser.children)
        .fullBox('stsd', shaka.util.Mp4Parser.sampleDescription)
        .box('hvc1', (box) => {
          hvcDataView = box.reader.getDataView();
          hvcStart = box.start;
          return shaka.util.Mp4Parser.visualSampleEntry(box);
        })
        .box('dvvC', (box) => {
          const ContentWorkarounds = shaka.media.ContentWorkarounds;
          box.parser.stop();
          // box start - hvc1 box start - type size
          const byteOffset = box.start - hvcStart - 4;
          hvcDataView.setUint32(byteOffset, ContentWorkarounds.BOX_TYPE_FREE_);
        })
        .parse(initSegment);

    return initSegment;
  }

  /**
   * Transform the init segment into a new init segment buffer that indicates
   * encryption.  If the init segment already indicates encryption, return the
   * original init segment.
   *
   * Should only be called for MP4 init segments, and only on platforms that
   * need this workaround.
   *
   * @param {!shaka.extern.Stream} stream
   * @param {!BufferSource} initSegmentBuffer
   * @param {?string} uri
   * @param {?string} keySystem
   * @return {!Uint8Array}
   * @see https://github.com/shaka-project/shaka-player/issues/2759
   */
  static fakeEncryption(stream, initSegmentBuffer, uri, keySystem) {
    const ContentWorkarounds = shaka.media.ContentWorkarounds;
    const initSegment = shaka.util.BufferUtils.toUint8(initSegmentBuffer);
    let modifiedInitSegment = initSegment;
    let isEncrypted = false;
    /** @type {shaka.extern.ParsedBox} */
    let stsdBox;
    const ancestorBoxes = [];

    const onSimpleAncestorBox = (box) => {
      ancestorBoxes.push(box);
      shaka.util.Mp4Parser.children(box);
    };

    const onEncryptionMetadataBox = (box) => {
      isEncrypted = true;
      box.parser.stop();
    };

    // Multiplexed content could have multiple boxes that we need to modify.
    // Add to this array in order of box offset.  This will be important later,
    // when we process the boxes.
    /** @type {!Array<{box: shaka.extern.ParsedBox, newType: number}>} */
    const boxesToModify = [];

    const pushEncv = (box) => {
      boxesToModify.push({
        box,
        newType: ContentWorkarounds.BOX_TYPE_ENCV_,
      });
    };

    const pushEnca = (box) => {
      boxesToModify.push({
        box,
        newType: ContentWorkarounds.BOX_TYPE_ENCA_,
      });
    };

    new shaka.util.Mp4Parser()
        .boxes(shaka.util.Mp4Parser.SAMPLE_TABLE_PATH, onSimpleAncestorBox)
        .fullBox('stsd', (box) => {
          stsdBox = box;
          ancestorBoxes.push(box);
          shaka.util.Mp4Parser.sampleDescription(box);
        })
        .boxes([
          'encv',
          'enca',
        ], onEncryptionMetadataBox)
        .boxes(shaka.util.Mp4Parser.AVC_HEVC_VVC_DV, pushEncv)
        .boxes([...shaka.util.Mp4Parser.GENERIC_AUDIO_SAMPLE_ENTRIES, 'mp4a'],
            pushEnca)
        .parse(initSegment);

    if (isEncrypted) {
      shaka.log.debug('Init segment already indicates encryption.');
      return initSegment;
    }

    if (boxesToModify.length == 0 || !stsdBox) {
      shaka.log.error('Failed to find boxes needed to fake encryption!');
      shaka.log.v2('Failed init segment (hex):',
          shaka.util.Uint8ArrayUtils.toHex(initSegment));
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.MEDIA,
          shaka.util.Error.Code.CONTENT_TRANSFORMATION_FAILED,
          uri);
    }

    // Modify boxes in order from largest offset to smallest, so that earlier
    // boxes don't have their offsets changed before we process them.
    boxesToModify.reverse();  // in place!
    for (const workItem of boxesToModify) {
      const insertedBoxType =
          shaka.util.Mp4Parser.typeToString(workItem.newType);
      shaka.log.debug(`Inserting "${insertedBoxType}" box into init segment.`);
      modifiedInitSegment = ContentWorkarounds.insertEncryptionMetadata_(
          stream, modifiedInitSegment, stsdBox, workItem.box, ancestorBoxes,
          workItem.newType, keySystem);
    }

    // Insert PSSH boxes as some platforms require it, i.e. NOS STBs.
    modifiedInitSegment = ContentWorkarounds.insertPsshBoxes_(stream,
        modifiedInitSegment);

    // Edge Windows needs the unmodified init segment to be appended after the
    // patched one, otherwise video element throws following error:
    // CHUNK_DEMUXER_ERROR_APPEND_FAILED: Sample encryption info is not
    // available.
    const device = shaka.device.DeviceFactory.getDevice();
    if (device.requiresClearAndEncryptedInitSegments(keySystem)) {
      const doubleInitSegment = new Uint8Array(initSegment.byteLength +
        modifiedInitSegment.byteLength);
      doubleInitSegment.set(modifiedInitSegment);
      doubleInitSegment.set(initSegment, modifiedInitSegment.byteLength);
      return doubleInitSegment;
    }

    return modifiedInitSegment;
  }

  /**
   * @param {!BufferSource} mediaSegmentBuffer
   * @return {!Uint8Array}
   */
  static fakeMediaEncryption(mediaSegmentBuffer) {
    const mediaSegment = shaka.util.BufferUtils.toUint8(mediaSegmentBuffer);
    const mdatBoxes = [];
    new shaka.util.Mp4Parser()
        .box('mdat', (box) => {
          mdatBoxes.push(box);
        })
        .parse(mediaSegment);

    const newSegmentChunks = [];
    for (let i = 0; i < mdatBoxes.length; i++) {
      const prevMdat = mdatBoxes[i - 1];
      const currMdat = mdatBoxes[i];
      const chunkStart = prevMdat ? prevMdat.start + prevMdat.size : 0;
      const chunkEnd = currMdat.start + currMdat.size;
      const chunk = mediaSegment.subarray(chunkStart, chunkEnd);
      newSegmentChunks.push(
          shaka.media.ContentWorkarounds.fakeMediaEncryptionInChunk_(chunk));
    }
    return shaka.util.Uint8ArrayUtils.concat(...newSegmentChunks);
  }

  /**
   * @param {!Uint8Array} chunk
   * @return {!Uint8Array}
   * @private
   */
  static fakeMediaEncryptionInChunk_(chunk) {
    // Which track from stsd we want to use, 1-based.
    const desiredSampleDescriptionIndex = 2;
    let tfhdBox;
    let trunBox;
    let parsedTfhd;
    let parsedTrun;
    const ancestorBoxes = [];
    const onSimpleAncestorBox = (box) => {
      ancestorBoxes.push(box);
      shaka.util.Mp4Parser.children(box);
    };
    const onTfhdBox = (box) => {
      tfhdBox = box;
      parsedTfhd = shaka.util.Mp4BoxParsers.parseTFHD(box.reader, box.flags);
    };
    const onTrunBox = (box) => {
      trunBox = box;
      parsedTrun = shaka.util.Mp4BoxParsers.parseTRUN(box.reader, box.version,
          box.flags);
    };
    new shaka.util.Mp4Parser()
        .boxes(shaka.util.Mp4Parser.FRAGMENT_PATH, onSimpleAncestorBox)
        .fullBox('tfhd', onTfhdBox)
        .fullBox('trun', onTrunBox)
        .parse(chunk);
    if (parsedTfhd && parsedTfhd.sampleDescriptionIndex !==
        desiredSampleDescriptionIndex) {
      const sdiPosition = tfhdBox.start +
          shaka.util.Mp4Parser.headerSize(tfhdBox) +
          4 + // track_id
          (parsedTfhd.baseDataOffset !== null ? 8 : 0);
      const dataview = shaka.util.BufferUtils.toDataView(chunk);
      if (parsedTfhd.sampleDescriptionIndex !== null) {
        dataview.setUint32(sdiPosition, desiredSampleDescriptionIndex);
      } else {
        const sdiSize = 4; // uint32

        // first, update size & flags of tfhd
        shaka.media.ContentWorkarounds.updateBoxSize_(chunk,
            tfhdBox.start, tfhdBox.size + sdiSize);
        const versionAndFlags = dataview.getUint32(tfhdBox.start + 8);
        dataview.setUint32(tfhdBox.start + 8, versionAndFlags | 0x000002);

        // second, update trun
        if (parsedTrun && parsedTrun.dataOffset !== null) {
          const newDataOffset = parsedTrun.dataOffset + sdiSize;
          const dataOffsetPosition = trunBox.start +
              shaka.util.Mp4Parser.headerSize(trunBox) +
              4; // sample count
          dataview.setInt32(dataOffsetPosition, newDataOffset);
        }
        const beforeSdi = chunk.subarray(0, sdiPosition);
        const afterSdi = chunk.subarray(sdiPosition);
        chunk = new Uint8Array(chunk.byteLength + sdiSize);
        chunk.set(beforeSdi);

        const bytes = [];
        for (let byte = sdiSize - 1; byte >= 0; byte--) {
          bytes.push((desiredSampleDescriptionIndex >> (8 * byte)) & 0xff);
        }
        chunk.set(new Uint8Array(bytes), sdiPosition);
        chunk.set(afterSdi, sdiPosition + sdiSize);
        for (const box of ancestorBoxes) {
          shaka.media.ContentWorkarounds.updateBoxSize_(chunk, box.start,
              box.size + sdiSize);
        }
      }
    }

    return chunk;
  }

  /**
   * Insert an encryption metadata box ("encv" or "enca" box) into the MP4 init
   * segment, based on the source box ("mp4a", "avc1", etc).  Returns a new
   * buffer containing the modified init segment.
   *
   * @param {!shaka.extern.Stream} stream
   * @param {!Uint8Array} initSegment
   * @param {shaka.extern.ParsedBox} stsdBox
   * @param {shaka.extern.ParsedBox} sourceBox
   * @param {!Array<shaka.extern.ParsedBox>} ancestorBoxes
   * @param {number} metadataBoxType
   * @param {?string} keySystem
   * @return {!Uint8Array}
   * @private
   */
  static insertEncryptionMetadata_(
      stream, initSegment, stsdBox, sourceBox, ancestorBoxes, metadataBoxType,
      keySystem) {
    const ContentWorkarounds = shaka.media.ContentWorkarounds;
    const metadataBoxArray = ContentWorkarounds.createEncryptionMetadata_(
        stream, initSegment, sourceBox, metadataBoxType);

    // Construct a new init segment array with room for the encryption metadata
    // box we're adding.
    const newInitSegment =
        new Uint8Array(initSegment.byteLength + metadataBoxArray.byteLength);

    // For Xbox One & Edge, we cut and insert at the start of the source box.
    // For other platforms, we cut and insert at the end of the source box. It's
    // not clear why this is necessary on Xbox One, but it seems to be evidence
    // of another bug in the firmware implementation of MediaSource & EME.
    const device = shaka.device.DeviceFactory.getDevice();
    const cutPoint = device.insertEncryptionDataBeforeClear(keySystem) ?
      sourceBox.start :
      sourceBox.start + sourceBox.size;

    // The data before the cut point will be copied to the same location as
    // before.  The data after that will be appended after the added metadata
    // box.
    const beforeData = initSegment.subarray(0, cutPoint);
    const afterData = initSegment.subarray(cutPoint);

    newInitSegment.set(beforeData);
    newInitSegment.set(metadataBoxArray, cutPoint);
    newInitSegment.set(afterData, cutPoint + metadataBoxArray.byteLength);

    // The parents up the chain from the encryption metadata box need their
    // sizes adjusted to account for the added box.  These offsets should not be
    // changed, because they should all be within the first section we copy.
    for (const box of ancestorBoxes) {
      goog.asserts.assert(box.start < cutPoint,
          'Ancestor MP4 box found in the wrong location!  ' +
          'Modified init segment will not make sense!');
      ContentWorkarounds.updateBoxSize_(
          newInitSegment, box.start, box.size + metadataBoxArray.byteLength);
    }

    // Add one to the sample entries field of the "stsd" box.  This is a 4-byte
    // field just past the box header.
    const stsdBoxView = shaka.util.BufferUtils.toDataView(
        newInitSegment, stsdBox.start);
    const stsdBoxHeaderSize = shaka.util.Mp4Parser.headerSize(stsdBox);
    const numEntries = stsdBoxView.getUint32(stsdBoxHeaderSize);
    stsdBoxView.setUint32(stsdBoxHeaderSize, numEntries + 1);

    return newInitSegment;
  }

  /**
   * Create an encryption metadata box ("encv" or "enca" box), based on the
   * source box ("mp4a", "avc1", etc).  Returns a new buffer containing the
   * encryption metadata box.
   *
   * @param {!shaka.extern.Stream} stream
   * @param {!Uint8Array} initSegment
   * @param {shaka.extern.ParsedBox} sourceBox
   * @param {number} metadataBoxType
   * @return {!Uint8Array}
   * @private
   */
  static createEncryptionMetadata_(stream, initSegment, sourceBox,
      metadataBoxType) {
    const ContentWorkarounds = shaka.media.ContentWorkarounds;

    const mp4Generator = new shaka.util.Mp4Generator();
    const sinfBoxArray = mp4Generator.sinf(stream, sourceBox.name);

    // Create a subarray which points to the source box data.
    const sourceBoxArray = initSegment.subarray(
        /* start= */ sourceBox.start,
        /* end= */ sourceBox.start + sourceBox.size);

    // Create an array to hold the new encryption metadata box, which is based
    // on the source box.
    const metadataBoxArray = new Uint8Array(
        sourceBox.size + sinfBoxArray.byteLength);

    // Copy the source box into the new array.
    metadataBoxArray.set(sourceBoxArray, /* targetOffset= */ 0);

    // Change the box type.
    const metadataBoxView = shaka.util.BufferUtils.toDataView(metadataBoxArray);
    metadataBoxView.setUint32(
        ContentWorkarounds.BOX_TYPE_OFFSET_, metadataBoxType);

    // Append the "sinf" box to the encryption metadata box.
    metadataBoxArray.set(sinfBoxArray, /* targetOffset= */ sourceBox.size);

    // Now update the encryption metadata box size.
    ContentWorkarounds.updateBoxSize_(
        metadataBoxArray, /* boxStart= */ 0, metadataBoxArray.byteLength);

    return metadataBoxArray;
  }

  /**
   * @param {!shaka.extern.Stream} stream
   * @param {!Uint8Array} initSegment
   * @return {!Uint8Array}
   * @private
   */
  static insertPsshBoxes_(stream, initSegment) {
    const psshs = new shaka.util.Mp4Generator().psshs(stream);
    if (psshs.byteLength === 0) {
      return initSegment;
    }

    let moovBoxStart = 0;
    let moovBoxSize = 0;
    new shaka.util.Mp4Parser()
        .box('moov', (box) => {
          moovBoxStart = box.start;
          moovBoxSize = box.size;
          box.parser.stop();
        })
        .parse(initSegment);

    const updatedMoovBoxSize = moovBoxSize + psshs.byteLength;
    const cutPoint = moovBoxStart + moovBoxSize;

    shaka.media.ContentWorkarounds.updateBoxSize_(
        initSegment, moovBoxStart, updatedMoovBoxSize);

    const newInitSegment = new Uint8Array(initSegment.byteLength +
      psshs.byteLength);
    const beforeData = initSegment.subarray(0, cutPoint);
    const afterData = initSegment.subarray(cutPoint);

    newInitSegment.set(beforeData);
    newInitSegment.set(psshs, cutPoint);
    newInitSegment.set(afterData, cutPoint + psshs.byteLength);

    return newInitSegment;
  }

  /**
   * Modify an MP4 box's size field in-place.
   *
   * @param {!Uint8Array} dataArray
   * @param {number} boxStart The start position of the box in dataArray.
   * @param {number} newBoxSize The new size of the box.
   * @private
   */
  static updateBoxSize_(dataArray, boxStart, newBoxSize) {
    const ContentWorkarounds = shaka.media.ContentWorkarounds;
    const boxView = shaka.util.BufferUtils.toDataView(dataArray, boxStart);
    const sizeField = boxView.getUint32(ContentWorkarounds.BOX_SIZE_OFFSET_);
    if (sizeField == 0) { // Means "the rest of the box".
      // No adjustment needed for this box.
    } else if (sizeField == 1) { // Means "use 64-bit size box".
      // Set the 64-bit int in two 32-bit parts.
      // The high bits should definitely be 0 in practice, but we're being
      // thorough here.
      boxView.setUint32(ContentWorkarounds.BOX_SIZE_64_OFFSET_,
          newBoxSize >> 32);
      boxView.setUint32(ContentWorkarounds.BOX_SIZE_64_OFFSET_ + 4,
          newBoxSize & 0xffffffff);
    } else { // Normal 32-bit size field.
      // Not checking the size of the value here, since a box larger than 4GB is
      // unrealistic.
      boxView.setUint32(ContentWorkarounds.BOX_SIZE_OFFSET_, newBoxSize);
    }
  }

  /**
   * Transform the init segment into a new init segment buffer that indicates
   * EC-3 as audio codec instead of AC-3. Even though any EC-3 decoder should
   * be able to decode AC-3 streams, there are platforms that do not accept
   * AC-3 as codec.
   *
   * Should only be called for MP4 init segments, and only on platforms that
   * need this workaround. Returns a new buffer containing the modified init
   * segment.
   *
   * @param {!BufferSource} initSegmentBuffer
   * @return {!Uint8Array}
   */
  static fakeEC3(initSegmentBuffer) {
    const ContentWorkarounds = shaka.media.ContentWorkarounds;
    const initSegment = shaka.util.BufferUtils.toUint8(initSegmentBuffer);
    const ancestorBoxes = [];

    const onSimpleAncestorBox = (box) => {
      ancestorBoxes.push({start: box.start, size: box.size});
      shaka.util.Mp4Parser.children(box);
    };

    new shaka.util.Mp4Parser()
        .boxes(shaka.util.Mp4Parser.SAMPLE_TABLE_PATH, onSimpleAncestorBox)
        .box('stsd', (box) => {
          ancestorBoxes.push({start: box.start, size: box.size});
          const stsdBoxView = shaka.util.BufferUtils.toDataView(
              initSegment, box.start);
          // "size - 3" is because we immediately read a uint32.
          for (let i = 0; i < box.size -3; i++) {
            const codecTag = stsdBoxView.getUint32(i);
            if (codecTag == ContentWorkarounds.BOX_TYPE_AC_3_) {
              stsdBoxView.setUint32(i, ContentWorkarounds.BOX_TYPE_EC_3_);
            } else if (codecTag == ContentWorkarounds.BOX_TYPE_DAC3_) {
              stsdBoxView.setUint32(i, ContentWorkarounds.BOX_TYPE_DEC3_);
            }
          }
        }).parse(initSegment);

    return initSegment;
  }

  /**
   * Some packagers produce broken I-frame only media segments: they clip the
   * byte range down to the first I-frame but leave the moof/trun declaring the
   * whole GOP (many samples) and the mdat declaring the full, un-clipped size.
   * The demuxer then tries to decode samples whose data is not present and
   * fails (e.g. "Failed to prepare video sample for decode").
   *
   * When the mdat is shorter than the trun/mdat headers declare, rewrite the
   * fragment so that it only describes the single I-frame that is actually
   * present, and extend that sample's duration to span the original segment so
   * the buffered timeline stays continuous.  Returns the segment untouched when
   * it is well-formed or when the layout is not one we can safely rewrite.
   *
   * @param {!BufferSource} mediaSegmentBuffer
   * @return {!Uint8Array}
   */
  static fixBrokenIframes(mediaSegmentBuffer) {
    const ContentWorkarounds = shaka.media.ContentWorkarounds;
    const Mp4Parser = shaka.util.Mp4Parser;
    const segment = shaka.util.BufferUtils.toUint8(mediaSegmentBuffer);

    const moofBoxes = [];
    const trafBoxes = [];
    const tfhdBoxes = [];
    const trunBoxes = [];
    let hasEncryptionBoxes = false;

    new Mp4Parser()
        .box('moof', (box) => {
          moofBoxes.push(box);
          Mp4Parser.children(box);
        })
        .box('traf', (box) => {
          trafBoxes.push(box);
          Mp4Parser.children(box);
        })
        .fullBox('tfhd', (box) => {
          tfhdBoxes.push(box);
        })
        .fullBox('trun', (box) => {
          trunBoxes.push(box);
        })
        .boxes([
          'senc',
          'saiz',
          'saio',
        ], () => {
          hasEncryptionBoxes = true;
        })
        .parse(segment, /* partialOkay= */ false, /* stopOnPartial= */ true);

    // Only handle a single, unencrypted movie fragment.  Anything more complex
    // is left untouched.
    if (hasEncryptionBoxes || moofBoxes.length != 1 || trafBoxes.length != 1 ||
        tfhdBoxes.length != 1 || trunBoxes.length != 1) {
      return segment;
    }

    const moofBox = moofBoxes[0];
    const trafBox = trafBoxes[0];
    const tfhdBox = tfhdBoxes[0];
    const trunBox = trunBoxes[0];

    goog.asserts.assert(tfhdBox.flags != null, 'TFHD should have valid flags');
    goog.asserts.assert(trunBox.flags != null, 'TRUN should have valid flags');
    goog.asserts.assert(
        trunBox.version != null, 'TRUN should have a valid version');
    const trunFlags = trunBox.flags;
    const parsedTfhd =
        shaka.util.Mp4BoxParsers.parseTFHD(tfhdBox.reader, tfhdBox.flags);
    const parsedTrun = shaka.util.Mp4BoxParsers.parseTRUN(
        trunBox.reader, trunBox.version, trunBox.flags);

    // A base_data_offset or an implicit data offset means the sample data is
    // addressed in ways our byte removal would invalidate.
    if (parsedTfhd.baseDataOffset != null ||
        !(trunFlags & 0x000001) || parsedTrun.dataOffset == null) {
      return segment;
    }

    const mdatStart = moofBox.start + moofBox.size;
    const dataView = shaka.util.BufferUtils.toDataView(segment);
    if (mdatStart + 8 > segment.byteLength ||
        dataView.getUint32(mdatStart + ContentWorkarounds.BOX_TYPE_OFFSET_) !=
            ContentWorkarounds.BOX_TYPE_MDAT_) {
      return segment;
    }

    let mdatSize = dataView.getUint32(mdatStart);
    const mdatHeaderSize = 8;
    if (mdatSize == 1) {
      // A 64-bit mdat size is not something we expect for I-frame segments.
      return segment;
    } else if (mdatSize == 0) {
      mdatSize = segment.byteLength - mdatStart;
    }

    const availablePayload = segment.byteLength - (mdatStart + mdatHeaderSize);
    const declaredPayload = mdatSize - mdatHeaderSize;

    // If all the declared data is present, the fragment is well-formed.
    if (availablePayload >= declaredPayload) {
      return segment;
    }

    // Count how many whole samples fit in the bytes that are actually present.
    let keptPayloadBytes = 0;
    let keepCount = 0;
    for (const sample of parsedTrun.sampleData) {
      const size = sample.sampleSize != null ?
          sample.sampleSize : parsedTfhd.defaultSampleSize;
      if (size == null) {
        return segment;
      }
      if (keptPayloadBytes + size > availablePayload) {
        break;
      }
      keptPayloadBytes += size;
      keepCount++;
    }

    // We only handle the case where exactly the first sample (the I-frame) is
    // present out of a larger declared GOP.
    if (keepCount != 1 || keepCount >= parsedTrun.sampleCount) {
      return segment;
    }

    // Preserve the original segment duration so the single I-frame spans the
    // whole slot and the buffered timeline stays continuous.
    const hasPerSampleDuration = (trunFlags & 0x000100) != 0;
    let originalTotalDuration = 0;
    for (const sample of parsedTrun.sampleData) {
      const duration = hasPerSampleDuration ?
          sample.sampleDuration : parsedTfhd.defaultSampleDuration;
      if (duration == null) {
        return segment;
      }
      originalTotalDuration += duration;
    }

    const trunHeaderSize = Mp4Parser.headerSize(trunBox);
    // trun payload layout: sample_count(4) [+ data_offset(4)]
    // [+ first_sample_flags(4)] then the per-sample entries.
    let sampleEntriesStart = trunBox.start + trunHeaderSize + 4;
    sampleEntriesStart += 4; // data_offset, guaranteed present above.
    if (trunFlags & 0x000004) {
      sampleEntriesStart += 4; // first_sample_flags
    }

    let perSampleEntrySize = 0;
    if (trunFlags & 0x000100) {
      perSampleEntrySize += 4; // duration
    }
    if (trunFlags & 0x000200) {
      perSampleEntrySize += 4; // size
    }
    if (trunFlags & 0x000400) {
      perSampleEntrySize += 4; // flags
    }
    if (trunFlags & 0x000800) {
      perSampleEntrySize += 4; // composition time offset
    }

    const removedSamples = parsedTrun.sampleCount - keepCount;
    const bytesRemoved = removedSamples * perSampleEntrySize;
    const keepEntriesEnd = sampleEntriesStart + keepCount * perSampleEntrySize;

    // The removed sample entries must be the tail of the moof, immediately
    // followed by the mdat, or the layout is more complex than we can rewrite.
    if (keepEntriesEnd + bytesRemoved != mdatStart) {
      return segment;
    }

    // Build the fixed fragment: patched moof header + kept sample entry,
    // followed by an mdat that only holds the bytes we actually have.
    const front = segment.slice(0, keepEntriesEnd);
    const frontView = shaka.util.BufferUtils.toDataView(front);

    // trun: sample_count -> keepCount, data_offset shifted by removed bytes.
    frontView.setUint32(trunBox.start + trunHeaderSize, keepCount);
    frontView.setInt32(trunBox.start + trunHeaderSize + 4,
        parsedTrun.dataOffset - bytesRemoved);

    // Extend the kept sample's duration to the original segment duration.
    if (hasPerSampleDuration) {
      // The duration is the first per-sample field.
      frontView.setUint32(sampleEntriesStart, originalTotalDuration);
    } else {
      // The duration comes from tfhd's default_sample_duration.
      let durationOffset =
          tfhdBox.start + Mp4Parser.headerSize(tfhdBox) + 4; // track_ID
      if (parsedTfhd.sampleDescriptionIndex != null) {
        durationOffset += 4;
      }
      frontView.setUint32(durationOffset, originalTotalDuration);
    }

    // Shrink the container boxes to account for the removed sample entries.
    ContentWorkarounds.updateBoxSize_(
        front, trunBox.start, trunBox.size - bytesRemoved);
    ContentWorkarounds.updateBoxSize_(
        front, trafBox.start, trafBox.size - bytesRemoved);
    ContentWorkarounds.updateBoxSize_(
        front, moofBox.start, moofBox.size - bytesRemoved);

    // Rebuild the mdat header with the corrected size, then append only the
    // payload bytes that are actually present.
    const mdatHeader = segment.slice(mdatStart, mdatStart + mdatHeaderSize);
    ContentWorkarounds.updateBoxSize_(
        mdatHeader, 0, mdatHeaderSize + keptPayloadBytes);
    const mdatPayload = segment.subarray(mdatStart + mdatHeaderSize,
        mdatStart + mdatHeaderSize + keptPayloadBytes);

    return shaka.util.Uint8ArrayUtils.concat(front, mdatHeader, mdatPayload);
  }
};

/**
 * Offset to a box's size field.
 *
 * @const {number}
 * @private
 */
shaka.media.ContentWorkarounds.BOX_SIZE_OFFSET_ = 0;

/**
 * Offset to a box's type field.
 *
 * @const {number}
 * @private
 */
shaka.media.ContentWorkarounds.BOX_TYPE_OFFSET_ = 4;

/**
 * Offset to a box's 64-bit size field, if it has one.
 *
 * @const {number}
 * @private
 */
shaka.media.ContentWorkarounds.BOX_SIZE_64_OFFSET_ = 8;

/**
 * Box type for "mdat".
 *
 * @const {number}
 * @private
 */
shaka.media.ContentWorkarounds.BOX_TYPE_MDAT_ = 0x6d646174;

/**
 * Box type for "encv".
 *
 * @const {number}
 * @private
 */
shaka.media.ContentWorkarounds.BOX_TYPE_ENCV_ = 0x656e6376;

/**
 * Box type for "enca".
 *
 * @const {number}
 * @private
 */
shaka.media.ContentWorkarounds.BOX_TYPE_ENCA_ = 0x656e6361;

/**
 * Box type for "ac-3".
 *
 * @const {number}
 * @private
 */
shaka.media.ContentWorkarounds.BOX_TYPE_AC_3_ = 0x61632d33;

/**
 * Box type for "dac3".
 *
 * @const {number}
 * @private
 */
shaka.media.ContentWorkarounds.BOX_TYPE_DAC3_ = 0x64616333;

/**
 * Box type for "ec-3".
 *
 * @const {number}
 * @private
 */
shaka.media.ContentWorkarounds.BOX_TYPE_EC_3_ = 0x65632d33;

/**
 * Box type for "free".
 *
 * @const {number}
 * @private
 */
shaka.media.ContentWorkarounds.BOX_TYPE_FREE_ = 0x66726565;

/**
 * Box type for "dec3".
 *
 * @const {number}
 * @private
 */
shaka.media.ContentWorkarounds.BOX_TYPE_DEC3_ = 0x64656333;