Files
shaka-player/lib/util/stream_utils.js
T
Wojciech Tyczyński 970d7756ea feat: Add Device API (#8210)
The goal is to simplify and abstract feature logic detection. Currently
lots of places depend on various calls to `shaka.util.Platform` and
mainteinance of this is hard & not easy to read.

By introducing device API ideally rest of the player logic would look
into device features instead of directly checking platform. Additionally
we can more easily cache needed values, so we won't have to parse user
agent several times anymore.

---------

Co-authored-by: Álvaro Velad Galván <ladvan91@hotmail.com>
2025-06-02 13:46:40 +02:00

2124 lines
69 KiB
JavaScript

/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.util.StreamUtils');
goog.require('goog.asserts');
goog.require('shaka.config.AutoShowText');
goog.require('shaka.device.DeviceFactory');
goog.require('shaka.device.IDevice');
goog.require('shaka.lcevc.Dec');
goog.require('shaka.log');
goog.require('shaka.media.Capabilities');
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');
goog.require('shaka.util.MultiMap');
goog.require('shaka.util.ObjectUtils');
goog.requireType('shaka.drm.DrmEngine');
/**
* @summary A set of utility functions for dealing with Streams and Manifests.
* @export
*/
shaka.util.StreamUtils = class {
/**
* In case of multiple usable codecs, choose one based on lowest average
* bandwidth and filter out the rest.
* Also filters out variants that have too many audio channels.
* @param {!shaka.extern.Manifest} manifest
* @param {!Array<string>} preferredVideoCodecs
* @param {!Array<string>} preferredAudioCodecs
* @param {!Array<string>} preferredDecodingAttributes
* @param {!Array<string>} preferredTextFormats
*/
static chooseCodecsAndFilterManifest(manifest, preferredVideoCodecs,
preferredAudioCodecs, preferredDecodingAttributes, preferredTextFormats) {
const StreamUtils = shaka.util.StreamUtils;
const MimeUtils = shaka.util.MimeUtils;
if (preferredTextFormats.length) {
let subset = manifest.textStreams;
for (const textFormat of preferredTextFormats) {
const filtered = subset.filter((textStream) => {
if (textStream.codecs.startsWith(textFormat) ||
textStream.mimeType.startsWith(textFormat)) {
return true;
}
return false;
});
if (filtered.length) {
subset = filtered;
break;
}
}
manifest.textStreams = subset;
}
let variants = manifest.variants;
// To start, choose the codecs based on configured preferences if available.
if (preferredVideoCodecs.length || preferredAudioCodecs.length) {
variants = StreamUtils.choosePreferredCodecs(variants,
preferredVideoCodecs, preferredAudioCodecs);
}
if (preferredDecodingAttributes.length) {
// group variants by resolution and choose preferred variants only
/** @type {!shaka.util.MultiMap<shaka.extern.Variant>} */
const variantsByResolutionMap = new shaka.util.MultiMap();
for (const variant of variants) {
variantsByResolutionMap
.push(String(variant.video.width || 0), variant);
}
const bestVariants = [];
variantsByResolutionMap.forEach((width, variantsByResolution) => {
let highestMatch = 0;
let matchingVariants = [];
for (const variant of variantsByResolution) {
const matchCount = preferredDecodingAttributes.filter(
(attribute) => variant.decodingInfos[0][attribute],
).length;
if (matchCount > highestMatch) {
highestMatch = matchCount;
matchingVariants = [variant];
} else if (matchCount == highestMatch) {
matchingVariants.push(variant);
}
}
bestVariants.push(...matchingVariants);
});
variants = bestVariants;
}
const audioStreamsSet = new Set();
const videoStreamsSet = new Set();
for (const variant of variants) {
if (variant.audio) {
audioStreamsSet.add(variant.audio);
}
if (variant.video) {
videoStreamsSet.add(variant.video);
}
}
const audioStreams = Array.from(audioStreamsSet).sort((v1, v2) => {
return v1.bandwidth - v2.bandwidth;
});
const validAudioIds = [];
const validAudioStreamsMap = new Map();
const getAudioId = (stream) => {
let id = stream.language + (stream.channelsCount || 0) +
(stream.audioSamplingRate || 0) + stream.roles.join(',') +
stream.label + stream.groupId + stream.fastSwitching;
if (stream.dependencyStream) {
id += stream.dependencyStream.baseOriginalId || '';
}
return id;
};
for (const stream of audioStreams) {
const groupId = getAudioId(stream);
const validAudioStreams = validAudioStreamsMap.get(groupId) || [];
if (!validAudioStreams.length) {
validAudioStreams.push(stream);
validAudioIds.push(stream.id);
} else {
const previousStream = validAudioStreams[validAudioStreams.length - 1];
const previousCodec =
MimeUtils.getNormalizedCodec(previousStream.codecs);
const currentCodec =
MimeUtils.getNormalizedCodec(stream.codecs);
if (previousCodec == currentCodec) {
if (!stream.bandwidth || !previousStream.bandwidth ||
stream.bandwidth > previousStream.bandwidth) {
validAudioStreams.push(stream);
validAudioIds.push(stream.id);
}
}
}
validAudioStreamsMap.set(groupId, validAudioStreams);
}
// Keys based in MimeUtils.getNormalizedCodec. Lower is better
const videoCodecPreference = {
'vp8': 1,
'avc': 1,
'dovi-avc': 0.95,
'vp9': 0.9,
'vp09': 0.9,
'hevc': 0.85,
'dovi-hevc': 0.8,
'dovi-p5': 0.75,
'av01': 0.7,
'dovi-av1': 0.65,
'vvc': 0.6,
};
const videoStreams = Array.from(videoStreamsSet)
.sort((v1, v2) => {
if (!v1.bandwidth || !v2.bandwidth || v1.bandwidth == v2.bandwidth) {
if (v1.codecs && v2.codecs && v1.codecs != v2.codecs &&
v1.width == v2.width) {
const v1Codecs = MimeUtils.getNormalizedCodec(v1.codecs);
const v2Codecs = MimeUtils.getNormalizedCodec(v2.codecs);
if (v1Codecs != v2Codecs) {
const indexV1 = videoCodecPreference[v1Codecs] || 1;
const indexV2 = videoCodecPreference[v2Codecs] || 1;
return indexV1 - indexV2;
}
}
return v1.width - v2.width;
}
return v1.bandwidth - v2.bandwidth;
});
const isChangeTypeSupported =
shaka.media.Capabilities.isChangeTypeSupported();
const validVideoIds = [];
const validVideoStreamsMap = new Map();
const getVideoGroupId = (stream) => {
let id = String(stream.width || '') + String(stream.height || '') +
String(Math.round(stream.frameRate || 0)) + (stream.hdr || '') +
stream.fastSwitching;
if (stream.dependencyStream) {
id += stream.dependencyStream.baseOriginalId || '';
}
return id;
};
for (const stream of videoStreams) {
const groupId = getVideoGroupId(stream);
const validVideoStreams = validVideoStreamsMap.get(groupId) || [];
if (!validVideoStreams.length) {
validVideoStreams.push(stream);
validVideoIds.push(stream.id);
} else {
const previousStream = validVideoStreams[validVideoStreams.length - 1];
if (!isChangeTypeSupported) {
const previousCodec =
MimeUtils.getNormalizedCodec(previousStream.codecs);
const currentCodec =
MimeUtils.getNormalizedCodec(stream.codecs);
if (previousCodec !== currentCodec) {
continue;
}
}
const previousCodec =
MimeUtils.getNormalizedCodec(previousStream.codecs);
const currentCodec =
MimeUtils.getNormalizedCodec(stream.codecs);
if (previousCodec == currentCodec) {
if (!stream.bandwidth || !previousStream.bandwidth ||
stream.bandwidth > previousStream.bandwidth) {
validVideoStreams.push(stream);
validVideoIds.push(stream.id);
}
}
}
validVideoStreamsMap.set(groupId, validVideoStreams);
}
// Filter out any variants that don't match, forcing AbrManager to choose
// from a single video codec and a single audio codec possible.
manifest.variants = manifest.variants.filter((variant) => {
const audio = variant.audio;
const video = variant.video;
if (audio) {
if (!validAudioIds.includes(audio.id)) {
shaka.log.debug('Dropping Variant (better codec available)', variant);
return false;
}
}
if (video) {
if (!validVideoIds.includes(video.id)) {
shaka.log.debug('Dropping Variant (better codec available)', variant);
return false;
}
}
return true;
});
}
/**
* Choose the codecs by configured preferred audio and video codecs.
*
* @param {!Array<shaka.extern.Variant>} variants
* @param {!Array<string>} preferredVideoCodecs
* @param {!Array<string>} preferredAudioCodecs
* @return {!Array<shaka.extern.Variant>}
*/
static choosePreferredCodecs(variants, preferredVideoCodecs,
preferredAudioCodecs) {
let subset = variants;
for (const videoCodec of preferredVideoCodecs) {
const filtered = subset.filter((variant) => {
return variant.video && variant.video.codecs.startsWith(videoCodec);
});
if (filtered.length) {
subset = filtered;
break;
}
}
for (const audioCodec of preferredAudioCodecs) {
const filtered = subset.filter((variant) => {
return variant.audio && variant.audio.codecs.startsWith(audioCodec);
});
if (filtered.length) {
subset = filtered;
break;
}
}
return subset;
}
/**
* Filter the variants in |manifest| to only include the variants that meet
* the given restrictions.
*
* @param {!shaka.extern.Manifest} manifest
* @param {shaka.extern.Restrictions} restrictions
* @param {shaka.extern.Resolution} maxHwResolution
*/
static filterByRestrictions(manifest, restrictions, maxHwResolution) {
manifest.variants = manifest.variants.filter((variant) => {
return shaka.util.StreamUtils.meetsRestrictions(
variant, restrictions, maxHwResolution);
});
}
/**
* @param {shaka.extern.Variant} variant
* @param {shaka.extern.Restrictions} restrictions
* Configured restrictions from the user.
* @param {shaka.extern.Resolution} 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}
* @export
*/
static meetsRestrictions(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) {
let videoWidth = video.width;
let videoHeight = video.height;
if (videoHeight > videoWidth) {
// Vertical video.
[videoWidth, videoHeight] = [videoHeight, videoWidth];
}
if (!inRange(videoWidth,
restrictions.minWidth,
Math.min(restrictions.maxWidth, maxHwRes.width))) {
return false;
}
if (!inRange(videoHeight,
restrictions.minHeight,
Math.min(restrictions.maxHeight, maxHwRes.height))) {
return false;
}
if (!inRange(video.width * video.height,
restrictions.minPixels,
restrictions.maxPixels)) {
return false;
}
}
// |variant.video.frameRate| can be undefined, which breaks
// the math, so make sure they are there first.
if (variant && variant.video && variant.video.frameRate) {
if (!inRange(variant.video.frameRate,
restrictions.minFrameRate,
restrictions.maxFrameRate)) {
return false;
}
}
// |variant.audio.channelsCount| can be undefined, which breaks
// the math, so make sure they are there first.
if (variant && variant.audio && variant.audio.channelsCount) {
if (!inRange(variant.audio.channelsCount,
restrictions.minChannelsCount,
restrictions.maxChannelsCount)) {
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 {shaka.extern.Resolution} maxHwRes
* @return {boolean} Whether the tracks changed.
*/
static applyRestrictions(variants, restrictions, maxHwRes) {
let tracksChanged = false;
for (const variant of variants) {
const originalAllowed = variant.allowedByApplication;
variant.allowedByApplication = shaka.util.StreamUtils.meetsRestrictions(
variant, restrictions, maxHwRes);
if (originalAllowed != variant.allowedByApplication) {
tracksChanged = true;
}
}
return tracksChanged;
}
/**
* Alters the given Manifest to filter out any unplayable streams.
*
* @param {shaka.drm.DrmEngine} drmEngine
* @param {shaka.extern.Manifest} manifest
* @param {!Array<string>=} preferredKeySystems
* @param {!Object<string, string>=} keySystemsMapping
*/
static async filterManifest(drmEngine, manifest, preferredKeySystems = [],
keySystemsMapping = {}) {
await shaka.util.StreamUtils.filterManifestByMediaCapabilities(
drmEngine, manifest, manifest.offlineSessionIds.length > 0,
preferredKeySystems, keySystemsMapping);
shaka.util.StreamUtils.filterTextStreams_(manifest);
await shaka.util.StreamUtils.filterImageStreams_(manifest);
}
/**
* Alters the given Manifest to filter out any streams unsupported by the
* platform via MediaCapabilities.decodingInfo() API.
*
* @param {shaka.drm.DrmEngine} drmEngine
* @param {shaka.extern.Manifest} manifest
* @param {boolean} usePersistentLicenses
* @param {!Array<string>} preferredKeySystems
* @param {!Object<string, string>} keySystemsMapping
*/
static async filterManifestByMediaCapabilities(
drmEngine, manifest, usePersistentLicenses, preferredKeySystems,
keySystemsMapping) {
goog.asserts.assert(navigator.mediaCapabilities,
'MediaCapabilities should be valid.');
if (shaka.device.DeviceFactory.getDevice()
.shouldOverrideDolbyVisionCodecs()) {
shaka.util.StreamUtils.overrideDolbyVisionCodecs(manifest.variants);
}
await shaka.util.StreamUtils.getDecodingInfosForVariants(
manifest.variants, usePersistentLicenses, /* srcEquals= */ false,
preferredKeySystems);
let keySystem = null;
if (drmEngine) {
const drmInfo = drmEngine.getDrmInfo();
if (drmInfo) {
keySystem = drmInfo.keySystem;
}
}
const StreamUtils = shaka.util.StreamUtils;
manifest.variants = manifest.variants.filter((variant) => {
const supported = StreamUtils.checkVariantSupported_(
variant, keySystem, keySystemsMapping);
// Filter out all unsupported variants.
if (!supported) {
shaka.log.debug('Dropping variant - not compatible with platform',
StreamUtils.getVariantSummaryString_(variant));
}
return supported;
});
}
/**
* Maps Dolby Vision codecs to H.264 and H.265 equivalents as a workaround
* to make Dolby Vision playback work on some platforms.
*
* Mapping is done according to the relevant Dolby documentation found here:
* https://professionalsupport.dolby.com/s/article/How-to-signal-Dolby-Vision-in-MPEG-DASH?language=en_US
* @param {!Array<!shaka.extern.Variant>} variants
*/
static overrideDolbyVisionCodecs(variants) {
/** @type {!Map<string, string>} */
const codecMap = new Map()
.set('dvav', 'avc3')
.set('dva1', 'avc1')
.set('dvhe', 'hev1')
.set('dvh1', 'hvc1')
.set('dvc1', 'vvc1')
.set('dvi1', 'vvi1');
/** @type {!Set<!shaka.extern.Stream>} */
const streams = new Set();
for (const variant of variants) {
if (variant.video) {
streams.add(variant.video);
}
}
for (const video of streams) {
for (const [dvCodec, replacement] of codecMap) {
if (video.codecs.includes(dvCodec)) {
video.codecs = video.codecs.replace(dvCodec, replacement);
break;
}
}
}
}
/**
* @param {!shaka.extern.Variant} variant
* @param {?string} keySystem
* @param {!Object<string, string>} keySystemsMapping
* @return {boolean}
* @private
*/
static checkVariantSupported_(variant, keySystem, keySystemsMapping) {
const variantSupported = variant.decodingInfos.some((decodingInfo) => {
if (!decodingInfo.supported) {
return false;
}
if (keySystem) {
const keySystemAccess = decodingInfo.keySystemAccess;
if (keySystemAccess) {
const currentKeySystem =
keySystemsMapping[keySystemAccess.keySystem] ||
keySystemAccess.keySystem;
if (currentKeySystem != keySystem) {
return false;
}
}
}
return true;
});
if (!variantSupported) {
return false;
}
const device = shaka.device.DeviceFactory.getDevice();
const isXboxOne = device.getDeviceName() === 'Xbox';
const isFirefoxAndroid =
device.getDeviceType() === shaka.device.IDevice.DeviceType.MOBILE &&
device.getBrowserEngine() === shaka.device.IDevice.BrowserEngine.GECKO;
// See: https://github.com/shaka-project/shaka-player/issues/3860
const video = variant.video;
const videoWidth = (video && video.width) || 0;
const videoHeight = (video && video.height) || 0;
// See: https://github.com/shaka-project/shaka-player/issues/3380
// Note: it makes sense to drop early
if (isXboxOne && video && (videoWidth > 1920 || videoHeight > 1080) &&
(video.codecs.includes('avc1.') || video.codecs.includes('avc3.'))) {
return false;
}
const videoDependencyStream = video && video.dependencyStream;
if (videoDependencyStream &&
!shaka.lcevc.Dec.isStreamSupported(videoDependencyStream)) {
return false;
}
const audio = variant.audio;
// See: https://github.com/shaka-project/shaka-player/issues/6111
// It seems that Firefox Android reports that it supports
// Opus + Widevine, but it is not actually supported.
// It makes sense to drop early.
if (isFirefoxAndroid && audio && audio.encrypted &&
audio.codecs.toLowerCase().includes('opus')) {
return false;
}
const audioDependencyStream = audio && audio.dependencyStream;
if (audioDependencyStream) {
return false;
}
return true;
}
/**
* Queries mediaCapabilities for the decoding info for that decoding config,
* and assigns it to the given variant.
* If that query has been done before, instead return a cached result.
* @param {!shaka.extern.Variant} variant
* @param {!Array<!MediaDecodingConfiguration>} decodingConfigs
* @private
*/
static async getDecodingInfosForVariant_(variant, decodingConfigs) {
/**
* @param {?MediaCapabilitiesDecodingInfo} a
* @param {!MediaCapabilitiesDecodingInfo} b
* @return {!MediaCapabilitiesDecodingInfo}
*/
const merge = (a, b) => {
if (!a) {
return b;
} else {
const res = shaka.util.ObjectUtils.shallowCloneObject(a);
res.supported = a.supported && b.supported;
res.powerEfficient = a.powerEfficient && b.powerEfficient;
res.smooth = a.smooth && b.smooth;
if (b.keySystemAccess && !res.keySystemAccess) {
res.keySystemAccess = b.keySystemAccess;
}
return res;
}
};
const StreamUtils = shaka.util.StreamUtils;
/** @type {?MediaCapabilitiesDecodingInfo} */
let finalResult = null;
const promises = [];
for (const decodingConfig of decodingConfigs) {
const cacheKey =
shaka.util.ObjectUtils.alphabeticalKeyOrderStringify(decodingConfig);
const cache = StreamUtils.decodingConfigCache_;
if (cache.has(cacheKey)) {
shaka.log.v2('Using cached results of mediaCapabilities.decodingInfo',
'for key', cacheKey);
finalResult = merge(finalResult, cache.get(cacheKey));
} else {
// Do a final pass-over of the decoding config: if a given stream has
// multiple codecs, that suggests that it switches between those codecs
// at points of the go-through.
// mediaCapabilities by itself will report "not supported" when you
// put in multiple different codecs, so each has to be checked
// individually. So check each and take the worst result, to determine
// overall variant compatibility.
promises.push(StreamUtils
.checkEachDecodingConfigCombination_(decodingConfig).then((res) => {
/** @type {?MediaCapabilitiesDecodingInfo} */
let acc = null;
for (const result of (res || [])) {
acc = merge(acc, result);
}
if (acc) {
cache.set(cacheKey, acc);
finalResult = merge(finalResult, acc);
}
}));
}
}
await Promise.all(promises);
if (finalResult) {
variant.decodingInfos.push(finalResult);
}
}
/**
* @param {!MediaDecodingConfiguration} decodingConfig
* @return {!Promise<?Array<!MediaCapabilitiesDecodingInfo>>}
* @private
*/
static checkEachDecodingConfigCombination_(decodingConfig) {
let videoCodecs = [''];
if (decodingConfig.video) {
videoCodecs = shaka.util.MimeUtils.getCodecs(
decodingConfig.video.contentType).split(',');
}
let audioCodecs = [''];
if (decodingConfig.audio) {
audioCodecs = shaka.util.MimeUtils.getCodecs(
decodingConfig.audio.contentType).split(',');
}
const promises = [];
for (const videoCodec of videoCodecs) {
for (const audioCodec of audioCodecs) {
const copy = shaka.util.ObjectUtils.cloneObject(decodingConfig);
if (decodingConfig.video) {
const mimeType = shaka.util.MimeUtils.getBasicType(
copy.video.contentType);
copy.video.contentType = shaka.util.MimeUtils.getFullType(
mimeType, videoCodec);
}
if (decodingConfig.audio) {
const mimeType = shaka.util.MimeUtils.getBasicType(
copy.audio.contentType);
copy.audio.contentType = shaka.util.MimeUtils.getFullType(
mimeType, audioCodec);
}
promises.push(new Promise((resolve, reject) => {
// On some (Android) WebView environments, decodingInfo will
// not resolve or reject, at least if RESOURCE_PROTECTED_MEDIA_ID
// is not set. This is a workaround for that issue.
const TIMEOUT_FOR_DECODING_INFO_IN_SECONDS = 5;
let promise;
const device = shaka.device.DeviceFactory.getDevice();
if (device.getDeviceType() ==
shaka.device.IDevice.DeviceType.MOBILE) {
promise = shaka.util.Functional.promiseWithTimeout(
TIMEOUT_FOR_DECODING_INFO_IN_SECONDS,
navigator.mediaCapabilities.decodingInfo(copy),
);
} else {
promise = navigator.mediaCapabilities.decodingInfo(copy);
}
promise.then((res) => {
resolve(res);
}).catch(reject);
}));
}
}
return Promise.all(promises).catch((e) => {
shaka.log.info('MediaCapabilities.decodingInfo() failed.',
JSON.stringify(decodingConfig), e);
return null;
});
}
/**
* Get the decodingInfo results of the variants via MediaCapabilities.
* This should be called after the DrmEngine is created and configured, and
* before DrmEngine sets the mediaKeys.
*
* @param {!Array<shaka.extern.Variant>} variants
* @param {boolean} usePersistentLicenses
* @param {boolean} srcEquals
* @param {!Array<string>} preferredKeySystems
* @exportDoc
*/
static async getDecodingInfosForVariants(variants, usePersistentLicenses,
srcEquals, preferredKeySystems) {
const gotDecodingInfo = variants.some((variant) =>
variant.decodingInfos.length);
if (gotDecodingInfo) {
shaka.log.debug('Already got the variants\' decodingInfo.');
return;
}
// Try to get preferred key systems first to avoid unneeded calls to CDM.
for (const preferredKeySystem of preferredKeySystems) {
let keySystemSatisfied = false;
for (const variant of variants) {
/** @type {!Array<!Array<!MediaDecodingConfiguration>>} */
const decodingConfigs = shaka.util.StreamUtils.getDecodingConfigs_(
variant, usePersistentLicenses, srcEquals)
.filter((configs) => {
// All configs in a batch will have the same keySystem.
const config = configs[0];
const keySystem = config.keySystemConfiguration &&
config.keySystemConfiguration.keySystem;
return keySystem === preferredKeySystem;
});
// The reason we are performing this await in a loop rather than
// batching into a `promise.all` is performance related.
// https://github.com/shaka-project/shaka-player/pull/4708#discussion_r1022581178
for (const configs of decodingConfigs) {
// eslint-disable-next-line no-await-in-loop
await shaka.util.StreamUtils.getDecodingInfosForVariant_(
variant, configs);
}
if (variant.decodingInfos.length) {
keySystemSatisfied = true;
}
} // for (const variant of variants)
if (keySystemSatisfied) {
// Return if any preferred key system is already satisfied.
return;
}
} // for (const preferredKeySystem of preferredKeySystems)
for (const variant of variants) {
/** @type {!Array<!Array<!MediaDecodingConfiguration>>} */
const decodingConfigs = shaka.util.StreamUtils.getDecodingConfigs_(
variant, usePersistentLicenses, srcEquals)
.filter((configs) => {
// All configs in a batch will have the same keySystem.
const config = configs[0];
const keySystem = config.keySystemConfiguration &&
config.keySystemConfiguration.keySystem;
// Avoid checking preferred systems twice.
return !keySystem || !preferredKeySystems.includes(keySystem);
});
// The reason we are performing this await in a loop rather than
// batching into a `promise.all` is performance related.
// https://github.com/shaka-project/shaka-player/pull/4708#discussion_r1022581178
for (const configs of decodingConfigs) {
// eslint-disable-next-line no-await-in-loop
await shaka.util.StreamUtils.getDecodingInfosForVariant_(
variant, configs);
}
}
}
/**
* Generate a batch of MediaDecodingConfiguration objects to get the
* decodingInfo results for each variant.
* Each batch shares the same DRM information, and represents the various
* fullMimeType combinations of the streams.
* @param {!shaka.extern.Variant} variant
* @param {boolean} usePersistentLicenses
* @param {boolean} srcEquals
* @return {!Array<!Array<!MediaDecodingConfiguration>>}
* @private
*/
static getDecodingConfigs_(variant, usePersistentLicenses, srcEquals) {
const audio = variant.audio;
const video = variant.video;
const ContentType = shaka.util.ManifestParserUtils.ContentType;
const ManifestParserUtils = shaka.util.ManifestParserUtils;
const MimeUtils = shaka.util.MimeUtils;
const StreamUtils = shaka.util.StreamUtils;
const videoConfigs = [];
const audioConfigs = [];
if (video) {
for (const fullMimeType of video.fullMimeTypes) {
let videoCodecs = MimeUtils.getCodecs(fullMimeType);
// For multiplexed streams with audio+video codecs, the config should
// have AudioConfiguration and VideoConfiguration.
// We ignore the multiplexed audio when there is normal audio also.
if (videoCodecs.includes(',') && !audio) {
const allCodecs = videoCodecs.split(',');
const baseMimeType = MimeUtils.getBasicType(fullMimeType);
videoCodecs = ManifestParserUtils.guessCodecs(
ContentType.VIDEO, allCodecs);
let audioCodecs = ManifestParserUtils.guessCodecs(
ContentType.AUDIO, allCodecs);
audioCodecs = StreamUtils.getCorrectAudioCodecs(
audioCodecs, baseMimeType);
const audioFullType = MimeUtils.getFullOrConvertedType(
baseMimeType, audioCodecs, ContentType.AUDIO);
audioConfigs.push({
contentType: audioFullType,
channels: 2,
bitrate: variant.bandwidth || 1,
samplerate: 1,
spatialRendering: false,
});
}
videoCodecs = StreamUtils.getCorrectVideoCodecs(videoCodecs);
const fullType = MimeUtils.getFullOrConvertedType(
MimeUtils.getBasicType(fullMimeType), videoCodecs,
ContentType.VIDEO);
// VideoConfiguration
const videoConfig = {
contentType: fullType,
// NOTE: Some decoders strictly check the width and height fields and
// won't decode smaller than 64x64. So if we don't have this info (as
// is the case in some of our simpler tests), assume a 64x64
// resolution to fill in this required field for MediaCapabilities.
//
// This became an issue specifically on Firefox on M1 Macs.
width: video.width || 64,
height: video.height || 64,
bitrate: video.bandwidth || variant.bandwidth || 1,
// framerate must be greater than 0, otherwise the config is invalid.
framerate: video.frameRate || 1,
};
if (video.hdr) {
switch (video.hdr) {
case 'SDR':
videoConfig.transferFunction = 'srgb';
break;
case 'PQ':
videoConfig.transferFunction = 'pq';
break;
case 'HLG':
videoConfig.transferFunction = 'hlg';
break;
}
}
if (video.colorGamut) {
videoConfig.colorGamut = video.colorGamut;
}
videoConfigs.push(videoConfig);
}
}
if (audio) {
for (const fullMimeType of audio.fullMimeTypes) {
const baseMimeType = MimeUtils.getBasicType(fullMimeType);
const codecs = StreamUtils.getCorrectAudioCodecs(
MimeUtils.getCodecs(fullMimeType), baseMimeType);
const fullType = MimeUtils.getFullOrConvertedType(
baseMimeType, codecs, ContentType.AUDIO);
// AudioConfiguration
audioConfigs.push({
contentType: fullType,
channels: audio.channelsCount || 2,
bitrate: audio.bandwidth || variant.bandwidth || 1,
samplerate: audio.audioSamplingRate || 1,
spatialRendering: audio.spatialAudio,
});
}
}
// Generate each combination of video and audio config as a separate
// MediaDecodingConfiguration, inside the main "batch".
/** @type {!Array<!MediaDecodingConfiguration>} */
const mediaDecodingConfigBatch = [];
if (videoConfigs.length == 0) {
videoConfigs.push(null);
}
if (audioConfigs.length == 0) {
audioConfigs.push(null);
}
for (const videoConfig of videoConfigs) {
for (const audioConfig of audioConfigs) {
/** @type {!MediaDecodingConfiguration} */
const mediaDecodingConfig = {
type: srcEquals ? 'file' : 'media-source',
};
if (videoConfig) {
mediaDecodingConfig.video = videoConfig;
}
if (audioConfig) {
mediaDecodingConfig.audio = audioConfig;
}
mediaDecodingConfigBatch.push(mediaDecodingConfig);
}
}
const videoDrmInfos = variant.video ? variant.video.drmInfos : [];
const audioDrmInfos = variant.audio ? variant.audio.drmInfos : [];
const allDrmInfos = videoDrmInfos.concat(audioDrmInfos);
// Return a list containing the mediaDecodingConfig for unencrypted variant.
if (!allDrmInfos.length) {
return [mediaDecodingConfigBatch];
}
// A list of MediaDecodingConfiguration objects created for the variant.
const configs = [];
// Get all the drm info so that we can avoid using nested loops when we
// just need the drm info.
const drmInfoByKeySystems = new Map();
for (const info of allDrmInfos) {
if (!drmInfoByKeySystems.get(info.keySystem)) {
drmInfoByKeySystems.set(info.keySystem, []);
}
drmInfoByKeySystems.get(info.keySystem).push(info);
}
const persistentState =
usePersistentLicenses ? 'required' : 'optional';
const sessionTypes =
usePersistentLicenses ? ['persistent-license'] : ['temporary'];
for (const keySystem of drmInfoByKeySystems.keys()) {
const drmInfos = drmInfoByKeySystems.get(keySystem);
// Get all the robustness info so that we can avoid using nested
// loops when we just need the robustness.
const drmInfosByRobustness = new Map();
for (const info of drmInfos) {
const keyName = `${info.videoRobustness},${info.audioRobustness}`;
if (!drmInfosByRobustness.get(keyName)) {
drmInfosByRobustness.set(keyName, []);
}
drmInfosByRobustness.get(keyName).push(info);
}
for (const drmInfosRobustness of drmInfosByRobustness.values()) {
const modifiedMediaDecodingConfigBatch = [];
for (const base of mediaDecodingConfigBatch) {
// Create a copy of the mediaDecodingConfig.
const config = /** @type {!MediaDecodingConfiguration} */
(Object.assign({}, base));
/** @type {!MediaCapabilitiesKeySystemConfiguration} */
const keySystemConfig = {
keySystem: keySystem,
initDataType: 'cenc',
persistentState: persistentState,
distinctiveIdentifier: 'optional',
sessionTypes: sessionTypes,
};
for (const info of drmInfosRobustness) {
if (info.initData && info.initData.length) {
const initDataTypes = new Set();
for (const initData of info.initData) {
initDataTypes.add(initData.initDataType);
}
if (initDataTypes.size > 1) {
shaka.log.v2('DrmInfo contains more than one initDataType,',
'and we use the initDataType of the first initData.',
info);
}
keySystemConfig.initDataType = info.initData[0].initDataType;
}
if (info.distinctiveIdentifierRequired) {
keySystemConfig.distinctiveIdentifier = 'required';
}
if (info.persistentStateRequired) {
keySystemConfig.persistentState = 'required';
}
if (info.sessionType) {
keySystemConfig.sessionTypes = [info.sessionType];
}
if (audio) {
if (!keySystemConfig.audio) {
// KeySystemTrackConfiguration
keySystemConfig.audio = {
robustness: info.audioRobustness,
};
if (info.encryptionScheme) {
keySystemConfig.audio.encryptionScheme =
info.encryptionScheme;
}
} else {
if (info.encryptionScheme) {
keySystemConfig.audio.encryptionScheme =
keySystemConfig.audio.encryptionScheme ||
info.encryptionScheme;
}
keySystemConfig.audio.robustness =
keySystemConfig.audio.robustness ||
info.audioRobustness;
}
// See: https://github.com/shaka-project/shaka-player/issues/4659
if (keySystemConfig.audio.robustness == '') {
delete keySystemConfig.audio.robustness;
}
}
if (video) {
if (!keySystemConfig.video) {
// KeySystemTrackConfiguration
keySystemConfig.video = {
robustness: info.videoRobustness,
};
if (info.encryptionScheme) {
keySystemConfig.video.encryptionScheme =
info.encryptionScheme;
}
} else {
if (info.encryptionScheme) {
keySystemConfig.video.encryptionScheme =
keySystemConfig.video.encryptionScheme ||
info.encryptionScheme;
}
keySystemConfig.video.robustness =
keySystemConfig.video.robustness ||
info.videoRobustness;
}
// See: https://github.com/shaka-project/shaka-player/issues/4659
if (keySystemConfig.video.robustness == '') {
delete keySystemConfig.video.robustness;
}
}
}
config.keySystemConfiguration = keySystemConfig;
modifiedMediaDecodingConfigBatch.push(config);
}
configs.push(modifiedMediaDecodingConfigBatch);
}
}
return configs;
}
/**
* Generates the correct audio codec for MediaDecodingConfiguration and
* for MediaSource.isTypeSupported.
* @param {string} codecs
* @param {string} mimeType
* @return {string}
*/
static getCorrectAudioCodecs(codecs, mimeType) {
// According to RFC 6381 section 3.3, 'fLaC' is actually the correct
// codec string. We still need to map it to 'flac', as some browsers
// currently don't support 'fLaC', while 'flac' is supported by most
// major browsers.
// See https://bugs.chromium.org/p/chromium/issues/detail?id=1422728
const device = shaka.device.DeviceFactory.getDevice();
const webkit = shaka.device.IDevice.BrowserEngine.WEBKIT;
if (codecs.toLowerCase() == 'flac') {
if (device.getBrowserEngine() != webkit) {
return 'flac';
} else {
return 'fLaC';
}
}
// The same is true for 'Opus'.
if (codecs.toLowerCase() === 'opus') {
if (device.getBrowserEngine() != webkit) {
return 'opus';
} else {
if (shaka.util.MimeUtils.getContainerType(mimeType) == 'mp4') {
return 'Opus';
} else {
return 'opus';
}
}
}
if (codecs.toLowerCase() == 'ac-3' && device.requiresEC3InitSegments()) {
return 'ec-3';
}
return codecs;
}
/**
* Generates the correct video codec for MediaDecodingConfiguration and
* for MediaSource.isTypeSupported.
* @param {string} codec
* @return {string}
*/
static getCorrectVideoCodecs(codec) {
if (codec.includes('avc1')) {
// Convert avc1 codec string from RFC-4281 to RFC-6381 for
// MediaSource.isTypeSupported
// Example, convert avc1.66.30 to avc1.42001e (0x42 == 66 and 0x1e == 30)
const avcData = codec.split('.');
if (avcData.length == 3) {
let result = avcData.shift() + '.';
result += parseInt(avcData.shift(), 10).toString(16);
result +=
('000' + parseInt(avcData.shift(), 10).toString(16)).slice(-4);
return result;
}
} else if (codec == 'vp9') {
// MediaCapabilities supports 'vp09...' codecs, but not 'vp9'. Translate
// vp9 codec strings into 'vp09...', to allow such content to play with
// mediaCapabilities enabled.
// This means profile 0, level 4.1, 8-bit color. This supports 1080p @
// 60Hz. See https://en.wikipedia.org/wiki/VP9#Levels
//
// If we don't have more detailed codec info, assume this profile and
// level because it's high enough to likely accommodate the parameters we
// do have, such as width and height. If an implementation is checking
// the profile and level very strictly, we want older VP9 content to
// still work to some degree. But we don't want to set a level so high
// that it is rejected by a hardware decoder that can't handle the
// maximum requirements of the level.
//
// This became an issue specifically on Firefox on M1 Macs.
return 'vp09.00.41.08';
}
return codec;
}
/**
* Alters the given Manifest to filter out any streams incompatible with the
* current variant.
*
* @param {?shaka.extern.Variant} currentVariant
* @param {shaka.extern.Manifest} manifest
*/
static filterManifestByCurrentVariant(currentVariant, manifest) {
const StreamUtils = shaka.util.StreamUtils;
manifest.variants = manifest.variants.filter((variant) => {
const audio = variant.audio;
const video = variant.video;
if (audio && currentVariant && currentVariant.audio) {
if (!StreamUtils.areStreamsCompatible_(audio, currentVariant.audio)) {
shaka.log.debug('Dropping variant - not compatible with active audio',
'active audio',
StreamUtils.getStreamSummaryString_(currentVariant.audio),
'variant.audio',
StreamUtils.getStreamSummaryString_(audio));
return false;
}
}
if (video && currentVariant && currentVariant.video) {
if (!StreamUtils.areStreamsCompatible_(video, currentVariant.video)) {
shaka.log.debug('Dropping variant - not compatible with active video',
'active video',
StreamUtils.getStreamSummaryString_(currentVariant.video),
'variant.video',
StreamUtils.getStreamSummaryString_(video));
return false;
}
}
return true;
});
}
/**
* Alters the given Manifest to filter out any unsupported text streams.
*
* @param {shaka.extern.Manifest} manifest
* @private
*/
static filterTextStreams_(manifest) {
// Filter text streams.
manifest.textStreams = manifest.textStreams.filter((stream) => {
const fullMimeType = shaka.util.MimeUtils.getFullType(
stream.mimeType, stream.codecs);
const keep = shaka.text.TextEngine.isTypeSupported(fullMimeType);
if (!keep) {
shaka.log.debug('Dropping text stream. Is not supported by the ' +
'platform.', stream);
}
return keep;
});
}
/**
* Alters the given Manifest to filter out any unsupported image streams.
*
* @param {shaka.extern.Manifest} manifest
* @private
*/
static async filterImageStreams_(manifest) {
const imageStreams = [];
for (const stream of manifest.imageStreams) {
let mimeType = stream.mimeType;
if (mimeType == 'application/mp4' && stream.codecs == 'mjpg') {
mimeType = 'image/jpg';
}
if (!shaka.util.StreamUtils.supportedImageMimeTypes_.has(mimeType)) {
const minImage = shaka.util.StreamUtils.minImage_.get(mimeType);
if (minImage) {
// eslint-disable-next-line no-await-in-loop
const res = await shaka.util.StreamUtils.isImageSupported_(minImage);
shaka.util.StreamUtils.supportedImageMimeTypes_.set(mimeType, res);
} else {
shaka.util.StreamUtils.supportedImageMimeTypes_.set(mimeType, false);
}
}
const keep =
shaka.util.StreamUtils.supportedImageMimeTypes_.get(mimeType);
if (!keep) {
shaka.log.debug('Dropping image stream. Is not supported by the ' +
'platform.', stream);
} else {
imageStreams.push(stream);
}
}
manifest.imageStreams = imageStreams;
}
/**
* @param {string} minImage
* @return {!Promise<boolean>}
* @private
*/
static isImageSupported_(minImage) {
return new Promise((resolve) => {
const imageElement = /** @type {HTMLImageElement} */(new Image());
imageElement.src = minImage;
if ('decode' in imageElement) {
imageElement.decode().then(() => {
resolve(true);
}).catch(() => {
resolve(false);
});
} else {
imageElement.onload = imageElement.onerror = () => {
resolve(imageElement.height === 2);
};
}
});
}
/**
* @param {shaka.extern.Stream} s0
* @param {shaka.extern.Stream} s1
* @return {boolean}
* @private
*/
static areStreamsCompatible_(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}
*/
static variantToTrack(variant) {
const ManifestParserUtils = shaka.util.ManifestParserUtils;
const ContentType = shaka.util.ManifestParserUtils.ContentType;
/** @type {?shaka.extern.Stream} */
const audio = variant.audio;
/** @type {?shaka.extern.Stream} */
const video = variant.video;
/** @type {?string} */
const audioMimeType = audio ? audio.mimeType : null;
/** @type {?string} */
const videoMimeType = video ? video.mimeType : null;
/** @type {?string} */
const audioCodec = audio ? audio.codecs : null;
/** @type {?string} */
const videoCodec = video ? video.codecs : null;
/** @type {?string} */
const audioGroupId = audio ? audio.groupId : null;
/** @type {!Array<string>} */
const mimeTypes = [];
if (video) {
mimeTypes.push(video.mimeType);
}
if (audio) {
mimeTypes.push(audio.mimeType);
}
/** @type {?string} */
const mimeType = mimeTypes[0] || null;
/** @type {!Array<string>} */
const kinds = [];
if (audio) {
kinds.push(audio.kind);
}
if (video) {
kinds.push(video.kind);
}
/** @type {?string} */
const kind = kinds[0] || null;
/** @type {!Set<string>} */
const roles = new Set();
if (audio) {
for (const role of audio.roles) {
roles.add(role);
}
}
if (video) {
for (const role of video.roles) {
roles.add(role);
}
}
/** @type {shaka.extern.Track} */
const track = {
id: variant.id,
active: false,
type: 'variant',
bandwidth: variant.bandwidth,
language: variant.language,
label: null,
kind: kind,
width: null,
height: null,
frameRate: null,
pixelAspectRatio: null,
hdr: null,
colorGamut: null,
videoLayout: null,
mimeType: mimeType,
audioMimeType: audioMimeType,
videoMimeType: videoMimeType,
codecs: '',
audioCodec: audioCodec,
videoCodec: videoCodec,
primary: variant.primary,
roles: Array.from(roles),
audioRoles: null,
forced: false,
videoId: null,
audioId: null,
audioGroupId: audioGroupId,
channelsCount: null,
audioSamplingRate: null,
spatialAudio: false,
tilesLayout: null,
audioBandwidth: null,
videoBandwidth: null,
originalVideoId: null,
originalAudioId: null,
originalTextId: null,
originalImageId: null,
accessibilityPurpose: null,
originalLanguage: 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.pixelAspectRatio = video.pixelAspectRatio || null;
track.videoBandwidth = video.bandwidth || null;
track.hdr = video.hdr || null;
track.colorGamut = video.colorGamut || null;
track.videoLayout = video.videoLayout || null;
const dependencyStream = video.dependencyStream;
if (dependencyStream) {
track.width = dependencyStream.width || track.width;
track.height = dependencyStream.height || track.height;
track.videoCodec = dependencyStream.codecs || track.videoCodec;
}
if (videoCodec.includes(',')) {
track.channelsCount = video.channelsCount;
track.audioSamplingRate = video.audioSamplingRate;
track.spatialAudio = video.spatialAudio;
track.originalLanguage = video.originalLanguage;
track.audioMimeType = videoMimeType;
const allCodecs = videoCodec.split(',');
try {
track.videoCodec = ManifestParserUtils.guessCodecs(
ContentType.VIDEO, allCodecs);
track.audioCodec = ManifestParserUtils.guessCodecs(
ContentType.AUDIO, allCodecs);
} catch (e) {
// Ignore this error.
}
}
}
if (audio) {
track.audioId = audio.id;
track.originalAudioId = audio.originalId;
track.channelsCount = audio.channelsCount;
track.audioSamplingRate = audio.audioSamplingRate;
track.audioBandwidth = audio.bandwidth || null;
track.spatialAudio = audio.spatialAudio;
track.label = audio.label;
track.audioRoles = audio.roles;
track.accessibilityPurpose = audio.accessibilityPurpose;
track.originalLanguage = audio.originalLanguage;
const dependencyStream = audio.dependencyStream;
if (dependencyStream) {
track.audioCodec = dependencyStream.codecs || track.audioCodec;
}
}
/** @type {!Array<string>} */
const codecs = [];
if (track.videoCodec) {
codecs.push(track.videoCodec);
}
if (track.audioCodec) {
codecs.push(track.audioCodec);
}
track.codecs = codecs.join(', ');
return track;
}
/**
* @param {shaka.extern.Stream} stream
* @return {shaka.extern.TextTrack}
*/
static textStreamToTrack(stream) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
/** @type {shaka.extern.TextTrack} */
const track = {
id: stream.id,
active: false,
type: ContentType.TEXT,
bandwidth: stream.bandwidth || 0,
language: stream.language,
label: stream.label,
kind: stream.kind || null,
mimeType: stream.mimeType,
codecs: stream.codecs || null,
primary: stream.primary,
roles: stream.roles,
accessibilityPurpose: stream.accessibilityPurpose,
forced: stream.forced,
originalTextId: stream.originalId,
originalLanguage: stream.originalLanguage,
};
return track;
}
/**
* @param {shaka.extern.Stream} stream
* @return {shaka.extern.ImageTrack}
*/
static imageStreamToTrack(stream) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
let width = stream.width || null;
let height = stream.height || null;
// The stream width and height represent the size of the entire thumbnail
// sheet, so divide by the layout.
let reference = null;
// Note: segmentIndex is built by default for HLS, but not for DASH, but
// in DASH this information comes at the stream level and not at the
// segment level.
if (stream.segmentIndex) {
reference = stream.segmentIndex.earliestReference();
}
let layout = stream.tilesLayout;
if (reference) {
layout = reference.getTilesLayout() || layout;
}
if (layout && width != null) {
width /= Number(layout.split('x')[0]);
}
if (layout && height != null) {
height /= Number(layout.split('x')[1]);
}
// TODO: What happens if there are multiple grids, with different
// layout sizes, inside this image stream?
/** @type {shaka.extern.ImageTrack} */
const track = {
id: stream.id,
type: ContentType.IMAGE,
bandwidth: stream.bandwidth || 0,
width,
height,
mimeType: stream.mimeType,
codecs: stream.codecs || null,
tilesLayout: layout || null,
originalImageId: stream.originalId,
};
return track;
}
/**
* Generate and return an ID for this track, since the ID field is optional.
*
* @param {TextTrack|AudioTrack|VideoTrack} html5Track
* @return {number} The generated ID.
*/
static html5TrackId(html5Track) {
if (!html5Track['__shaka_id']) {
html5Track['__shaka_id'] = shaka.util.StreamUtils.nextTrackId_++;
}
return html5Track['__shaka_id'];
}
/**
* @param {TextTrack} textTrack
* @return {shaka.extern.TextTrack}
*/
static html5TextTrackToTrack(textTrack) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
/** @type {shaka.extern.TextTrack} */
const track = {
id: shaka.util.StreamUtils.html5TrackId(textTrack),
active: textTrack.mode != 'disabled',
type: ContentType.TEXT,
bandwidth: 0,
language: shaka.util.LanguageUtils.normalize(textTrack.language || 'und'),
label: textTrack.label,
kind: textTrack.kind,
mimeType: null,
codecs: null,
primary: false,
roles: [],
accessibilityPurpose: null,
forced: textTrack.kind == 'forced',
originalTextId: textTrack.id,
originalLanguage: textTrack.language,
};
if (textTrack.kind == 'captions') {
// See: https://github.com/shaka-project/shaka-player/issues/6233
track.mimeType = 'unknown';
}
if (textTrack.kind == 'subtitles') {
track.mimeType = 'text/vtt';
}
if (textTrack.kind) {
track.roles = [textTrack.kind];
}
return track;
}
/**
* @param {?AudioTrack} audioTrack
* @param {?VideoTrack} videoTrack
* @return {shaka.extern.Track}
*/
static html5TrackToShakaTrack(audioTrack, videoTrack) {
goog.asserts.assert(audioTrack || videoTrack,
'There must be at least audioTrack or videoTrack.');
const LanguageUtils = shaka.util.LanguageUtils;
const language = audioTrack ? audioTrack.language : null;
/** @type {shaka.extern.Track} */
const track = {
id: shaka.util.StreamUtils.html5TrackId(audioTrack || videoTrack),
active: audioTrack ? audioTrack.enabled : videoTrack.selected,
type: 'variant',
bandwidth: 0,
language: LanguageUtils.normalize(language || 'und'),
label: audioTrack ? audioTrack.label : null,
kind: audioTrack ? audioTrack.kind : null,
width: null,
height: null,
frameRate: null,
pixelAspectRatio: null,
hdr: null,
colorGamut: null,
videoLayout: null,
mimeType: null,
audioMimeType: null,
videoMimeType: null,
codecs: null,
audioCodec: null,
videoCodec: null,
primary: audioTrack ? audioTrack.kind == 'main' : false,
roles: [],
forced: false,
audioRoles: null,
videoId: null,
audioId: null,
audioGroupId: null,
channelsCount: null,
audioSamplingRate: null,
spatialAudio: false,
tilesLayout: null,
audioBandwidth: null,
videoBandwidth: null,
originalVideoId: videoTrack ? videoTrack.id : null,
originalAudioId: audioTrack ? audioTrack.id : null,
originalTextId: null,
originalImageId: null,
accessibilityPurpose: null,
originalLanguage: language,
};
if (audioTrack && audioTrack.kind) {
track.roles = [audioTrack.kind];
track.audioRoles = [audioTrack.kind];
}
if (audioTrack && audioTrack.configuration) {
if (audioTrack.configuration.codec) {
track.audioCodec = audioTrack.configuration.codec;
track.codecs = track.audioCodec;
}
if (audioTrack.configuration.bitrate) {
track.audioBandwidth = audioTrack.configuration.bitrate;
track.bandwidth += track.audioBandwidth;
}
if (audioTrack.configuration.sampleRate) {
track.audioSamplingRate = audioTrack.configuration.sampleRate;
}
if (audioTrack.configuration.numberOfChannels) {
track.channelsCount = audioTrack.configuration.numberOfChannels;
}
}
if (videoTrack && videoTrack.configuration) {
if (videoTrack.configuration.codec) {
track.videoCodec = videoTrack.configuration.codec;
if (track.codecs) {
track.codecs += ',' + track.videoCodec;
} else {
track.codecs = track.videoCodec;
}
}
if (videoTrack.configuration.bitrate) {
track.videoBandwidth = videoTrack.configuration.bitrate;
track.bandwidth += track.videoBandwidth;
}
if (videoTrack.configuration.framerate) {
track.frameRate = videoTrack.configuration.framerate;
}
if (videoTrack.configuration.width) {
track.width = videoTrack.configuration.width;
}
if (videoTrack.configuration.height) {
track.height = videoTrack.configuration.height;
}
if (videoTrack.configuration.colorSpace &&
videoTrack.configuration.colorSpace.transfer) {
switch (videoTrack.configuration.colorSpace.transfer) {
case 'pq':
track.hdr = 'PQ';
break;
case 'hlg':
track.hdr = 'HLG';
break;
case 'bt709':
track.hdr = 'SDR';
break;
}
}
}
return track;
}
/**
* Determines if the given variant is playable.
* @param {!shaka.extern.Variant} variant
* @return {boolean}
*/
static isPlayable(variant) {
return variant.allowedByApplication &&
variant.allowedByKeySystem &&
variant.disabledUntilTime == 0;
}
/**
* Filters out unplayable variants.
* @param {!Array<!shaka.extern.Variant>} variants
* @return {!Array<!shaka.extern.Variant>}
*/
static getPlayableVariants(variants) {
return variants.filter((variant) => {
return shaka.util.StreamUtils.isPlayable(variant);
});
}
/**
* Chooses streams according to the given config.
* Works both for Stream and Track types due to their similarities.
*
* @param {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>} streams
* @param {string} preferredLanguage
* @param {string} preferredRole
* @param {boolean} preferredForced
* @return {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>}
*/
static filterStreamsByLanguageAndRole(
streams, preferredLanguage, preferredRole, preferredForced) {
const LanguageUtils = shaka.util.LanguageUtils;
/** @type {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>} */
let chosen = streams;
// Start with the set of primary streams.
/** @type {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>} */
const primary = streams.filter((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.
const firstLanguage = chosen.length ? chosen[0].language : '';
chosen = chosen.filter((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;
});
}
}
// Filter by forced preference
chosen = chosen.filter((stream) => {
return stream.forced == preferredForced;
});
// Now refine the choice based on role preference.
if (preferredRole) {
const roleMatches = shaka.util.StreamUtils.filterStreamsByRole_(
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.
const noRoleMatches = chosen.filter((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.
const allRoles = chosen.map((stream) => {
return stream.roles;
}).reduce(shaka.util.Functional.collapseArrays, []);
if (!allRoles.length) {
return chosen;
}
return shaka.util.StreamUtils.filterStreamsByRole_(chosen, allRoles[0]);
}
/**
* Filter Streams by role.
* Works both for Stream and Track types due to their similarities.
*
* @param {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>} streams
* @param {string} preferredRole
* @return {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>}
* @private
*/
static filterStreamsByRole_(streams, preferredRole) {
return streams.filter((stream) => {
return stream.roles.includes(preferredRole);
});
}
/**
* Checks if the given stream is an audio stream.
*
* @param {shaka.extern.Stream} stream
* @return {boolean}
*/
static isAudio(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}
*/
static isVideo(stream) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
return stream.type == ContentType.VIDEO;
}
/**
* Get all non-null streams in the variant as an array.
*
* @param {shaka.extern.Variant} variant
* @return {!Array<shaka.extern.Stream>}
*/
static getVariantStreams(variant) {
const streams = [];
if (variant.audio) {
streams.push(variant.audio);
}
if (variant.video) {
streams.push(variant.video);
}
return streams;
}
/**
* Indicates if some of the variant's streams are fastSwitching.
*
* @param {shaka.extern.Variant} variant
* @return {boolean}
*/
static isFastSwitching(variant) {
if (variant.audio && variant.audio.fastSwitching) {
return true;
}
if (variant.video && variant.video.fastSwitching) {
return true;
}
return false;
}
/**
* Set the best iframe stream to the original stream.
*
* @param {!shaka.extern.Stream} stream
* @param {!Array<!shaka.extern.Stream>} iFrameStreams
*/
static setBetterIFrameStream(stream, iFrameStreams) {
if (!iFrameStreams.length) {
return;
}
const validStreams = iFrameStreams.filter((iFrameStream) =>
shaka.util.MimeUtils.getNormalizedCodec(stream.codecs) ==
shaka.util.MimeUtils.getNormalizedCodec(iFrameStream.codecs))
.sort((a, b) => {
if (!a.bandwidth || !b.bandwidth || a.bandwidth == b.bandwidth) {
return (a.width || 0) - (b.width || 0);
}
return a.bandwidth - b.bandwidth;
});
stream.trickModeVideo = validStreams[0];
if (validStreams.length > 1) {
const sameResolutionStream = validStreams.find((iFrameStream) =>
stream.width == iFrameStream.width &&
stream.height == iFrameStream.height);
if (sameResolutionStream) {
stream.trickModeVideo = sameResolutionStream;
}
}
}
/**
* Returns a string of a variant, with the attribute values of its audio
* and/or video streams for log printing.
* @param {shaka.extern.Variant} variant
* @return {string}
* @private
*/
static getVariantSummaryString_(variant) {
const summaries = [];
if (variant.audio) {
summaries.push(shaka.util.StreamUtils.getStreamSummaryString_(
variant.audio));
}
if (variant.video) {
summaries.push(shaka.util.StreamUtils.getStreamSummaryString_(
variant.video));
}
return summaries.join(', ');
}
/**
* Returns a string of an audio or video stream for log printing.
* @param {shaka.extern.Stream} stream
* @return {string}
* @private
*/
static getStreamSummaryString_(stream) {
// Accepted parameters for Chromecast can be found (internally) at
// go/cast-mime-params
if (shaka.util.StreamUtils.isAudio(stream)) {
return 'type=audio' +
' codecs=' + stream.codecs +
' bandwidth='+ stream.bandwidth +
' channelsCount=' + stream.channelsCount +
' audioSamplingRate=' + stream.audioSamplingRate;
}
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';
}
/**
* Clears underlying decoding config cache.
*/
static clearDecodingConfigCache() {
shaka.util.StreamUtils.decodingConfigCache_.clear();
}
/**
* Check if we should show text on screen automatically.
*
* @param {?shaka.extern.Stream} audioStream
* @param {shaka.extern.Stream} textStream
* @param {!shaka.extern.PlayerConfiguration} config
* @return {boolean}
*/
static shouldInitiallyShowText(audioStream, textStream, config) {
const AutoShowText = shaka.config.AutoShowText;
if (config.autoShowText == AutoShowText.NEVER) {
return false;
}
if (config.autoShowText == AutoShowText.ALWAYS) {
return true;
}
const LanguageUtils = shaka.util.LanguageUtils;
/** @type {string} */
const preferredTextLocale =
LanguageUtils.normalize(config.preferredTextLanguage);
/** @type {string} */
const textLocale = LanguageUtils.normalize(textStream.language);
if (config.autoShowText == AutoShowText.IF_PREFERRED_TEXT_LANGUAGE) {
// Only the text language match matters.
return LanguageUtils.areLanguageCompatible(
textLocale,
preferredTextLocale);
}
if (config.autoShowText == AutoShowText.IF_SUBTITLES_MAY_BE_NEEDED) {
if (!audioStream) {
return false;
}
/* The text should automatically be shown if the text is
* language-compatible with the user's text language preference, but not
* compatible with the audio. These are cases where we deduce that
* subtitles may be needed.
*
* For example:
* preferred | chosen | chosen |
* text | text | audio | show
* -----------------------------------
* en-CA | en | jp | true
* en | en-US | fr | true
* fr-CA | en-US | jp | false
* en-CA | en-US | en-US | false
*
*/
/** @type {string} */
const audioLocale = LanguageUtils.normalize(audioStream.language);
return (
LanguageUtils.areLanguageCompatible(textLocale, preferredTextLocale) &&
!LanguageUtils.areLanguageCompatible(audioLocale, textLocale));
}
shaka.log.alwaysWarn('Invalid autoShowText setting!');
return false;
}
};
/**
* A cache of results from mediaCapabilities.decodingInfo, indexed by the
* (stringified) decodingConfig.
*
* @type {Map<string, !MediaCapabilitiesDecodingInfo>}
* @private
*/
shaka.util.StreamUtils.decodingConfigCache_ = new Map();
/** @private {number} */
shaka.util.StreamUtils.nextTrackId_ = 0;
/**
* @enum {string}
*/
shaka.util.StreamUtils.DecodingAttributes = {
SMOOTH: 'smooth',
POWER: 'powerEfficient',
};
/**
* @private {!Map<string, boolean>}
*/
shaka.util.StreamUtils.supportedImageMimeTypes_ = new Map()
.set('image/svg+xml', true)
.set('image/png', true)
.set('image/jpeg', true)
.set('image/jpg', true);
/**
* @const {string}
* @private
*/
// cspell: disable-next-line
shaka.util.StreamUtils.minWebPImage_ = 'data:image/webp;base64,UklGRjoAAABXRU' +
'JQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwY' +
'AAA';
/**
* @const {string}
* @private
*/
// cspell: disable-next-line
shaka.util.StreamUtils.minAvifImage_ = 'data:image/avif;base64,AAAAIGZ0eXBhdm' +
'lmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljd' +
'AAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEA' +
'AAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAA' +
'AamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAA' +
'xhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAA' +
'CVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A=';
/**
* @const {!Map<string, string>}
* @private
*/
shaka.util.StreamUtils.minImage_ = new Map()
.set('image/webp', shaka.util.StreamUtils.minWebPImage_)
.set('image/avif', shaka.util.StreamUtils.minAvifImage_);