Source: lib/util/stream_utils.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.util.StreamUtils');

goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.media.DrmEngine');
goog.require('shaka.media.MediaSourceEngine');
goog.require('shaka.text.TextEngine');
goog.require('shaka.util.Functional');
goog.require('shaka.util.LanguageUtils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.MimeUtils');


/**
 * @namespace shaka.util.StreamUtils
 * @summary A set of utility functions for dealing with Streams and Manifests.
 */


/**
 * @param {shaka.extern.Variant} variant
 * @param {shaka.extern.Restrictions} restrictions
 *   Configured restrictions from the user.
 * @param {{width: number, height: number}} maxHwRes
 *   The maximum resolution the hardware can handle.
 *   This is applied separately from user restrictions because the setting
 *   should not be easily replaced by the user's configuration.
 * @return {boolean}
 */
shaka.util.StreamUtils.meetsRestrictions = function(
    variant, restrictions, maxHwRes) {
  /** @type {function(number, number, number):boolean} */
  const inRange = (x, min, max) => {
    return x >= min && x <= max;
  };

  const video = variant.video;

  // |video.width| and |video.height| can be undefined, which breaks
  // the math, so make sure they are there first.
  if (video && video.width && video.height) {
    if (!inRange(video.width,
                 restrictions.minWidth,
                 Math.min(restrictions.maxWidth, maxHwRes.width))) {
      return false;
    }

    if (!inRange(video.height,
                 restrictions.minHeight,
                 Math.min(restrictions.maxHeight, maxHwRes.height))) {
      return false;
    }

    if (!inRange(video.width * video.height,
                 restrictions.minPixels,
                 restrictions.maxPixels)) {
      return false;
    }
  }

  if (!inRange(variant.bandwidth,
               restrictions.minBandwidth,
               restrictions.maxBandwidth)) {
    return false;
  }

  return true;
};


/**
 * @param {!Array.<shaka.extern.Variant>} variants
 * @param {shaka.extern.Restrictions} restrictions
 * @param {{width: number, height: number}} maxHwRes
 * @return {boolean} Whether the tracks changed.
 */
shaka.util.StreamUtils.applyRestrictions =
    function(variants, restrictions, maxHwRes) {
  let tracksChanged = false;

  variants.forEach((variant) => {
    let originalAllowed = variant.allowedByApplication;
    variant.allowedByApplication = shaka.util.StreamUtils.meetsRestrictions(
        variant, restrictions, maxHwRes);

    if (originalAllowed != variant.allowedByApplication) {
      tracksChanged = true;
    }
  });

  return tracksChanged;
};


/**
 * Alters the given Period to filter out any unplayable streams.
 *
 * @param {shaka.media.DrmEngine} drmEngine
 * @param {?shaka.extern.Stream} activeAudio
 * @param {?shaka.extern.Stream} activeVideo
 * @param {shaka.extern.Period} period
 */
