/** * @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.ArrayUtils'); 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 {shakaExtern.Variant} variant * @param {shakaExtern.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) { let video = variant.video; if (video) { if (video.width < restrictions.minWidth || video.width > restrictions.maxWidth || video.width > maxHwRes.width || video.height < restrictions.minHeight || video.height > restrictions.maxHeight || video.height > maxHwRes.height || (video.width * video.height) < restrictions.minPixels || (video.width * video.height) > restrictions.maxPixels) { return false; } } if (variant.bandwidth < restrictions.minBandwidth || variant.bandwidth > restrictions.maxBandwidth) { return false; } return true; }; /** * @param {shakaExtern.Period} period * @param {shakaExtern.Restrictions} restrictions * @param {{width: number, height: number}} maxHwRes * @return {boolean} Whether the tracks changed. */ shaka.util.StreamUtils.applyRestrictions = function(period, restrictions, maxHwRes) { let tracksChanged = false; period.variants.forEach(function(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 {?shakaExtern.Stream} activeAudio * @param {?shakaExtern.Stream} activeVideo * @param {shakaExtern.Period} period */ shaka.util.StreamUtils.filterNewPeriod = function( drmEngine, activeAudio, activeVideo, period) { const StreamUtils = shaka.util.StreamUtils; if (activeAudio) { goog.asserts.assert( shaka.util.StreamUtils.isAudio(activeAudio), 'Audio streams must have the audio type.'); } if (activeVideo) { goog.asserts.assert( shaka.util.StreamUtils.isVideo(activeVideo), 'Video streams must have the video type.'); } // Filter variants. period.variants = period.variants.filter(function(variant) { let keep = StreamUtils.isVariantCompatible_( variant, drmEngine, activeAudio, activeVideo); if (!keep) { shaka.log.debug('Dropping Variant (not compatible with key system, ' + 'platform, or active Variant)', variant); } return keep; }); // 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; }); }; /** * Checks if a stream is compatible with the key system, platform, * and active stream. * This does not check if the stream is supported by the chosen key system. * * @param {?shakaExtern.Stream} stream A non-text stream to check. * @param {shaka.media.DrmEngine} drmEngine * @param {?shakaExtern.Stream} activeStream * @return {boolean} * @private */ shaka.util.StreamUtils.isStreamCompatible_ = function(stream, drmEngine, activeStream) { if (!stream) return true; const ContentType = shaka.util.ManifestParserUtils.ContentType; goog.asserts.assert(stream.type != ContentType.TEXT, 'Should not be called on a text stream!'); let drmSupportedMimeTypes = null; if (drmEngine && drmEngine.initialized()) { drmSupportedMimeTypes = drmEngine.getSupportedTypes(); } // Check if the stream can be played by the platform. let fullMimeType = shaka.util.MimeUtils.getFullType( stream.mimeType, stream.codecs); if (!shaka.media.MediaSourceEngine.isStreamSupported(stream)) { return false; } // Check if the stream can be handled by the key system. // There's no need to check that the stream is supported by the // chosen key system since the caller has already verified that. if (drmSupportedMimeTypes && stream.encrypted && drmSupportedMimeTypes.indexOf(fullMimeType) < 0) { return false; } // Lastly, check if the active stream can switch to the stream. // 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 (activeStream) { if (stream.mimeType != activeStream.mimeType || stream.codecs.split('.')[0] != activeStream.codecs.split('.')[0]) { return false; } } return true; }; /** * Checks if a variant is compatible with the key system, platform, * and active stream. * * @param {!shakaExtern.Variant} variant * @param {shaka.media.DrmEngine} drmEngine * @param {?shakaExtern.Stream} activeAudio * @param {?shakaExtern.Stream} activeVideo * @return {boolean} * @private */ shaka.util.StreamUtils.isVariantCompatible_ = function(variant, drmEngine, activeAudio, activeVideo) { if (drmEngine && drmEngine.initialized()) { if (!drmEngine.isSupportedByKeySystem(variant)) return false; } let isStreamCompatible = shaka.util.StreamUtils.isStreamCompatible_; return isStreamCompatible(variant.audio, drmEngine, activeAudio) && isStreamCompatible(variant.video, drmEngine, activeVideo); }; /** * @param {shakaExtern.Variant} variant * @return {shakaExtern.Track} */ shaka.util.StreamUtils.variantToTrack = function(variant) { /** @type {?shakaExtern.Stream} */ let audio = variant.audio; /** @type {?shakaExtern.Stream} */ let video = variant.video; /** @type {?string} */ let audioCodec = audio ? audio.codecs : null; /** @type {?string} */ let videoCodec = video ? video.codecs : null; /** @type {!Array.} */ let codecs = []; if (videoCodec) codecs.push(videoCodec); if (audioCodec) codecs.push(audioCodec); /** @type {!Array.} */ let mimeTypes = []; if (video) mimeTypes.push(video.mimeType); if (audio) mimeTypes.push(audio.mimeType); /** @type {?string} */ let mimeType = mimeTypes[0] || null; /** @type {!Array.} */ let kinds = []; if (audio) kinds.push(audio.kind); if (video) kinds.push(video.kind); /** @type {?string} */ let kind = kinds[0] || null; /** @type {!Array.} */ let roles = []; if (audio) roles.push.apply(roles, audio.roles); if (video) roles.push.apply(roles, video.roles); roles = shaka.util.ArrayUtils.removeDuplicates(roles); /** @type {shakaExtern.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: roles, videoId: null, audioId: null, channelCount: null, audioBandwidth: null, videoBandwidth: null }; if (video) { track.videoId = video.id; 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.channelCount = audio.channelCount; track.audioBandwidth = audio.bandwidth || null; track.label = audio.label; } return track; }; /** * @param {shakaExtern.Stream} stream * @return {shakaExtern.Track} */ shaka.util.StreamUtils.textStreamToTrack = function(stream) { const ContentType = shaka.util.ManifestParserUtils.ContentType; /** @type {shakaExtern.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, channelCount: null, audioBandwidth: null, videoBandwidth: null }; return track; }; /** * Gets track representations of all playable variants and all text streams. * * @param {shakaExtern.Period} period * @return {!Array.} */ shaka.util.StreamUtils.getTracks = function(period) { const StreamUtils = shaka.util.StreamUtils; let tracks = []; let variants = StreamUtils.getPlayableVariants(period.variants); let textStreams = period.textStreams; variants.forEach(function(variant) { tracks.push(StreamUtils.variantToTrack(variant)); }); textStreams.forEach(function(stream) { tracks.push(StreamUtils.textStreamToTrack(stream)); }); return tracks; }; /** * Gets an array of Track objects for the given Period. * * @param {shakaExtern.Period} period * @param {?number} activeAudioId * @param {?number} activeVideoId * @return {!Array.} */ shaka.util.StreamUtils.getVariantTracks = function(period, activeAudioId, activeVideoId) { const StreamUtils = shaka.util.StreamUtils; let variants = StreamUtils.getPlayableVariants(period.variants); return variants.map(function(variant) { let track = StreamUtils.variantToTrack(variant); if (variant.video && variant.audio) { track.active = activeVideoId == variant.video.id && activeAudioId == variant.audio.id; } else if (variant.video) { track.active = activeVideoId == variant.video.id; } else if (variant.audio) { track.active = activeAudioId == variant.audio.id; } return track; }); }; /** * Gets an array of text Track objects for the given Period. * * @param {shakaExtern.Period} period * @param {?number} activeStreamId * @return {!Array.} */ shaka.util.StreamUtils.getTextTracks = function(period, activeStreamId) { return period.textStreams.map(function(stream) { let track = shaka.util.StreamUtils.textStreamToTrack(stream); track.active = activeStreamId == stream.id; return track; }); }; /** * Finds the Variant for the given track. * * @param {shakaExtern.Period} period * @param {shakaExtern.Track} track * @return {?shakaExtern.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 {shakaExtern.Period} period * @param {shakaExtern.Track} track * @return {?shakaExtern.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 {!shakaExtern.Variant} variant * @return {boolean} */ shaka.util.StreamUtils.isPlayable = function(variant) { return variant.allowedByApplication && variant.allowedByKeySystem; }; /** * Filters out unplayable variants. * @param {!Array.} variants * @return {!Array.} */ shaka.util.StreamUtils.getPlayableVariants = function(variants) { return variants.filter(function(variant) { return shaka.util.StreamUtils.isPlayable(variant); }); }; /** * Chooses variants according to the given config. * * @param {!Array.} variants * @param {string} preferredLanguage * @param {string} preferredRole * @param {number} preferredAudioChannelCount * @param {!Object=} languageMatches * @return {!Array.} */ shaka.util.StreamUtils.filterVariantsByConfig = function( variants, preferredLanguage, preferredRole, preferredAudioChannelCount, languageMatches) { let chosen = shaka.util.StreamUtils.filterVariantsByLanguageAndRole( variants, preferredLanguage, preferredRole, languageMatches); return shaka.util.StreamUtils.filterVariantsByAudioChannelCount(chosen, preferredAudioChannelCount); }; /** * Chooses variants according to the given config. * * @param {!Array.} variants * @param {string} preferredLanguage * @param {string} preferredRole * @param {!Object=} languageMatches * @return {!Array.} */ shaka.util.StreamUtils.filterVariantsByLanguageAndRole = function( variants, preferredLanguage, preferredRole, languageMatches) { const LanguageUtils = shaka.util.LanguageUtils; const ContentType = shaka.util.ManifestParserUtils.ContentType; /** @type {!Array.} */ let playable = shaka.util.StreamUtils.getPlayableVariants(variants); /** @type {!Array.} */ let chosen = playable; // Start with the set of primary variants. /** @type {!Array.} */ let primary = playable.filter(function(variant) { return variant.primary; }); if (primary.length) { chosen = primary; } // Now reduce the set to one language. This covers both arbitrary language // choices and the reduction of the "primary" variant set to one language. let firstLanguage = chosen.length ? chosen[0].language : ''; chosen = chosen.filter(function(variant) { return variant.language == firstLanguage; }); // Now search for matches based on language preference. If any language match // is found, it overrides the selection above. Favor exact matches, then base // matches, finally different subtags. Execute in reverse order so the later // steps override the previous ones. if (preferredLanguage) { let pref = LanguageUtils.normalize(preferredLanguage); [LanguageUtils.MatchType.OTHER_SUB_LANGUAGE_OKAY, LanguageUtils.MatchType.BASE_LANGUAGE_OKAY, LanguageUtils.MatchType.EXACT] .forEach(function(matchType) { let betterLangMatchFound = false; playable.forEach(function(variant) { pref = LanguageUtils.normalize(pref); let lang = LanguageUtils.normalize(variant.language); if (LanguageUtils.match(matchType, pref, lang)) { if (betterLangMatchFound) { chosen.push(variant); } else { chosen = [variant]; betterLangMatchFound = true; } if (languageMatches) { languageMatches[ContentType.AUDIO] = true; } } }); // forEach(variant) }); // forEach(matchType) } // if (preferredLanguage) // Now refine the choice based on role preference. if (preferredRole) { let roleMatches = shaka.util.StreamUtils.filterVariantsByRole_( chosen, preferredRole); if (roleMatches.length) { return roleMatches; } else { shaka.log.warning('No exact match for the variant role could be found.'); } } // 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(variant) { let audioRoles = variant.audio ? variant.audio.roles : []; let videoRoles = variant.video ? variant.video.roles : []; return audioRoles.concat(videoRoles); }).reduce(shaka.util.Functional.collapseArrays, []); if (!allRoles.length) { return chosen; } return shaka.util.StreamUtils.filterVariantsByRole_(chosen, allRoles[0]); }; /** * Filters variants according to the given audio channel count config. * * @param {!Array.} variants * @param {number} preferredAudioChannelCount * @return {!Array.} */ shaka.util.StreamUtils.filterVariantsByAudioChannelCount = function( variants, preferredAudioChannelCount) { // Group variants by their audio channel counts. let variantsByChannelCount = variants .filter((v) => v.audio && v.audio.channelCount) .reduce((map, variant) => { let count = variant.audio.channelCount; 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.} streams * @param {string} preferredLanguage * @param {string} preferredRole * @param {!Object=} languageMatches * @return {!Array.} */ shaka.util.StreamUtils.filterStreamsByLanguageAndRole = function( streams, preferredLanguage, preferredRole, languageMatches) { const LanguageUtils = shaka.util.LanguageUtils; const ContentType = shaka.util.ManifestParserUtils.ContentType; /** @type {!Array.} */ let chosen = streams; // Start with the set of primary streams. /** @type {!Array.} */ 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; }); // Now search for matches based on language preference. If any language match // is found, it overrides the selection above. Favor exact matches, then base // matches, finally different subtags. Execute in reverse order so the later // steps override the previous ones. if (preferredLanguage) { let pref = LanguageUtils.normalize(preferredLanguage); [LanguageUtils.MatchType.OTHER_SUB_LANGUAGE_OKAY, LanguageUtils.MatchType.BASE_LANGUAGE_OKAY, LanguageUtils.MatchType.EXACT] .forEach(function(matchType) { let betterLangMatchFound = false; streams.forEach(function(stream) { let lang = LanguageUtils.normalize(stream.language); if (LanguageUtils.match(matchType, pref, lang)) { if (betterLangMatchFound) { chosen.push(stream); } else { chosen = [stream]; betterLangMatchFound = true; } if (languageMatches) { languageMatches[ContentType.TEXT] = true; } } }); // forEach(stream) }); // forEach(matchType) } // if (preferredLanguage) // 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 Variants by role. * * @param {!Array.} variants * @param {string} preferredRole * @return {!Array.} * @private */ shaka.util.StreamUtils.filterVariantsByRole_ = function(variants, preferredRole) { return variants.filter(function(variant) { return (variant.audio && variant.audio.roles.indexOf(preferredRole) >= 0) || (variant.video && variant.video.roles.indexOf(preferredRole) >= 0); }); }; /** * Filter text Streams by role. * * @param {!Array.} textStreams * @param {string} preferredRole * @return {!Array.} * @private */ shaka.util.StreamUtils.filterTextStreamsByRole_ = function(textStreams, preferredRole) { return textStreams.filter(function(stream) { return stream.roles.indexOf(preferredRole) >= 0; }); }; /** * Finds a Variant with given audio and video streams. * Returns null if no such Variant was found. * * @param {?shakaExtern.Stream} audio * @param {?shakaExtern.Stream} video * @param {!Array.} variants * @return {?shakaExtern.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; }; /** * Finds a Variant with the given video and audio streams, by stream ID. * Returns null if no such Variant was found. * * @param {?number} audioId * @param {?number} videoId * @param {!Array.} variants * @return {?shakaExtern.Variant} */ shaka.util.StreamUtils.getVariantByStreamIds = function( audioId, videoId, variants) { function matchesId(id, stream) { if (id == null) { return stream == null; } else { return stream.id == id; } } for (let i = 0; i < variants.length; i++) { if (matchesId(audioId, variants[i].audio) && matchesId(videoId, variants[i].video)) { return variants[i]; } } return null; }; /** * Gets the index of the Period that contains the given time. * @param {shakaExtern.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 {shakaExtern.Manifest} manifest * @param {shakaExtern.Stream} stream * @return {number} The index of the Period which contains |stream|, or -1 if * no Period contains |stream|. */ shaka.util.StreamUtils.findPeriodContainingStream = function(manifest, stream) { const ContentType = shaka.util.ManifestParserUtils.ContentType; for (let periodIdx = 0; periodIdx < manifest.periods.length; ++periodIdx) { let period = manifest.periods[periodIdx]; if (stream.type == ContentType.TEXT) { for (let j = 0; j < period.textStreams.length; ++j) { let textStream = period.textStreams[j]; if (textStream == stream) { return periodIdx; } } } else { for (let j = 0; j < period.variants.length; ++j) { let variant = period.variants[j]; if (variant.audio == stream || variant.video == stream || (variant.video && variant.video.trickModeVideo == stream)) { return periodIdx; } } } } return -1; }; /** * @param {shakaExtern.Manifest} manifest * @param {shakaExtern.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; }; /** * Gets the rebuffering goal from the manifest and configuration. * * @param {shakaExtern.Manifest} manifest * @param {shakaExtern.StreamingConfiguration} config * @param {number} scaleFactor * * @return {number} */ shaka.util.StreamUtils.getRebufferingGoal = function( manifest, config, scaleFactor) { return scaleFactor * Math.max(manifest.minBufferTime || 0, config.rebufferingGoal); }; /** * Checks if the given stream is an audio stream. * * @param {shakaExtern.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 {shakaExtern.Stream} stream * @return {boolean} */ shaka.util.StreamUtils.isVideo = function(stream) { const ContentType = shaka.util.ManifestParserUtils.ContentType; return stream.type == ContentType.VIDEO; };