Source: index.js

'use strict';

const BufferPool = require('./lib/buffer-pool');

const { Transform } = require('stream');

/*
todo after version 0.7.0
const { deprecate } = require('util');
*/

/**
 * @file
 * <ul>
 * <li>Creates a stream transform for piping a fmp4 (fragmented mp4) from ffmpeg.</li>
 * <li>Can be used to generate a fmp4 m3u8 HLS playlist and compatible file fragments.</li>
 * <li>Can be used for storing past segments of the mp4 video in a buffer for later access.</li>
 * <li>Must use the following ffmpeg args <b><i>-movflags +frag_keyframe+empty_moov+default_base_moof</i></b> to generate
 * a valid fmp4 with a compatible file structure : ftyp+moov -> moof+mdat -> moof+mdat ...</li>
 * </ul>
 * @extends stream.Transform
 */
class Mp4Frag extends Transform {
  /* ----> static private fields <---- */
  static #ERR = { invalidArg: 'ERR_INVALID_ARG', chunkParse: 'ERR_CHUNK_PARSE', chunkLength: 'ERR_CHUNK_LENGTH' };
  static #HLS_INIT_DEF = true; // initialize hls playlist before 1st segment
  static #HLS_SIZE = { def: 4, min: 2, max: 20 }; // hls playlist size
  static #HLS_EXTRA = { def: 0, min: 0, max: 10 }; // hls playlist extra segments in memory
  static #SEG_SIZE = { def: 2, min: 2, max: 30 }; // segment list size
  static #FTYP = Mp4Frag.#boxFrom([0x66, 0x74, 0x79, 0x70]); // ftyp
  static #MOOV = Mp4Frag.#boxFrom([0x6d, 0x6f, 0x6f, 0x76]); // moov
  static #MDHD = Mp4Frag.#boxFrom([0x6d, 0x64, 0x68, 0x64]); // mdhd
  static #MOOF = Mp4Frag.#boxFrom([0x6d, 0x6f, 0x6f, 0x66]); // moof
  static #MDAT = Mp4Frag.#boxFrom([0x6d, 0x64, 0x61, 0x74]); // mdat
  static #TFHD = Mp4Frag.#boxFrom([0x74, 0x66, 0x68, 0x64]); // tfhd
  static #TRUN = Mp4Frag.#boxFrom([0x74, 0x72, 0x75, 0x6e]); // trun
  static #MFRA = Mp4Frag.#boxFrom([0x6d, 0x66, 0x72, 0x61]); // mfra
  static #HVCC = Mp4Frag.#boxFrom([0x68, 0x76, 0x63, 0x43]); // hvcC
  static #HEV1 = Mp4Frag.#boxFrom([0x68, 0x65, 0x76, 0x31]); // hev1
  static #HVC1 = Mp4Frag.#boxFrom([0x68, 0x76, 0x63, 0x31]); // hvc1
  static #AVCC = Mp4Frag.#boxFrom([0x61, 0x76, 0x63, 0x43]); // avcC
  static #AVC1 = Mp4Frag.#boxFrom([0x61, 0x76, 0x63, 0x31]); // avc1
  static #AVC2 = Mp4Frag.#boxFrom([0x61, 0x76, 0x63, 0x32]); // avc2
  static #AVC3 = Mp4Frag.#boxFrom([0x61, 0x76, 0x63, 0x33]); // avc3
  static #AVC4 = Mp4Frag.#boxFrom([0x61, 0x76, 0x63, 0x34]); // avc4
  static #MP4A = Mp4Frag.#boxFrom([0x6d, 0x70, 0x34, 0x61]); // mp4a
  static #ESDS = Mp4Frag.#boxFrom([0x65, 0x73, 0x64, 0x73]); // esds

  /* ----> private method placeholders <---- */
  #bufferConcat = Buffer.concat; // will be reassigned if setting pool > 0
  #parseChunk = this.#noop; // reassigned after each box parsing is complete
  #setKeyframe = this.#noop; // placeholder for #setKeyframeAVCC() | #setKeyframeHECC()
  #sendInit = this.#sendInitAsBuffer; // will be reassigned if setting readableObjectMode to true
  #sendSegment = this.#sendSegmentAsBuffer; // will be reassigned if setting readableObjectMode to true

  /* ----> private fields <---- */
  #hlsPlaylist = undefined;
  #segmentCount = 0;
  #bufferPool = 0;
  #poolLength = 0;
  #ftypSize = 0;
  #moovSize = 0;
  #ftypMoovSize = 0;
  #ftypMoovChunks = [];
  #ftypMoovChunksTotalLength = 0;
  #moofSize = 0;
  #mdatSize = 0;
  #moofMdatSize = 0;
  #moofMdatChunks = [];
  #moofMdatChunksTotalLength = 0;
  #smallChunk = undefined; // to be used when chunk is less than 8 bytes and moof/mdat box index cannot be found

  /* ----> private fields with getters (readonly) <---- */
  #initialization;
  #audioCodec;
  #videoCodec;
  #mime;
  #timescale;
  #segment;
  #sequence;
  #duration;
  #timestamp;
  #keyframe;
  #segmentObjects;
  #totalDuration;
  #totalByteLength;
  #allKeyframes;
  #m3u8;