shaka.util.StreamUtils.filterNewPeriod = function(
    drmEngine, activeAudio, activeVideo, period) {
  const StreamUtils = shaka.util.StreamUtils;

  if (activeAudio) {
    goog.asserts.assert(StreamUtils.isAudio(activeAudio),
                        'Audio streams must have the audio type.');
  }

  if (activeVideo) {
    goog.asserts.assert(StreamUtils.isVideo(activeVideo),
                        'Video streams must have the video type.');
  }

  // Filter variants.
  period.variants = period.variants.filter((variant) => {
    if (drmEngine && drmEngine.initialized()) {
      if (!drmEngine.supportsVariant(variant)) {
        shaka.log.debug('Dropping variant - not compatible with key system',
                        variant);
        return false;
      }
    }

    const audio = variant.audio;
    const video = variant.video;

    if (audio && !shaka.media.MediaSourceEngine.isStreamSupported(audio)) {
      shaka.log.debug('Dropping variant - audio not compatible with platform',
                      StreamUtils.getStreamSummaryString_(audio));
      return false;
    }

    if (video && !shaka.media.MediaSourceEngine.isStreamSupported(video)) {
      shaka.log.debug('Dropping variant - video not compatible with platform',
                      StreamUtils.getStreamSummaryString_(video));
      return false;
    }

    if (audio && activeAudio) {
      if (!StreamUtils.areStreamsCompatible_(audio, activeAudio)) {
        shaka.log.debug('Droping variant - not compatible with active audio',
                        'active audio',
                        StreamUtils.getStreamSummaryString_(activeAudio),
                        'variant.audio',
                        StreamUtils.getStreamSummaryString_(audio));
        return false;
      }
    }

    if (video && activeVideo) {
      if (!StreamUtils.areStreamsCompatible_(video, activeVideo)) {
        shaka.log.debug('Droping variant - not compatible with active video',
                        'active video',
                        StreamUtils.getStreamSummaryString_(activeVideo),
                        'variant.video',
                        StreamUtils.getStreamSummaryString_(video));
        return false;
      }
    }

    return true;
  });

  // Filter text streams.
  period.textStreams = period.textStreams.filter(function(stream) {
    let fullMimeType = shaka.util.MimeUtils.getFullType(
        stream.mimeType, stream.codecs);
    let keep = shaka.text.TextEngine.isTypeSupported(fullMimeType);

    if (!keep) {
      shaka.log.debug('Dropping text stream. Is not supported by the ' +
                      'platform.', stream);
    }

    return keep;
  });
};


/**
 * @param {shaka.extern.Stream} s0
 * @param {shaka.extern.Stream} s1
 * @return {boolean}
 * @private
 */
shaka.util.StreamUtils.areStreamsCompatible_ = function(s0, s1) {
  // Basic mime types and basic codecs need to match.
  // For example, we can't adapt between WebM and MP4,
  // nor can we adapt between mp4a.* to ec-3.
  // We can switch between text types on the fly,
  // so don't run this check on text.
  if (s0.mimeType != s1.mimeType) {
    return false;
  }

  if (s0.codecs.split('.')[0] != s1.codecs.split('.')[0]) {
    return false;
  }

  return true;
};


/**
 * @param {shaka.extern.Variant} variant
 * @return {shaka.extern.Track}
 */
shaka.util.StreamUtils.variantToTrack = function(variant) {
  /** @type {?shaka.extern.Stream} */
  let audio = variant.audio;
  /** @type {?shaka.extern.Stream} */
  let video = variant.video;

  /** @type {?string} */
  let audioCodec = audio ? audio.codecs : null;
  /** @type {?string} */
  let videoCodec = video ? video.codecs : null;

  /** @type {!Array.<string>} */
  let codecs = [];
  if (videoCodec) codecs.push(videoCodec);
  if (audioCodec) codecs.push(audioCodec);

  /** @type {!Array.<string>} */
  let mimeTypes = [];
  if (video) mimeTypes.push(video.mimeType);
  if (audio) mimeTypes.push(audio.mimeType);
  /** @type {?string} */
  let mimeType = mimeTypes[0] || null;

  /** @type {!Array.<string>} */
  let kinds = [];
  if (audio) kinds.push(audio.kind);
  if (video) kinds.push(video.kind);
  /** @type {?string} */
  let kind = kinds[0] || null;

  /** @type {!Set.<string>} */
  const roles = new Set();
  if (audio) audio.roles.forEach((role) => roles.add(role));
  if (video) video.roles.forEach((role) => roles.add(role));

  /** @type {shaka.extern.Track} */
  let track = {
    id: variant.id,
    active: false,
    type: 'variant',
    bandwidth: variant.bandwidth,
    language: variant.language,
    label: null,
    kind: kind,
    width: null,
    height: null,
    frameRate: null,
    mimeType: mimeType,
    codecs: codecs.join(', '),
    audioCodec: audioCodec,
    videoCodec: videoCodec,
    primary: variant.primary,
    roles: Array.from(roles),
    videoId: null,
    audioId: null,
    channelsCount: null,
    audioBandwidth: null,
    videoBandwidth: null,
    originalVideoId: null,
    originalAudioId: null,
    originalTextId: null,
  };

  if (video) {
    track.videoId = video.id;
    track.originalVideoId = video.originalId;
    track.width = video.width || null;
    track.height = video.height || null;
    track.frameRate = video.frameRate || null;
    track.videoBandwidth = video.bandwidth || null;
  }

  if (audio) {
    track.audioId = audio.id;
    track.originalAudioId = audio.originalId;
    track.channelsCount = audio.channelsCount;
    track.audioBandwidth = audio.bandwidth || null;
    track.label = audio.label;
  }

  return track;
};


/**
 * @param {shaka.extern.Stream} stream
 * @return {shaka.extern.Track}
 */
shaka.util.StreamUtils.textStreamToTrack = function(stream) {
  const ContentType = shaka.util.ManifestParserUtils.ContentType;

  /** @type {shaka.extern.Track} */
  let track = {
    id: stream.id,
    active: false,
    type: ContentType.TEXT,
    bandwidth: 0,
    language: stream.language,
    label: stream.label,
    kind: stream.kind || null,
    width: null,
    height: null,
    frameRate: null,
    mimeType: stream.mimeType,
    codecs: stream.codecs || null,
    audioCodec: null,
    videoCodec: null,
    primary: stream.primary,
    roles: stream.roles,
    videoId: null,
    audioId: null,
    channelsCount: null,
    audioBandwidth: null,
    videoBandwidth: null,
    originalVideoId: null,
    originalAudioId: null,
    originalTextId: stream.originalId,
  };

  return track;
};


/**
 * Finds the Variant for the given track.
 *
 * @param {shaka.extern.Period} period
 * @param {shaka.extern.Track} track
 * @return {?shaka.extern.Variant}
 */
shaka.util.StreamUtils.findVariantForTrack = function(period, track) {
  for (let i = 0; i < period.variants.length; i++) {
    if (period.variants[i].id == track.id) {
      return period.variants[i];
    }
  }
  return null;
};


/**
 * Finds the text stream for the given track.
 *
 * @param {shaka.extern.Period} period
 * @param {shaka.extern.Track} track
 * @return {?shaka.extern.Stream}
 */
shaka.util.StreamUtils.findTextStreamForTrack = function(period, track) {
  for (let i = 0; i < period.textStreams.length; i++) {
    if (period.textStreams[i].id == track.id) {
      return period.textStreams[i];
    }
  }
  return null;
};


/**
 * Determines if the given variant is playable.
 * @param {!shaka.extern.Variant} variant
 * @return {boolean}
 */
shaka.util.StreamUtils.isPlayable = function(variant) {
  return variant.allowedByApplication && variant.allowedByKeySystem;
};


/**
 * Filters out unplayable variants.
 * @param {!Array.<!shaka.extern.Variant>} variants
 * @return {!Array.<!shaka.extern.Variant>}
 */
shaka.util.StreamUtils.getPlayableVariants = function(variants) {
  return variants.filter(function(variant) {
    return shaka.util.StreamUtils.isPlayable(variant);
  });
};


/**
 * Filters variants according to the given audio channel count config.
 *
 * @param {!Array.<shaka.extern.Variant>} variants
 * @param {number} preferredAudioChannelCount
 * @return {!Array.<!shaka.extern.Variant>}
 */
shaka.util.StreamUtils.filterVariantsByAudioChannelCount = function(
    variants, preferredAudioChannelCount) {
  // Group variants by their audio channel counts.
  let variantsByChannelCount = variants
      .filter((v) => v.audio && v.audio.channelsCount)
      .reduce((map, variant) => {
        let count = variant.audio.channelsCount;
        if (map[count]) {
          map[count].push(variant);
        } else {
          map[count] = [variant];
        }
        return map;
      }, {});

  let channelCounts = Object.keys(variantsByChannelCount);

  // If no variant has audio channel count info, return the original variants.
  if (channelCounts.length == 0) {
    return variants;
  }

  // Choose the variants with the largest number of audio channels less than or
  // equal to the configured number of audio channels.
  let countLessThanOrEqualtoConfig =
      channelCounts.filter((count) => count <= preferredAudioChannelCount);
  if (countLessThanOrEqualtoConfig.length) {
    return variantsByChannelCount[Math.max.apply(null,
        countLessThanOrEqualtoConfig)];
  }
  // If all variants have more audio channels than the config, choose the
  // variants with the fewest audio channels.
  return variantsByChannelCount[Math.min.apply(null, channelCounts)];
};