  /**
   * @constructor
   * @param {object} [options] - Configuration options.
   * @param {boolean} [options.readableObjectMode = false] - If true, segments will be piped out as an object instead of a Buffer.
   * @param {string} [options.hlsPlaylistBase] - Base name of files in m3u8 playlist. Must only contain letters and underscores. Must be set to generate m3u8 playlist. e.g. 'front_door'.
   * @param {number} [options.hlsPlaylistSize = 4] - Number of segments to use in m3u8 playlist. Must be an integer ranging from 2 to 20.
   * @param {number} [options.hlsPlaylistExtra = 0] - Number of extra segments to keep in memory. Must be an integer ranging from 0 to 10.
   * @param {boolean} [options.hlsPlaylistInit = true] - Indicates that m3u8 playlist should be generated after [initialization]{@link Mp4Frag#initialization} is created and before media segments are created.
   * @param {number} [options.segmentCount = 2] - Number of segments to keep in memory. If using hlsPlaylistBase, value will be calculated from hlsPlaylistSize + hlsPlaylistExtra. Must be an integer ranging from 2 to 30.
   * @param {number} [options.pool = 0] - Reuse pooled ArrayBuffer allocations to reduce garbage collection. Set to 1 to activate. Experimental.
   * @throws Will throw an error if options.hlsPlaylistBase contains characters other than letters(a-zA-Z) and underscores(_).
   */
  constructor(options) {
    options = options instanceof Object ? options : {};
    super({ writableObjectMode: false, readableObjectMode: options.readableObjectMode === true });
    if (typeof options.hlsPlaylistBase !== 'undefined') {
      if (/[^a-z_]/gi.test(options.hlsPlaylistBase)) {
        throw Mp4Frag.#createError('hlsPlaylistBase must only contain underscores and letters (_, a-z, A-Z)', Mp4Frag.#ERR.invalidArg);
        /*return process.nextTick(() => {
          this.#emitError('hlsPlaylistBase must only contain underscores and letters (_, a-z, A-Z)', Mp4Frag.#ERR.invalidArg);
        });*/
      }
      this.#hlsPlaylist = {
        base: options.hlsPlaylistBase,
        init: Mp4Frag.#validateBool(options.hlsPlaylistInit, Mp4Frag.#HLS_INIT_DEF),
        size: Mp4Frag.#validateInt(options.hlsPlaylistSize, Mp4Frag.#HLS_SIZE.def, Mp4Frag.#HLS_SIZE.min, Mp4Frag.#HLS_SIZE.max),
        extra: Mp4Frag.#validateInt(options.hlsPlaylistExtra, Mp4Frag.#HLS_EXTRA.def, Mp4Frag.#HLS_EXTRA.min, Mp4Frag.#HLS_EXTRA.max),
      };
      this.#segmentCount = this.#hlsPlaylist.size + this.#hlsPlaylist.extra;
      this.#segmentObjects = [];
    } else if (typeof options.segmentCount !== 'undefined') {
      this.#segmentCount = Mp4Frag.#validateInt(options.segmentCount, Mp4Frag.#SEG_SIZE.def, Mp4Frag.#SEG_SIZE.min, Mp4Frag.#SEG_SIZE.max);
      this.#segmentObjects = [];
    }
    if (options.pool > 0) {
      this.#poolLength = (this.#segmentCount || 1) + options.pool;
      this.#bufferPool = new BufferPool({ length: this.#poolLength });
      this.#bufferConcat = this.#bufferPool.concat.bind(this.#bufferPool);
    }
    if (options.readableObjectMode === true) {
      this.#sendInit = this.#sendInitAsObject;
      this.#sendSegment = this.#sendSegmentAsObject;
    }
    /*
    todo after version 0.7.0
    this.on('newListener', event => {
      if (event === 'initialized') {
        deprecate(() => {}, '"initialized" event will be removed in version >= 0.8.0. Please use "data" event and check for type: init.')();
      } else if (event === 'segment') {
        deprecate(() => {}, '"segment" event will be removed in version >= 0.8.0. Please use "data" event and check for type: segment.')();
      }
    });
    */
    this.#parseChunk = this.#findFtyp;
  }

  /**
   * @readonly
   * @property {string|null} audioCodec
   * - Returns the audio codec information as a <b>string</b>.
   * <br/>
   * - Returns <b>null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
   * @returns {string|null}
   */
  get audioCodec() {
    return this.#audioCodec || null;
  }

  /**
   * @readonly
   * @property {string|null} videoCodec
   * - Returns the video codec information as a <b>string</b>.
   * <br/>
   * - Returns <b>null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
   * @returns {string|null}
   */
  get videoCodec() {
    return this.#videoCodec || null;
  }

  /**
   * @readonly
   * @property {string|null} mime
   * - Returns the mime type information as a <b>string</b>.
   * <br/>
   * - Returns <b>null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
   * @returns {string|null}
   */
  get mime() {
    return this.#mime || null;
  }

  /**
   * @readonly
   * @property {number} timescale
   * - Returns the timescale information as a <b>number</b>.
   * <br/>
   * - Returns <b>-1</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
   * @returns {number}
   */
  get timescale() {
    return this.#timescale || -1;
  }

  /**
   * @readonly
   * @property {Buffer|null} initialization
   * - Returns the Mp4 initialization fragment as a <b>Buffer</b>.
   * <br/>
   * - Returns <b>null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
   * @returns {Buffer|null}
   */
  get initialization() {
    return this.#initialization || null;
  }

  /**
   * @readonly
   * @property {number} poolLength
   * - Returns the number of array buffers in pool
   * <br/>
   * - Returns <b>-1</b> if pool not in use.
   * @returns {number}
   */
  get poolLength() {
    return this.#poolLength || -1;
  }

  /**
   * @readonly
   * @property {Buffer|null} segment
   * - Returns the latest Mp4 segment as a <b>Buffer</b>.
   * <br/>
   * - Returns <b>null</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
   * @returns {Buffer|null}
   */
  get segment() {
    return this.#segment || null;
  }

  /**
   * @readonly
   * @property {object} segmentObject
   * - Returns the latest Mp4 segment as an <b>object</b>.
   * <br/>
   *  - <b><code>{segment, sequence, duration, timestamp, keyframe}</code></b>
   * <br/>
   * - Returns <b>{segment: null, sequence: -1, duration: -1; timestamp: -1, keyframe: true}</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
   * @returns {object}
   */
  get segmentObject() {
    return {
      segment: this.segment,
      sequence: this.sequence,
      duration: this.duration,
      timestamp: this.timestamp,
      keyframe: this.keyframe,
    };
  }

  /**
   * @readonly
   * @property {number} timestamp
   * - Returns the timestamp of the latest Mp4 segment as an <b>Integer</b>(<i>milliseconds</i>).
   * <br/>
   * - Returns <b>-1</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
   * @returns {number}
   */
  get timestamp() {
    return this.#timestamp || -1;
  }

  /**
   * @readonly
   * @property {number} duration
   * - Returns the duration of latest Mp4 segment as a <b>Float</b>(<i>seconds</i>).
   * <br/>
   * - Returns <b>-1</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
   * @returns {number}
   */
  get duration() {
    return this.#duration || -1;
  }

  /**
   * @readonly
   * @property {number} totalDuration
   * - Returns the total duration of all Mp4 segments as a <b>Float</b>(<i>seconds</i>).
   * <br/>
   * - Returns <b>-1</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
   * @returns {number}
   */
  get totalDuration() {
    return this.#totalDuration || -1;
  }

  /**
   * @readonly
   * @property {number} totalByteLength
   * - Returns the total byte length of the Mp4 initialization and all Mp4 segments as an <b>Integer</b>(<i>bytes</i>).
   * <br/>
   * - Returns <b>-1</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
   * @returns {number}
   */
  get totalByteLength() {
    return this.#totalByteLength || -1;
  }

  /**
   * @readonly
   * @property {string|null} m3u8
   * - Returns the fmp4 HLS m3u8 playlist as a <b>string</b>.
   * <br/>
   * - Returns <b>null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
   * @returns {string|null}
   */
  get m3u8() {
    return this.#m3u8 || null;
  }

  /**
   * @readonly
   * @property {number} sequence
   * - Returns the sequence of the latest Mp4 segment as an <b>Integer</b>.
   * <br/>
   * - Returns <b>-1</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
   * @returns {number}
   */
  get sequence() {
    return Number.isInteger(this.#sequence) ? this.#sequence : -1;
  }

  /**
   * @readonly
   * @property {boolean} keyframe
   * - Returns a boolean indicating if the current segment contains a keyframe.
   * <br/>
   * - Returns <b>false</b> if the current segment does not contain a keyframe.
   * <br/>
   * - Returns <b>true</b> if segment only contains audio.
   * @returns {boolean}
   */
  get keyframe() {
    return typeof this.#keyframe === 'boolean' ? this.#keyframe : true;
  }

  /**
   * @readonly
   * @property {boolean} allKeyframes
   * - Returns a boolean indicating if all segments contain a keyframe.
   * <br/>
   * - Returns <b>false</b> if any segments do not contain a keyframe.
   * @returns {boolean}
   */
  get allKeyframes() {
    return typeof this.#allKeyframes === 'boolean' ? this.#allKeyframes : true;
  }

  /**
   * @readonly
   * @property {Array|null} segmentObjects
   * - Returns the Mp4 segments as an <b>Array</b> of <b>objects</b>
   * <br/>
   * - <b><code>[{segment, sequence, duration, timestamp, keyframe},...]</code></b>
   * <br/>
   * - Returns <b>null</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
   * @returns {Array|null}
   */
  get segmentObjects() {
    return this.#segmentObjects && this.#segmentObjects.length ? this.#segmentObjects : null;
  }

  /**
   * @param {number|string} sequence - sequence number
   * - Returns the Mp4 segment that corresponds to the numbered sequence as an <b>object</b>.
   * <br/>
   * - <b><code>{segment, sequence, duration, timestamp, keyframe}</code></b>
   * <br/>
   * - Returns <b>null</b> if there is no segment that corresponds to sequence number.
   * @returns {object|null}
   */
  getSegmentObject(sequence) {
    sequence = Number.parseInt(sequence);
    if (this.#segmentObjects && this.#segmentObjects.length) {
      return this.#segmentObjects[this.#segmentObjects.length - 1 - (this.#sequence - sequence)] || null;
    }
    return null;
  }

  /**
   * Clear cached values
   */
  resetCache() {
    /*
    todo after version 0.7.0
    deprecate
    */
    this.reset();
  }

  /**
   * Clear cached values
   */
  reset() {
    this.emit('reset');
    this.#parseChunk = this.#findFtyp;
    if (this.#segmentObjects) {
      this.#segmentObjects = [];
    }
    this.#timescale = undefined;
    this.#sequence = undefined;
    this.#allKeyframes = undefined;
    this.#keyframe = undefined;
    this.#mime = undefined;
    this.#videoCodec = undefined;
    this.#audioCodec = undefined;
    this.#initialization = undefined;
    this.#segment = undefined;
    this.#timestamp = undefined;
    this.#duration = undefined;
    this.#totalDuration = undefined;
    this.#totalByteLength = undefined;
    this.#m3u8 = undefined;
    this.#setKeyframe = this.#noop;
    this.#resetFtypMoov();
    this.#resetMoofMdat();
    if (this.#bufferPool) {
      this.#bufferPool.reset();
    }
  }

  /**
   *
   * @returns {object}
   */
  toJSON() {
    return {
      initialization: this.initialization,
      audioCodec: this.audioCodec,
      videoCodec: this.videoCodec,
      mime: this.mime,
      timescale: this.timescale,
      poolLength: this.poolLength,
      segmentObject: this.segmentObject,
      segmentObjects: this.segmentObjects,
      totalDuration: this.totalDuration,
      totalByteLength: this.totalByteLength,
      allKeyframes: this.allKeyframes,
      m3u8: this.m3u8,
    };
  }

  /**
   * @private
   */
  #noop() {}

  /**
   *
   * @param {string} msg
   * @param {string} code
   * @private
   */
  #emitError(msg, code) {
    this.#parseChunk = this.#noop;
    this.emit('error', Mp4Frag.#createError(msg, code));
  }

  /**
   * Search buffer for ftyp.
   * @param {Buffer} chunk
   * @private
   */
  #findFtyp(chunk) {
    const chunkLength = chunk.length;
    if (chunk.indexOf(Mp4Frag.#FTYP) === 4) {
      this.#ftypSize = chunk.readUInt32BE(0);
      if (this.#ftypSize === chunkLength) {
        this.#ftypMoovChunks.push(chunk);
        this.#ftypMoovChunksTotalLength += chunkLength;
        this.#parseChunk = this.#findMoov;
      } else if (this.#ftypSize < chunkLength) {
        // recursive
        this.#ftypMoovChunks.push(chunk.subarray(0, this.#ftypSize));
        this.#ftypMoovChunksTotalLength += this.#ftypSize;
        const nextChunk = chunk.subarray(this.#ftypSize);
        this.#parseChunk = this.#findMoov;
        this.#parseChunk(nextChunk);
      } else {
        this.#emitError(`ftypSize:${this.#ftypSize} > chunkLength:${chunkLength}.`, Mp4Frag.#ERR.chunkLength);
      }
    } else {
      this.#emitError(`${Mp4Frag.#FTYP.toString()} not found. chunkLength:${chunkLength}.`, Mp4Frag.#ERR.chunkParse);
    }
  }

  /**
   * Search buffer for moov.
   * @param {Buffer} chunk
   * @private
   */
  #findMoov(chunk) {
    const chunkLength = chunk.length;
    if (chunk.indexOf(Mp4Frag.#MOOV) === 4) {
      this.#moovSize = chunk.readUInt32BE(0);
      this.#ftypMoovSize = this.#ftypSize + this.#moovSize;
      if (this.#moovSize === chunkLength) {
        this.#ftypMoovChunks.push(chunk);
        this.#ftypMoovChunksTotalLength += chunkLength;
        this.#handleFtypMoov();
        this.#parseChunk = this.#findMoof;
      } else if (this.#moovSize < chunkLength) {
        // recursive
        this.#ftypMoovChunks.push(chunk.subarray(0, this.#moovSize));
        this.#ftypMoovChunksTotalLength += this.#moovSize;
        const nextChunk = chunk.subarray(this.#moovSize);
        this.#handleFtypMoov();
        this.#parseChunk = this.#findMoof;
        this.#parseChunk(nextChunk);
      } else {
        this.#emitError(`moovSize:${this.#moovSize} > chunkLength:${chunkLength}.`, Mp4Frag.#ERR.chunkLength);
      }
    } else {
      this.#emitError(`${Mp4Frag.#MOOV.toString()} not found. chunkLength:${chunkLength}.`, Mp4Frag.#ERR.chunkParse);
    }
  }

  #resetFtypMoov() {
    this.#ftypSize = this.#moovSize = this.#ftypMoovSize = this.#ftypMoovChunks.length = this.#ftypMoovChunksTotalLength = 0;
  }

  #handleFtypMoov() {
    const ftypMoov = ((list, totalLength) => {
      if (list.length === 2) {
        const [ftyp, moov] = list;
        if (ftyp.buffer === moov.buffer && ftyp.buffer.byteLength === totalLength) {
          return Buffer.from(ftyp.buffer);
        }
      }
      let bytesCopied = 0;
      const buffer = Buffer.allocUnsafeSlow(totalLength);
      list.forEach(chunk => {
        bytesCopied += chunk.copy(buffer, bytesCopied);
      });
      return buffer;
    })(this.#ftypMoovChunks, this.#ftypMoovChunksTotalLength);
    this.#resetFtypMoov();
    this.#initialize(ftypMoov);
  }

  /**
   * Search buffer for moof.
   * @param {Buffer} chunk
   * @private
   */
  #findMoof(chunk) {
    const chunkLength = chunk.length;
    if (this.#moofSize) {
      if (this.#moofSize === this.#moofMdatChunksTotalLength + chunkLength) {
        this.#moofMdatChunks.push(chunk);
        this.#moofMdatChunksTotalLength += chunkLength;
        this.#parseChunk = this.#findMdat;
      } else if (this.#moofSize < this.#moofMdatChunksTotalLength + chunkLength) {
        // recursive
        const finalChunkSize = this.#moofSize - this.#moofMdatChunksTotalLength;
        this.#moofMdatChunks.push(chunk.subarray(0, finalChunkSize));
        this.#moofMdatChunksTotalLength += finalChunkSize;
        const nextChunk = chunk.subarray(finalChunkSize);
        this.#parseChunk = this.#findMdat;
        this.#parseChunk(nextChunk);
      } else {
        this.#moofMdatChunks.push(chunk);
        this.#moofMdatChunksTotalLength += chunkLength;
      }
    } else {
      if (chunk.indexOf(Mp4Frag.#MOOF) === 4) {
        this.#moofSize = chunk.readUInt32BE(0);
        if (this.#moofSize === chunkLength) {
          this.#moofMdatChunks.push(chunk);
          this.#moofMdatChunksTotalLength += chunkLength;
          this.#parseChunk = this.#findMdat;
        } else if (this.#moofSize < chunkLength) {
          // recursive
          this.#moofMdatChunks.push(chunk.subarray(0, this.#moofSize));
          this.#moofMdatChunksTotalLength += this.#moofSize;
          const nextChunk = chunk.subarray(this.#moofSize);
          this.#parseChunk = this.#findMdat;
          this.#parseChunk(nextChunk);
        } else {
          this.#moofMdatChunks.push(chunk);
          this.#moofMdatChunksTotalLength += chunkLength;
        }
      } else {
        if (chunk.indexOf(Mp4Frag.#MFRA) === 4) {
          // console.log(`\nend of segments ${Mp4Frag.#MFRA.toString()}\n`);
          this.#parseChunk = this.#noop;
        } else {
          if (this.#smallChunk) {
            // recursive
            const repairedChunk = Buffer.concat([this.#smallChunk, chunk]);
            this.#smallChunk = undefined;
            this.#parseChunk(repairedChunk);
          } else if (chunkLength < 8) {
            this.#smallChunk = chunk;
          } else {
            this.#emitError(`${Mp4Frag.#MOOF.toString()} not found. chunkLength:${chunkLength}.`, Mp4Frag.#ERR.chunkParse);
          }
        }
      }
    }
  }

  /**
   * Search buffer for mdat.
   * @param {Buffer} chunk
   * @private
   */
  #findMdat(chunk) {
    const chunkLength = chunk.length;
    if (this.#mdatSize) {
      if (this.#moofMdatSize === this.#moofMdatChunksTotalLength + chunkLength) {
        this.#moofMdatChunks.push(chunk);
        this.#moofMdatChunksTotalLength += chunkLength;
        this.#handleMoofMdat();
        this.#parseChunk = this.#findMoof;
      } else if (this.#moofMdatSize < this.#moofMdatChunksTotalLength + chunkLength) {
        // recursive
        const finalChunkSize = this.#moofMdatSize - this.#moofMdatChunksTotalLength;
        this.#moofMdatChunks.push(chunk.subarray(0, finalChunkSize));
        this.#moofMdatChunksTotalLength += finalChunkSize;
        const nextChunk = chunk.subarray(finalChunkSize);
        this.#handleMoofMdat();
        this.#parseChunk = this.#findMoof;
        this.#parseChunk(nextChunk);
      } else {
        this.#moofMdatChunks.push(chunk);
        this.#moofMdatChunksTotalLength += chunkLength;
      }
    } else {
      if (chunk.indexOf(Mp4Frag.#MDAT) === 4) {
        this.#mdatSize = chunk.readUInt32BE(0);
        this.#moofMdatSize = this.#moofSize + this.#mdatSize;
        if (this.#mdatSize === chunkLength) {
          this.#moofMdatChunks.push(chunk);
          this.#moofMdatChunksTotalLength += chunkLength;
          this.#handleMoofMdat();
          this.#parseChunk = this.#findMoof;
        } else if (this.#mdatSize < chunkLength) {
          // recursive
          this.#moofMdatChunks.push(chunk.subarray(0, this.#mdatSize));
          this.#moofMdatChunksTotalLength += this.#mdatSize;
          const nextChunk = chunk.subarray(this.#mdatSize);
          this.#handleMoofMdat();
          this.#parseChunk = this.#findMoof;
          this.#parseChunk(nextChunk);
        } else {
          this.#moofMdatChunks.push(chunk);
          this.#moofMdatChunksTotalLength += chunkLength;
        }
      } else {
        if (this.#smallChunk) {
          const repairedChunk = Buffer.concat([this.#smallChunk, chunk]);
          this.#smallChunk = undefined;
          this.#parseChunk(repairedChunk);
        } else if (chunkLength < 8) {
          this.#smallChunk = chunk;
        } else {
          this.#emitError(`${Mp4Frag.#MDAT.toString()} not found. chunkLength:${chunkLength}.`, Mp4Frag.#ERR.chunkParse);
        }
      }
    }
  }

  #resetMoofMdat() {
    this.#moofSize = this.#mdatSize = this.#moofMdatSize = this.#moofMdatChunks.length = this.#moofMdatChunksTotalLength = 0;
  }

  #handleMoofMdat() {
    const moofMdat = ((list, totalLength) => {
      if (list.length === 2) {
        const [moof, mdat] = list;
        if (moof.buffer === mdat.buffer && moof.byteOffset + moof.length === mdat.byteOffset) {
          return Buffer.from(moof.buffer, moof.byteOffset, totalLength);
        }
      }
      return this.#bufferConcat(list, totalLength);
    })(this.#moofMdatChunks, this.#moofMdatChunksTotalLength);
    this.#resetMoofMdat();
    this.#setSegment(moofMdat);
  }

  /**
   * Parse moov for mime.
   * @fires Mp4Frag#initialized
   * @param {Buffer} chunk
   * @private
   */
  #initialize(chunk) {
    this.#initialization = chunk;
    const mdhdIndex = chunk.indexOf(Mp4Frag.#MDHD);
    const mdhdVersion = chunk[mdhdIndex + 4];
    this.#timescale = chunk.readUInt32BE(mdhdIndex + (mdhdVersion === 0 ? 16 : 24));
    this.#timestamp = Date.now();
    this.#sequence = -1;
    this.#allKeyframes = true;
    this.#totalDuration = 0;
    this.#totalByteLength = chunk.byteLength;
    const codecs = [];
    let mp4Type;
    if (this.#parseCodecAVCC(chunk) || this.#parseCodecHVCC(chunk)) {
      codecs.push(this.#videoCodec);
      mp4Type = 'video';
    }
    if (this.#parseCodecMP4A(chunk)) {
      codecs.push(this.#audioCodec);
      if (!this.#videoCodec) {
        mp4Type = 'audio';
      }
    }
    if (codecs.length === 0) {
      this.#emitError('codecs not found.', Mp4Frag.#ERR.chunkParse);
      return;
    }
    this.#mime = `${mp4Type}/mp4; codecs="${codecs.join(', ')}"`;
    if (this.#hlsPlaylist && this.#hlsPlaylist.init) {
      let m3u8 = '#EXTM3U\n';
      m3u8 += '#EXT-X-VERSION:7\n';
      m3u8 += `#EXT-X-TARGETDURATION:1\n`;
      m3u8 += `#EXT-X-MEDIA-SEQUENCE:0\n`;
      m3u8 += `#EXT-X-MAP:URI="init-${this.#hlsPlaylist.base}.mp4"\n`;
      this.#m3u8 = m3u8;
    }
    this.#sendInit();
    /*
    todo after version 0.7.0
    replace with emit('data')
    */
    this.emit('initialized', { mime: this.mime, initialization: this.initialization, m3u8: this.m3u8 });
  }

  /**
   * @private
   */
  #sendInitAsBuffer() {
    this.emit('data', this.initialization, { type: 'init', mime: this.mime, m3u8: this.m3u8 });
  }

  /**
   * @private
   */
  #sendInitAsObject() {
    this.emit('data', { type: 'init', initialization: this.initialization, mime: this.mime, m3u8: this.m3u8 });
  }

  /**
   * Set hvcC keyframe.
   * @param {Buffer} chunk
   * @private
   */
  #setKeyframeHVCC(chunk) {
    // let index = this.#moofSize + 8;
    let index = chunk.indexOf(Mp4Frag.#MDAT) + 4;
    const end = chunk.length - 5;
    while (index < end) {
      const nalLength = chunk.readUInt32BE(index);
      // simplify check for iframe nal types 16, 17, 18, 19, 20, 21; (chunk[(index += 4)] & 0x20) >> 1
      if ((chunk[(index += 4)] & 0x20) === 32) {
        this.#keyframe = true;
        return;
      }
      index += nalLength;
    }
    this.#allKeyframes = false;
    this.#keyframe = false;
  }

  /**
   * Set avcC keyframe.
   * @see {@link https://github.com/video-dev/hls.js/blob/729a36d409cc78cc391b17a0680eaf743f9213fb/tools/mp4-inspect.js#L48}
   * @param {Buffer} chunk
   * @private
   */
  #setKeyframeAVCC(chunk) {
    // let index = this.#moofSize + 8;
    let index = chunk.indexOf(Mp4Frag.#MDAT) + 4;
    const end = chunk.length - 5;
    while (index < end) {
      const nalLength = chunk.readUInt32BE(index);
      if ((chunk[(index += 4)] & 0x1f) === 5) {
        this.#keyframe = true;
        return;
      }
      index += nalLength;
    }
    this.#allKeyframes = false;
    this.#keyframe = false;
  }

  /**
   * Get duration of segment.
   * @see {@link https://github.com/video-dev/hls.js/blob/04cc5f167dac2aed4e41e493125968838cb32445/src/utils/mp4-tools.ts#L392}
   * @param {Buffer} chunk
   * @private
   */
  #parseDuration(chunk) {
    const trunIndex = chunk.indexOf(Mp4Frag.#TRUN);
    let trunOffset = trunIndex + 4;
    const trunFlags = chunk.readUInt32BE(trunOffset);
    trunOffset += 4;
    const sampleCount = chunk.readUInt32BE(trunOffset);
    // prefer using trun sample durations
    if (trunFlags & 0x000100) {
      trunOffset += 4;
      trunFlags & 0x000001 && (trunOffset += 4);
      trunFlags & 0x000004 && (trunOffset += 4);
      const increment = 4 + (trunFlags & 0x000200 && 4) + (trunFlags & 0x000400 && 4) + (trunFlags & 0x000800 && 4);
      let sampleDurationSum = 0;
      for (let i = 0; i < sampleCount; ++i, trunOffset += increment) {
        sampleDurationSum += chunk.readUInt32BE(trunOffset);
      }
      return sampleDurationSum / this.#timescale;
    }
    // fallback to using tfhd default sample duration
    const tfhdIndex = chunk.indexOf(Mp4Frag.#TFHD);
    let tfhdOffset = tfhdIndex + 4;
    const tfhdFlags = chunk.readUInt32BE(tfhdOffset);
    if (tfhdFlags & 0x000008) {
      tfhdOffset += 8;
      tfhdFlags & 0x000001 && (tfhdOffset += 8);
      tfhdFlags & 0x000002 && (tfhdOffset += 4);
      return (chunk.readUInt32BE(tfhdOffset) * sampleCount) / this.#timescale;
    }
    return 0;
  }

  /**
   * Set duration and timestamp.
   * @param {Buffer} chunk
   * @private
   */
  #setDurTime(chunk) {
    const duration = this.#parseDuration(chunk);
    const currentTime = Date.now();
    this.#duration = duration || (currentTime - this.#timestamp) / 1000;
    this.#timestamp = currentTime;
  }

  /**
   * Process current segment.
   * @fires Mp4Frag#segment
   * @param {Buffer} chunk
   * @private
   */
  #setSegment(chunk) {
    this.#segment = chunk;
    this.#setKeyframe(chunk);
    this.#setDurTime(chunk);
    this.#sequence++;
    if (this.#segmentObjects) {
      this.#segmentObjects.push({
        segment: chunk,
        sequence: this.#sequence,
        duration: this.#duration,
        timestamp: this.#timestamp,
        keyframe: this.#keyframe,
      });
      this.#totalDuration += this.#duration;
      this.#totalByteLength += chunk.byteLength;
      while (this.#segmentObjects.length > this.#segmentCount) {
        const {
          duration,
          segment: { byteLength },
        } = this.#segmentObjects.shift();
        this.#totalDuration -= duration;
        this.#totalByteLength -= byteLength;
      }
      if (this.#hlsPlaylist) {
        let i = this.#segmentObjects.length > this.#hlsPlaylist.size ? this.#segmentObjects.length - this.#hlsPlaylist.size : 0;
        const mediaSequence = this.#segmentObjects[i].sequence;
        let targetDuration = 1;
        let segments = '';
        for (i; i < this.#segmentObjects.length; ++i) {
          targetDuration = Math.max(targetDuration, this.#segmentObjects[i].duration);
          segments += `#EXTINF:${this.#segmentObjects[i].duration.toFixed(6)},\n`;
          segments += `${this.#hlsPlaylist.base}${this.#segmentObjects[i].sequence}.m4s\n`;
        }
        let m3u8 = '#EXTM3U\n';
        m3u8 += '#EXT-X-VERSION:7\n';
        m3u8 += `#EXT-X-TARGETDURATION:${Math.round(targetDuration) || 1}\n`;
        m3u8 += `#EXT-X-MEDIA-SEQUENCE:${mediaSequence}\n`;
        m3u8 += `#EXT-X-MAP:URI="init-${this.#hlsPlaylist.base}.mp4"\n`;
        m3u8 += segments;
        this.#m3u8 = m3u8;
      }
    } else {
      this.#totalDuration = this.#duration;
      this.#totalByteLength = this.#initialization.byteLength + chunk.byteLength;
    }
    this.#sendSegment();
    /*
    todo after version 0.7.0
    replace with emit('data')
    */
    this.emit('segment', this.segmentObject);
  }

  /**
   * @private
   */
  #sendSegmentAsBuffer() {
    this.emit('data', this.segment, { type: 'segment', sequence: this.sequence, duration: this.duration, timestamp: this.timestamp, keyframe: this.keyframe });
  }

  /**
   * @private
   */
  #sendSegmentAsObject() {
    this.emit('data', { type: 'segment', segment: this.segment, sequence: this.sequence, duration: this.duration, timestamp: this.timestamp, keyframe: this.keyframe });
  }

  /**
   * @param {Buffer} chunk
   * @returns {boolean}
   * @private
   */
  #parseCodecMP4A(chunk) {
    const index = chunk.indexOf(Mp4Frag.#MP4A);
    if (index !== -1) {
      const codec = ['mp4a'];
      const esdsIndex = chunk.indexOf(Mp4Frag.#ESDS, index);
      // verify tags 3, 4, 5 to be in expected positions
      if (esdsIndex !== -1 && chunk[esdsIndex + 8] === 0x03 && chunk[esdsIndex + 16] === 0x04 && chunk[esdsIndex + 34] === 0x05) {
        codec.push(chunk[esdsIndex + 21].toString(16));
        codec.push(((chunk[esdsIndex + 39] & 0xf8) >> 3).toString());
        this.#audioCodec = codec.join('.');
        return true;
      }
      // console.warn('unexpected mp4a esds structure');
    }
    return false;
  }

  /**
   * @param {Buffer} chunk
   * @returns {boolean}
   * @private
   */
  #parseCodecAVCC(chunk) {
    const index = chunk.indexOf(Mp4Frag.#AVCC);
    if (index !== -1) {
      const codec = [];
      if (chunk.includes(Mp4Frag.#AVC1)) {
        codec.push('avc1');
      } else if (chunk.includes(Mp4Frag.#AVC2)) {
        codec.push('avc2');
      } else if (chunk.includes(Mp4Frag.#AVC3)) {
        codec.push('avc3');
      } else if (chunk.includes(Mp4Frag.#AVC4)) {
        codec.push('avc4');
      } else {
        return false;
      }
      codec.push(
        chunk
          .subarray(index + 5, index + 8)
          .toString('hex')
          .toUpperCase()
      );
      this.#videoCodec = codec.join('.');
      this.#setKeyframe = this.#setKeyframeAVCC;
      return true;
    }
    return false;
  }

  /**
   * @param {Buffer} chunk
   * @returns {boolean}
   * @private
   */
  #parseCodecHVCC(chunk) {
    const index = chunk.indexOf(Mp4Frag.#HVCC);
    if (index !== -1) {
      const codec = [];
      if (chunk.includes(Mp4Frag.#HVC1)) {
        codec.push('hvc1');
      } else if (chunk.includes(Mp4Frag.#HEV1)) {
        codec.push('hev1');
      } else {
        return false;
      }
      const tmpByte = chunk[index + 5];
      const generalProfileSpace = tmpByte >> 6; // get 1st 2 bits (11000000)
      const generalTierFlag = !!(tmpByte & 0x20) ? 'H' : 'L'; // get next bit (00100000)
      const generalProfileIdc = (tmpByte & 0x1f).toString(); // get last 5 bits (00011111)
      const generalProfileCompatibility = Mp4Frag.#reverseBitsToHex(chunk.readUInt32BE(index + 6));
      const generalConstraintIndicator = Buffer.from(chunk.subarray(index + 10, index + 16).filter(byte => !!byte)).toString('hex');
      const generalLevelIdc = chunk[index + 16].toString();
      switch (generalProfileSpace) {
        case 0:
          codec.push(generalProfileIdc);
          break;
        case 1:
          codec.push(`A${generalProfileIdc}`);
          break;
        case 2:
          codec.push(`B${generalProfileIdc}`);
          break;
        case 3:
          codec.push(`C${generalProfileIdc}`);
          break;
      }
      codec.push(generalProfileCompatibility);
      codec.push(`${generalTierFlag}${generalLevelIdc}`);
      if (generalConstraintIndicator.length) {
        codec.push(generalConstraintIndicator);
      }
      this.#videoCodec = codec.join('.');
      this.#setKeyframe = this.#setKeyframeHVCC;
      return true;
    }
    return false;
  }

  /**
   * Required for stream transform.
   * @param {Buffer} chunk
   * @param {string} encoding
   * @param {TransformCallback} callback
   * @private
   */
  _transform(chunk, encoding, callback) {
    this.#parseChunk(chunk);
    callback();
  }

  /**
   * Run cleanup when unpiped.
   * @param {TransformCallback} callback
   * @private
   */
  _flush(callback) {
    this.reset();
    callback();
  }

  /**
   * Validate number is in range.
   * @param {number|string} n
   * @param {number} def
   * @param {number} min
   * @param {number} max
   * @returns {number}
   * @private
   * @static
   */
  static #validateInt(n, def, min, max) {
    n = Number.parseInt(n);
    return isNaN(n) ? def : n < min ? min : n > max ? max : n;
  }

  /**
   * Validate boolean value.
   * @param {*} bool
   * @param {boolean} def
   * @returns {boolean}
   * @private
   * @static
   */
  static #validateBool(bool, def) {
    return typeof bool === 'boolean' ? bool : def;
  }

  /**
   * Reverse bits and convert to hexadecimal.
   * @see {@link http://graphics.stanford.edu/~seander/bithacks.html#ReverseParallel}
   * @param {number} n - unsigned 32-bit integer
   * @returns {string} - bit reversed hex string
   * @private
   * @static
   */
  static #reverseBitsToHex(n) {
    n = ((n >> 1) & 0x55555555) | ((n & 0x55555555) << 1);
    n = ((n >> 2) & 0x33333333) | ((n & 0x33333333) << 2);
    n = ((n >> 4) & 0x0f0f0f0f) | ((n & 0x0f0f0f0f) << 4);
    n = ((n >> 8) & 0x00ff00ff) | ((n & 0x00ff00ff) << 8);
    return ((n >> 16) | (n << 16)).toString(16);
  }

  /**
   * Create box Buffer.
   * @param {number[]} arr
   * @returns {Buffer}
   * @private
   * @static
   */
  static #boxFrom(arr) {
    const buffer = Buffer.allocUnsafeSlow(4);
    for (let i = 0; i < 4; ++i) {
      buffer[i] = arr[i];
    }
    return buffer;
  }

  /**
   *
   * @param {string} msg
   * @param {string} code
   * @returns {Error}
   * @private
   * @static
   */
  static #createError(msg, code) {
    const error = new Error(msg);
    error.code = code;
    return error;
  }
}

/**
 * Fires when the [initialization]{@link Mp4Frag#initialization} of the Mp4 is parsed from the piped data.
 * @event Mp4Frag#initialized
 * @type {Event}
 * @property {object} object
 * @property {string} object.mime - [Mp4Frag.mime]{@link Mp4Frag#mime}
 * @property {Buffer} object.initialization - [Mp4Frag.initialization]{@link Mp4Frag#initialization}
 * @property {string} object.m3u8 - [Mp4Frag.m3u8]{@link Mp4Frag#m3u8}
 */

/**
 * Fires when the latest Mp4 segment is parsed from the piped data.
 * @event Mp4Frag#segment
 * @type {Event}
 * @property {object} object - [Mp4Frag.segmentObject]{@link Mp4Frag#segmentObject}
 * @property {Buffer} object.segment - [Mp4Frag.segment]{@link Mp4Frag#segment}
 * @property {number} object.sequence - [Mp4Frag.sequence]{@link Mp4Frag#sequence}
 * @property {number} object.duration - [Mp4Frag.duration]{@link Mp4Frag#duration}
 * @property {number} object.timestamp - [Mp4Frag.timestamp]{@link Mp4Frag#timestamp}
 * @property {number} object.keyframe - [Mp4Frag.keyframe]{@link Mp4Frag#keyframe}
 */

/**
 * Fires when reset() is called.
 * @event Mp4Frag#reset
 * @type {Event}
 */

module.exports = Mp4Frag;