/**
 * Chooses streams according to the given config.
 *
 * @param {!Array.<shaka.extern.Stream>} streams
 * @param {string} preferredLanguage
 * @param {string} preferredRole
 * @return {!Array.<!shaka.extern.Stream>}
 */
shaka.util.StreamUtils.filterStreamsByLanguageAndRole = function(
    streams, preferredLanguage, preferredRole) {
  const LanguageUtils = shaka.util.LanguageUtils;

  /** @type {!Array.<!shaka.extern.Stream>} */
  let chosen = streams;

  // Start with the set of primary streams.
  /** @type {!Array.<!shaka.extern.Stream>} */
  let primary = streams.filter(function(stream) {
    return stream.primary;
  });

  if (primary.length) {
    chosen = primary;
  }

  // Now reduce the set to one language.  This covers both arbitrary language
  // choice and the reduction of the "primary" stream set to one language.
  let firstLanguage = chosen.length ? chosen[0].language : '';
  chosen = chosen.filter(function(stream) {
    return stream.language == firstLanguage;
  });

  // Find the streams that best match our language preference. This will
  // override previous selections.
  if (preferredLanguage) {
    const closestLocale = LanguageUtils.findClosestLocale(
        LanguageUtils.normalize(preferredLanguage),
        streams.map((stream) => stream.language));

    // Only replace |chosen| if we found a locale that is close to our
    // preference.
    if (closestLocale) {
      chosen = streams.filter((stream) => {
        const locale = LanguageUtils.normalize(stream.language);
        return locale == closestLocale;
      });
    }
  }

  // Now refine the choice based on role preference.
  if (preferredRole) {
    let roleMatches = shaka.util.StreamUtils.filterTextStreamsByRole_(
        chosen, preferredRole);
    if (roleMatches.length) {
      return roleMatches;
    } else {
      shaka.log.warning('No exact match for the text role could be found.');
    }
  } else {
    // Prefer text streams with no roles, if they exist.
    let noRoleMatches = chosen.filter(function(stream) {
      return stream.roles.length == 0;
    });
    if (noRoleMatches.length) {
      return noRoleMatches;
    }
  }

  // Either there was no role preference, or it could not be satisfied.
  // Choose an arbitrary role, if there are any, and filter out any other roles.
  // This ensures we never adapt between roles.

  let allRoles = chosen.map(function(stream) {
    return stream.roles;
  }).reduce(shaka.util.Functional.collapseArrays, []);

  if (!allRoles.length) {
    return chosen;
  }
  return shaka.util.StreamUtils.filterTextStreamsByRole_(chosen, allRoles[0]);
};


/**
 * Filter text Streams by role.
 *
 * @param {!Array.<shaka.extern.Stream>} textStreams
 * @param {string} preferredRole
 * @return {!Array.<shaka.extern.Stream>}
 * @private
 */
shaka.util.StreamUtils.filterTextStreamsByRole_ =
    function(textStreams, preferredRole) {
  return textStreams.filter(function(stream) {
    return stream.roles.includes(preferredRole);
  });
};


/**
 * Finds a Variant with given audio and video streams.
 * Returns null if no such Variant was found.
 *
 * @param {?shaka.extern.Stream} audio
 * @param {?shaka.extern.Stream} video
 * @param {!Array.<!shaka.extern.Variant>} variants
 * @return {?shaka.extern.Variant}
 */
shaka.util.StreamUtils.getVariantByStreams = function(audio, video, variants) {
  if (audio) {
    goog.asserts.assert(
        shaka.util.StreamUtils.isAudio(audio),
        'Audio streams must have the audio type.');
  }

  if (video) {
    goog.asserts.assert(
        shaka.util.StreamUtils.isVideo(video),
        'Video streams must have the video type.');
  }

  for (let i = 0; i < variants.length; i++) {
    if (variants[i].audio == audio && variants[i].video == video) {
      return variants[i];
    }
  }

  return null;
};


/**
 * Gets the index of the Period that contains the given time.
 * @param {shaka.extern.Manifest} manifest
 * @param {number} time The time in seconds from the start of the presentation.
 * @return {number}
 */
shaka.util.StreamUtils.findPeriodContainingTime = function(manifest, time) {
  let threshold = shaka.util.ManifestParserUtils.GAP_OVERLAP_TOLERANCE_SECONDS;
  for (let i = manifest.periods.length - 1; i > 0; --i) {
    let period = manifest.periods[i];
    // The last segment may end right before the end of the Period because of
    // rounding issues.
    if (time + threshold >= period.startTime) {
      return i;
    }
  }
  return 0;
};


/**
 * @param {shaka.extern.Manifest} manifest
 * @param {shaka.extern.Variant} variant
 * @return {number} The index of the Period which contains |stream|, or -1 if
 *   no Period contains |stream|.
 */
shaka.util.StreamUtils.findPeriodContainingVariant =
    function(manifest, variant) {
  for (let periodIdx = 0; periodIdx < manifest.periods.length; ++periodIdx) {
    let period = manifest.periods[periodIdx];
    for (let j = 0; j < period.variants.length; ++j) {
      if (period.variants[j] == variant) {
        return periodIdx;
      }
    }
  }
  return -1;
};


/**
 * Checks if the given stream is an audio stream.
 *
 * @param {shaka.extern.Stream} stream
 * @return {boolean}
 */
shaka.util.StreamUtils.isAudio = function(stream) {
  const ContentType = shaka.util.ManifestParserUtils.ContentType;
  return stream.type == ContentType.AUDIO;
};


/**
 * Checks if the given stream is a video stream.
 *
 * @param {shaka.extern.Stream} stream
 * @return {boolean}
 */
shaka.util.StreamUtils.isVideo = function(stream) {
  const ContentType = shaka.util.ManifestParserUtils.ContentType;
  return stream.type == ContentType.VIDEO;
};


/**
 * Get all the variants from all the periods in the manifest.
 *
 * @param {shaka.extern.Manifest} manifest
 * @return {!Array.<shaka.extern.Variant>}
 */
shaka.util.StreamUtils.getAllVariants = function(manifest) {
  /** @type {!Array.<shaka.extern.Variant>} */
  const found = [];

  manifest.periods.forEach((period) => {
    period.variants.forEach((variant) => {
      found.push(variant);
    });
  });

  return found;
};


/**
 * Get all non-null streams in the variant as an array.
 *
 * @param {shaka.extern.Variant} variant
 * @return {!Array.<shaka.extern.Stream>}
 */
shaka.util.StreamUtils.getVariantStreams = function(variant) {
  const streams = [];

  if (variant.audio) { streams.push(variant.audio); }
  if (variant.video) { streams.push(variant.video); }

  return streams;
};


/**
 * @param {shaka.extern.Stream} stream
 * @return {string}
 * @private
 */
shaka.util.StreamUtils.getStreamSummaryString_ = function(stream) {
  if (shaka.util.StreamUtils.isAudio(stream)) {
    return 'type=audio' +
           ' codecs=' + stream.codecs +
           ' bandwidth='+ stream.bandwidth +
           ' channelsCount=' + stream.channelsCount;
  }

  if (shaka.util.StreamUtils.isVideo(stream)) {
    return 'type=video' +
           ' codecs=' + stream.codecs +
           ' bandwidth=' + stream.bandwidth +
           ' frameRate=' + stream.frameRate +
           ' width=' + stream.width +
           ' height=' + stream.height;
  }

  return 'unexpected stream type';
};