UNPKG

shaka-player

Version:
1,362 lines (1,202 loc) 79.5 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.dash.DashParser'); goog.require('goog.asserts'); goog.require('shaka.abr.Ewma'); goog.require('shaka.dash.ContentProtection'); goog.require('shaka.dash.MpdUtils'); goog.require('shaka.dash.SegmentBase'); goog.require('shaka.dash.SegmentList'); goog.require('shaka.dash.SegmentTemplate'); goog.require('shaka.log'); goog.require('shaka.media.ManifestParser'); goog.require('shaka.media.PresentationTimeline'); goog.require('shaka.media.SegmentIndex'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.text.TextEngine'); goog.require('shaka.util.Error'); goog.require('shaka.util.Functional'); goog.require('shaka.util.LanguageUtils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Networking'); goog.require('shaka.util.OperationManager'); goog.require('shaka.util.PeriodCombiner'); goog.require('shaka.util.StringUtils'); goog.require('shaka.util.Timer'); goog.require('shaka.util.XmlUtils'); /** * Creates a new DASH parser. * * @implements {shaka.extern.ManifestParser} * @export */ shaka.dash.DashParser = class { /** Creates a new DASH parser. */ constructor() { /** @private {?shaka.extern.ManifestConfiguration} */ this.config_ = null; /** @private {?shaka.extern.ManifestParser.PlayerInterface} */ this.playerInterface_ = null; /** @private {!Array.<string>} */ this.manifestUris_ = []; /** @private {?shaka.extern.Manifest} */ this.manifest_ = null; /** @private {number} */ this.globalId_ = 1; /** * A map of IDs to Stream objects. * ID: Period@id,AdaptationSet@id,@Representation@id * e.g.: '1,5,23' * @private {!Object.<string, !shaka.extern.Stream>} */ this.streamMap_ = {}; /** * A map of period ids to their durations * @private {!Object.<string, number>} */ this.periodDurations_ = {}; /** @private {shaka.util.PeriodCombiner} */ this.periodCombiner_ = new shaka.util.PeriodCombiner(); /** * The update period in seconds, or 0 for no updates. * @private {number} */ this.updatePeriod_ = 0; /** * An ewma that tracks how long updates take. * This is to mitigate issues caused by slow parsing on embedded devices. * @private {!shaka.abr.Ewma} */ this.averageUpdateDuration_ = new shaka.abr.Ewma(5); /** @private {shaka.util.Timer} */ this.updateTimer_ = new shaka.util.Timer(() => { this.onUpdate_(); }); /** @private {!shaka.util.OperationManager} */ this.operationManager_ = new shaka.util.OperationManager(); /** * Largest period start time seen. * @private {?number} */ this.largestPeriodStartTime_ = null; /** * Period IDs seen in previous manifest. * @private {!Array.<string>} */ this.lastManifestUpdatePeriodIds_ = []; /** * The minimum of the availabilityTimeOffset values among the adaptation * sets. * @private {number} */ this.minTotalAvailabilityTimeOffset_ = Infinity; /** @private {boolean} */ this.lowLatencyMode_ = false; } /** * @override * @exportInterface */ configure(config) { goog.asserts.assert(config.dash != null, 'DashManifestConfiguration should not be null!'); this.config_ = config; } /** * @override * @exportInterface */ async start(uri, playerInterface) { goog.asserts.assert(this.config_, 'Must call configure() before start()!'); this.lowLatencyMode_ = playerInterface.isLowLatencyMode(); this.manifestUris_ = [uri]; this.playerInterface_ = playerInterface; const updateDelay = await this.requestManifest_(); if (this.playerInterface_) { this.setUpdateTimer_(updateDelay); } // Make sure that the parser has not been destroyed. if (!this.playerInterface_) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.PLAYER, shaka.util.Error.Code.OPERATION_ABORTED); } goog.asserts.assert(this.manifest_, 'Manifest should be non-null!'); return this.manifest_; } /** * @override * @exportInterface */ stop() { // When the parser stops, release all segment indexes, which stops their // timers, as well. for (const stream of Object.values(this.streamMap_)) { if (stream.segmentIndex) { stream.segmentIndex.release(); } } if (this.periodCombiner_) { this.periodCombiner_.release(); } this.playerInterface_ = null; this.config_ = null; this.manifestUris_ = []; this.manifest_ = null; this.streamMap_ = {}; this.periodCombiner_ = null; if (this.updateTimer_ != null) { this.updateTimer_.stop(); this.updateTimer_ = null; } return this.operationManager_.destroy(); } /** * @override * @exportInterface */ async update() { try { await this.requestManifest_(); } catch (error) { if (!this.playerInterface_ || !error) { return; } goog.asserts.assert(error instanceof shaka.util.Error, 'Bad error type'); this.playerInterface_.onError(error); } } /** * @override * @exportInterface */ onExpirationUpdated(sessionId, expiration) { // No-op } /** * Makes a network request for the manifest and parses the resulting data. * * @return {!Promise.<number>} Resolves with the time it took, in seconds, to * fulfill the request and parse the data. * @private */ async requestManifest_() { const requestType = shaka.net.NetworkingEngine.RequestType.MANIFEST; const type = shaka.net.NetworkingEngine.AdvancedRequestType.MPD; const request = shaka.net.NetworkingEngine.makeRequest( this.manifestUris_, this.config_.retryParameters); const startTime = Date.now(); const response = await this.makeNetworkRequest_( request, requestType, {type}); // Detect calls to stop(). if (!this.playerInterface_) { return 0; } // For redirections add the response uri to the first entry in the // Manifest Uris array. if (response.uri && !this.manifestUris_.includes(response.uri)) { this.manifestUris_.unshift(response.uri); } // This may throw, but it will result in a failed promise. await this.parseManifest_(response.data, response.uri); // Keep track of how long the longest manifest update took. const endTime = Date.now(); const updateDuration = (endTime - startTime) / 1000.0; this.averageUpdateDuration_.sample(1, updateDuration); // Let the caller know how long this update took. return updateDuration; } /** * Parses the manifest XML. This also handles updates and will update the * stored manifest. * * @param {BufferSource} data * @param {string} finalManifestUri The final manifest URI, which may * differ from this.manifestUri_ if there has been a redirect. * @return {!Promise} * @private */ async parseManifest_(data, finalManifestUri) { const Error = shaka.util.Error; const MpdUtils = shaka.dash.MpdUtils; const mpd = shaka.util.XmlUtils.parseXml(data, 'MPD'); if (!mpd) { throw new Error( Error.Severity.CRITICAL, Error.Category.MANIFEST, Error.Code.DASH_INVALID_XML, finalManifestUri); } const disableXlinkProcessing = this.config_.dash.disableXlinkProcessing; if (disableXlinkProcessing) { return this.processManifest_(mpd, finalManifestUri); } // Process the mpd to account for xlink connections. const failGracefully = this.config_.dash.xlinkFailGracefully; const xlinkOperation = MpdUtils.processXlinks( mpd, this.config_.retryParameters, failGracefully, finalManifestUri, this.playerInterface_.networkingEngine); this.operationManager_.manage(xlinkOperation); const finalMpd = await xlinkOperation.promise; return this.processManifest_(finalMpd, finalManifestUri); } /** * Takes a formatted MPD and converts it into a manifest. * * @param {!Element} mpd * @param {string} finalManifestUri The final manifest URI, which may * differ from this.manifestUri_ if there has been a redirect. * @return {!Promise} * @private */ async processManifest_(mpd, finalManifestUri) { const Functional = shaka.util.Functional; const XmlUtils = shaka.util.XmlUtils; const manifestPreprocessor = this.config_.dash.manifestPreprocessor; if (manifestPreprocessor) { manifestPreprocessor(mpd); } // Get any Location elements. This will update the manifest location and // the base URI. /** @type {!Array.<string>} */ let manifestBaseUris = [finalManifestUri]; /** @type {!Array.<string>} */ const locations = XmlUtils.findChildren(mpd, 'Location') .map(XmlUtils.getContents) .filter(Functional.isNotNull); if (locations.length > 0) { const absoluteLocations = shaka.util.ManifestParserUtils.resolveUris( manifestBaseUris, locations); this.manifestUris_ = absoluteLocations; manifestBaseUris = absoluteLocations; } const uriObjs = XmlUtils.findChildren(mpd, 'BaseURL'); const uris = uriObjs.map(XmlUtils.getContents); const baseUris = shaka.util.ManifestParserUtils.resolveUris( manifestBaseUris, uris); let availabilityTimeOffset = 0; if (uriObjs && uriObjs.length) { availabilityTimeOffset = XmlUtils.parseAttr( uriObjs[0], 'availabilityTimeOffset', XmlUtils.parseFloat) || 0; } const ignoreMinBufferTime = this.config_.dash.ignoreMinBufferTime; let minBufferTime = 0; if (!ignoreMinBufferTime) { minBufferTime = XmlUtils.parseAttr(mpd, 'minBufferTime', XmlUtils.parseDuration) || 0; } this.updatePeriod_ = /** @type {number} */ (XmlUtils.parseAttr( mpd, 'minimumUpdatePeriod', XmlUtils.parseDuration, -1)); const presentationStartTime = XmlUtils.parseAttr( mpd, 'availabilityStartTime', XmlUtils.parseDate); let segmentAvailabilityDuration = XmlUtils.parseAttr( mpd, 'timeShiftBufferDepth', XmlUtils.parseDuration); const ignoreSuggestedPresentationDelay = this.config_.dash.ignoreSuggestedPresentationDelay; let suggestedPresentationDelay = null; if (!ignoreSuggestedPresentationDelay) { suggestedPresentationDelay = XmlUtils.parseAttr( mpd, 'suggestedPresentationDelay', XmlUtils.parseDuration); } const ignoreMaxSegmentDuration = this.config_.dash.ignoreMaxSegmentDuration; let maxSegmentDuration = null; if (!ignoreMaxSegmentDuration) { maxSegmentDuration = XmlUtils.parseAttr( mpd, 'maxSegmentDuration', XmlUtils.parseDuration); } const mpdType = mpd.getAttribute('type') || 'static'; /** @type {!shaka.media.PresentationTimeline} */ let presentationTimeline; if (this.manifest_) { presentationTimeline = this.manifest_.presentationTimeline; // Before processing an update, evict from all segment indexes. Some of // them may not get updated otherwise if their corresponding Period // element has been dropped from the manifest since the last update. // Without this, playback will still work, but this is necessary to // maintain conditions that we assert on for multi-Period content. // This gives us confidence that our state is maintained correctly, and // that the complex logic of multi-Period eviction and period-flattening // is correct. See also: // https://github.com/shaka-project/shaka-player/issues/3169#issuecomment-823580634 for (const stream of Object.values(this.streamMap_)) { if (stream.segmentIndex) { stream.segmentIndex.evict( presentationTimeline.getSegmentAvailabilityStart()); } } } else { // DASH IOP v3.0 suggests using a default delay between minBufferTime // and timeShiftBufferDepth. This is literally the range of all // feasible choices for the value. Nothing older than // timeShiftBufferDepth is still available, and anything less than // minBufferTime will cause buffering issues. // // We have decided that our default will be the configured value, or // 1.5 * minBufferTime if not configured. This is fairly conservative. // Content providers should provide a suggestedPresentationDelay whenever // possible to optimize the live streaming experience. const defaultPresentationDelay = this.config_.defaultPresentationDelay || minBufferTime * 1.5; const presentationDelay = suggestedPresentationDelay != null ? suggestedPresentationDelay : defaultPresentationDelay; presentationTimeline = new shaka.media.PresentationTimeline( presentationStartTime, presentationDelay, this.config_.dash.autoCorrectDrift); } presentationTimeline.setStatic(mpdType == 'static'); const isLive = presentationTimeline.isLive(); // If it's live, we check for an override. if (isLive && !isNaN(this.config_.availabilityWindowOverride)) { segmentAvailabilityDuration = this.config_.availabilityWindowOverride; } // If it's null, that means segments are always available. This is always // the case for VOD, and sometimes the case for live. if (segmentAvailabilityDuration == null) { segmentAvailabilityDuration = Infinity; } presentationTimeline.setSegmentAvailabilityDuration( segmentAvailabilityDuration); const profiles = mpd.getAttribute('profiles') || ''; /** @type {shaka.dash.DashParser.Context} */ const context = { // Don't base on updatePeriod_ since emsg boxes can cause manifest // updates. dynamic: mpdType != 'static', presentationTimeline: presentationTimeline, period: null, periodInfo: null, adaptationSet: null, representation: null, bandwidth: 0, indexRangeWarningGiven: false, availabilityTimeOffset: availabilityTimeOffset, profiles: profiles.split(','), }; const periodsAndDuration = this.parsePeriods_(context, baseUris, mpd); const duration = periodsAndDuration.duration; const periods = periodsAndDuration.periods; if (mpdType == 'static' || !periodsAndDuration.durationDerivedFromPeriods) { // Ignore duration calculated from Period lengths if this is dynamic. presentationTimeline.setDuration(duration || Infinity); } // The segments are available earlier than the availability start time. // If the stream is low latency and the user has not configured the // lowLatencyMode, but if it has been configured to activate the // lowLatencyMode if a stream of this type is detected, we automatically // activate the lowLatencyMode. if (this.minTotalAvailabilityTimeOffset_ && !this.lowLatencyMode_) { const autoLowLatencyMode = this.playerInterface_.isAutoLowLatencyMode(); if (autoLowLatencyMode) { this.playerInterface_.enableLowLatencyMode(); this.lowLatencyMode_ = this.playerInterface_.isLowLatencyMode(); } } if (this.lowLatencyMode_) { presentationTimeline.setAvailabilityTimeOffset( this.minTotalAvailabilityTimeOffset_); } else if (this.minTotalAvailabilityTimeOffset_) { // If the playlist contains AvailabilityTimeOffset value, the // streaming.lowLatencyMode value should be set to true to stream with low // latency mode. shaka.log.alwaysWarn('Low-latency DASH live stream detected, but ' + 'low-latency streaming mode is not enabled in Shaka Player. ' + 'Set streaming.lowLatencyMode configuration to true, and see ' + 'https://bit.ly/3clctcj for details.'); } // Use @maxSegmentDuration to override smaller, derived values. presentationTimeline.notifyMaxSegmentDuration(maxSegmentDuration || 1); if (goog.DEBUG) { presentationTimeline.assertIsValid(); } await this.periodCombiner_.combinePeriods(periods, context.dynamic); // Set minBufferTime to 0 for low-latency DASH live stream to achieve the // best latency if (this.lowLatencyMode_) { minBufferTime = 0; } // Set updatePeriod_ to minTotalAvailabilityTimeOffset_ to achieve the best // latency in LL streams if (this.lowLatencyMode_ && this.minTotalAvailabilityTimeOffset_) { this.updatePeriod_ = this.minTotalAvailabilityTimeOffset_; } // These steps are not done on manifest update. if (!this.manifest_) { this.manifest_ = { presentationTimeline: presentationTimeline, variants: this.periodCombiner_.getVariants(), textStreams: this.periodCombiner_.getTextStreams(), imageStreams: this.periodCombiner_.getImageStreams(), offlineSessionIds: [], minBufferTime: minBufferTime || 0, sequenceMode: this.config_.dash.sequenceMode, ignoreManifestTimestampsInSegmentsMode: false, type: shaka.media.ManifestParser.DASH, serviceDescription: this.parseServiceDescription_(mpd), }; // We only need to do clock sync when we're using presentation start // time. This condition also excludes VOD streams. if (presentationTimeline.usingPresentationStartTime()) { const XmlUtils = shaka.util.XmlUtils; const timingElements = XmlUtils.findChildren(mpd, 'UTCTiming'); const offset = await this.parseUtcTiming_(baseUris, timingElements); // Detect calls to stop(). if (!this.playerInterface_) { return; } presentationTimeline.setClockOffset(offset); } // This is the first point where we have a meaningful presentation start // time, and we need to tell PresentationTimeline that so that it can // maintain consistency from here on. presentationTimeline.lockStartTime(); } else { // Just update the variants and text streams, which may change as periods // are added or removed. this.manifest_.variants = this.periodCombiner_.getVariants(); const textStreams = this.periodCombiner_.getTextStreams(); if (textStreams.length > 0) { this.manifest_.textStreams = textStreams; } this.manifest_.imageStreams = this.periodCombiner_.getImageStreams(); // Re-filter the manifest. This will check any configured restrictions on // new variants, and will pass any new init data to DrmEngine to ensure // that key rotation works correctly. this.playerInterface_.filter(this.manifest_); } // Add text streams to correspond to closed captions. This happens right // after period combining, while we still have a direct reference, so that // any new streams will appear in the period combiner. this.playerInterface_.makeTextStreamsForClosedCaptions(this.manifest_); } /** * Reads maxLatency and maxPlaybackRate properties from service * description element. * * @param {!Element} mpd * @return {?shaka.extern.ServiceDescription} * @private */ parseServiceDescription_(mpd) { const XmlUtils = shaka.util.XmlUtils; const elem = XmlUtils.findChild(mpd, 'ServiceDescription'); if (!elem ) { return null; } const latencyNode = XmlUtils.findChild(elem, 'Latency'); const playbackRateNode = XmlUtils.findChild(elem, 'PlaybackRate'); if ((latencyNode && latencyNode.getAttribute('max')) || playbackRateNode) { const maxLatency = latencyNode && latencyNode.getAttribute('max') ? parseInt(latencyNode.getAttribute('max'), 10) / 1000 : null; const maxPlaybackRate = playbackRateNode ? parseFloat(playbackRateNode.getAttribute('max')) : null; return {maxLatency, maxPlaybackRate}; } return null; } /** * Reads and parses the periods from the manifest. This first does some * partial parsing so the start and duration is available when parsing * children. * * @param {shaka.dash.DashParser.Context} context * @param {!Array.<string>} baseUris * @param {!Element} mpd * @return {{ * periods: !Array.<shaka.extern.Period>, * duration: ?number, * durationDerivedFromPeriods: boolean * }} * @private */ parsePeriods_(context, baseUris, mpd) { const XmlUtils = shaka.util.XmlUtils; const presentationDuration = XmlUtils.parseAttr( mpd, 'mediaPresentationDuration', XmlUtils.parseDuration); const periods = []; let prevEnd = 0; const periodNodes = XmlUtils.findChildren(mpd, 'Period'); for (let i = 0; i < periodNodes.length; i++) { const elem = periodNodes[i]; const next = periodNodes[i + 1]; const start = /** @type {number} */ ( XmlUtils.parseAttr(elem, 'start', XmlUtils.parseDuration, prevEnd)); const periodId = elem.id; const givenDuration = XmlUtils.parseAttr(elem, 'duration', XmlUtils.parseDuration); let periodDuration = null; if (next) { // "The difference between the start time of a Period and the start time // of the following Period is the duration of the media content // represented by this Period." const nextStart = XmlUtils.parseAttr(next, 'start', XmlUtils.parseDuration); if (nextStart != null) { periodDuration = nextStart - start; } } else if (presentationDuration != null) { // "The Period extends until the Period.start of the next Period, or // until the end of the Media Presentation in the case of the last // Period." periodDuration = presentationDuration - start; } const threshold = shaka.util.ManifestParserUtils.GAP_OVERLAP_TOLERANCE_SECONDS; if (periodDuration && givenDuration && Math.abs(periodDuration - givenDuration) > threshold) { shaka.log.warning('There is a gap/overlap between Periods', elem); } // Only use the @duration in the MPD if we can't calculate it. We should // favor the @start of the following Period. This ensures that there // aren't gaps between Periods. if (periodDuration == null) { periodDuration = givenDuration; } /** * This is to improve robustness when the player observes manifest with * past periods that are inconsistent to previous ones. * * This may happen when a CDN or proxy server switches its upstream from * one encoder to another redundant encoder. * * Skip periods that match all of the following criteria: * - Start time is earlier than latest period start time ever seen * - Period ID is never seen in the previous manifest * - Not the last period in the manifest * * Periods that meet the aforementioned criteria are considered invalid * and should be safe to discard. */ if (this.largestPeriodStartTime_ !== null && periodId !== null && start !== null && start < this.largestPeriodStartTime_ && !this.lastManifestUpdatePeriodIds_.includes(periodId) && i + 1 != periodNodes.length) { shaka.log.debug( `Skipping Period with ID ${periodId} as its start time is smaller` + ' than the largest period start time that has been seen, and ID ' + 'is unseen before'); continue; } // Save maximum period start time if it is the last period if (start !== null && (this.largestPeriodStartTime_ === null || start > this.largestPeriodStartTime_)) { this.largestPeriodStartTime_ = start; } // Parse child nodes. const info = { start: start, duration: periodDuration, node: elem, isLastPeriod: periodDuration == null || !next, }; const period = this.parsePeriod_(context, baseUris, info); periods.push(period); if (context.period.id && periodDuration) { this.periodDurations_[context.period.id] = periodDuration; } if (periodDuration == null) { if (next) { // If the duration is still null and we aren't at the end, then we // will skip any remaining periods. shaka.log.warning( 'Skipping Period', i + 1, 'and any subsequent Periods:', 'Period', i + 1, 'does not have a valid start time.', next); } // The duration is unknown, so the end is unknown. prevEnd = null; break; } prevEnd = start + periodDuration; } // end of period parsing loop // Replace previous seen periods with the current one. this.lastManifestUpdatePeriodIds_ = periods.map((el) => el.id); if (presentationDuration != null) { if (prevEnd != presentationDuration) { shaka.log.warning( '@mediaPresentationDuration does not match the total duration of ', 'all Periods.'); // Assume @mediaPresentationDuration is correct. } return { periods: periods, duration: presentationDuration, durationDerivedFromPeriods: false, }; } else { return { periods: periods, duration: prevEnd, durationDerivedFromPeriods: true, }; } } /** * Parses a Period XML element. Unlike the other parse methods, this is not * given the Node; it is given a PeriodInfo structure. Also, partial parsing * was done before this was called so start and duration are valid. * * @param {shaka.dash.DashParser.Context} context * @param {!Array.<string>} baseUris * @param {shaka.dash.DashParser.PeriodInfo} periodInfo * @return {shaka.extern.Period} * @private */ parsePeriod_(context, baseUris, periodInfo) { const Functional = shaka.util.Functional; const XmlUtils = shaka.util.XmlUtils; const ContentType = shaka.util.ManifestParserUtils.ContentType; context.period = this.createFrame_(periodInfo.node, null, baseUris); context.periodInfo = periodInfo; context.period.availabilityTimeOffset = context.availabilityTimeOffset; // If the period doesn't have an ID, give it one based on its start time. if (!context.period.id) { shaka.log.info( 'No Period ID given for Period with start time ' + periodInfo.start + ', Assigning a default'); context.period.id = '__shaka_period_' + periodInfo.start; } const eventStreamNodes = XmlUtils.findChildren(periodInfo.node, 'EventStream'); const availabilityStart = context.presentationTimeline.getSegmentAvailabilityStart(); for (const node of eventStreamNodes) { this.parseEventStream_( periodInfo.start, periodInfo.duration, node, availabilityStart); } const adaptationSetNodes = XmlUtils.findChildren(periodInfo.node, 'AdaptationSet'); const adaptationSets = adaptationSetNodes .map((node) => this.parseAdaptationSet_(context, node)) .filter(Functional.isNotNull); // For dynamic manifests, we use rep IDs internally, and they must be // unique. if (context.dynamic) { const ids = []; for (const set of adaptationSets) { for (const id of set.representationIds) { ids.push(id); } } const uniqueIds = new Set(ids); if (ids.length != uniqueIds.size) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_DUPLICATE_REPRESENTATION_ID); } } const normalAdaptationSets = adaptationSets .filter((as) => { return !as.trickModeFor; }); const trickModeAdaptationSets = adaptationSets .filter((as) => { return as.trickModeFor; }); // Attach trick mode tracks to normal tracks. for (const trickModeSet of trickModeAdaptationSets) { const targetIds = trickModeSet.trickModeFor.split(' '); for (const normalSet of normalAdaptationSets) { if (targetIds.includes(normalSet.id)) { for (const stream of normalSet.streams) { // There may be multiple trick mode streams, but we do not // currently support that. Just choose one. // TODO: https://github.com/shaka-project/shaka-player/issues/1528 stream.trickModeVideo = trickModeSet.streams.find((trickStream) => shaka.util.MimeUtils.getNormalizedCodec(stream.codecs) == shaka.util.MimeUtils.getNormalizedCodec(trickStream.codecs)); } } } } const audioSets = this.config_.disableAudio ? [] : this.getSetsOfType_(normalAdaptationSets, ContentType.AUDIO); const videoSets = this.config_.disableVideo ? [] : this.getSetsOfType_(normalAdaptationSets, ContentType.VIDEO); const textSets = this.config_.disableText ? [] : this.getSetsOfType_(normalAdaptationSets, ContentType.TEXT); const imageSets = this.config_.disableThumbnails ? [] : this.getSetsOfType_(normalAdaptationSets, ContentType.IMAGE); if (!videoSets.length && !audioSets.length) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_EMPTY_PERIOD); } const audioStreams = []; for (const audioSet of audioSets) { audioStreams.push(...audioSet.streams); } const videoStreams = []; for (const videoSet of videoSets) { videoStreams.push(...videoSet.streams); } const textStreams = []; for (const textSet of textSets) { textStreams.push(...textSet.streams); } const imageStreams = []; for (const imageSet of imageSets) { imageStreams.push(...imageSet.streams); } return { id: context.period.id, audioStreams, videoStreams, textStreams, imageStreams, }; } /** * @param {!Array.<!shaka.dash.DashParser.AdaptationInfo>} adaptationSets * @param {string} type * @return {!Array.<!shaka.dash.DashParser.AdaptationInfo>} * @private */ getSetsOfType_(adaptationSets, type) { return adaptationSets.filter((as) => { return as.contentType == type; }); } /** * Parses an AdaptationSet XML element. * * @param {shaka.dash.DashParser.Context} context * @param {!Element} elem The AdaptationSet element. * @return {?shaka.dash.DashParser.AdaptationInfo} * @private */ parseAdaptationSet_(context, elem) { const XmlUtils = shaka.util.XmlUtils; const Functional = shaka.util.Functional; const ManifestParserUtils = shaka.util.ManifestParserUtils; const ContentType = ManifestParserUtils.ContentType; const ContentProtection = shaka.dash.ContentProtection; context.adaptationSet = this.createFrame_(elem, context.period, null); let main = false; const roleElements = XmlUtils.findChildren(elem, 'Role'); const roleValues = roleElements.map((role) => { return role.getAttribute('value'); }).filter(Functional.isNotNull); // Default kind for text streams is 'subtitle' if unspecified in the // manifest. let kind = undefined; const isText = context.adaptationSet.contentType == ContentType.TEXT; if (isText) { kind = ManifestParserUtils.TextStreamKind.SUBTITLE; } for (const roleElement of roleElements) { const scheme = roleElement.getAttribute('schemeIdUri'); if (scheme == null || scheme == 'urn:mpeg:dash:role:2011') { // These only apply for the given scheme, but allow them to be specified // if there is no scheme specified. // See: DASH section 5.8.5.5 const value = roleElement.getAttribute('value'); switch (value) { case 'main': main = true; break; case 'caption': case 'subtitle': kind = value; break; } } } // Parallel for HLS VIDEO-RANGE as defined in DASH-IF IOP v4.3 6.2.5.1. let videoRange; // Ref. https://dashif.org/docs/DASH-IF-IOP-v4.3.pdf // If signaled, a Supplemental or Essential Property descriptor // shall be used, with the schemeIdUri set to // urn:mpeg:mpegB:cicp:<Parameter> as defined in // ISO/IEC 23001-8 [49] and <Parameter> one of the // following: ColourPrimaries, TransferCharacteristics, // or MatrixCoefficients. const scheme = 'urn:mpeg:mpegB:cicp'; const transferCharacteristicsScheme = `${scheme}:TransferCharacteristics`; const colourPrimariesScheme = `${scheme}:ColourPrimaries`; const matrixCoefficientsScheme = `${scheme}:MatrixCoefficients`; const getVideoRangeFromTransferCharacteristicCICP = (cicp) => { switch (cicp) { case 1: case 6: case 13: case 14: case 15: return 'SDR'; case 16: return 'PQ'; case 18: return 'HLG'; } return undefined; }; const essentialProperties = XmlUtils.findChildren(elem, 'EssentialProperty'); // ID of real AdaptationSet if this is a trick mode set: let trickModeFor = null; let unrecognizedEssentialProperty = false; for (const prop of essentialProperties) { const schemeId = prop.getAttribute('schemeIdUri'); if (schemeId == 'http://dashif.org/guidelines/trickmode') { trickModeFor = prop.getAttribute('value'); } else if (schemeId == transferCharacteristicsScheme) { videoRange = getVideoRangeFromTransferCharacteristicCICP( parseInt(prop.getAttribute('value'), 10), ); } else if (schemeId == colourPrimariesScheme || schemeId == matrixCoefficientsScheme) { continue; } else { unrecognizedEssentialProperty = true; } } const supplementalProperties = XmlUtils.findChildren(elem, 'SupplementalProperty'); for (const prop of supplementalProperties) { const schemeId = prop.getAttribute('schemeIdUri'); if (schemeId == transferCharacteristicsScheme) { videoRange = getVideoRangeFromTransferCharacteristicCICP( parseInt(prop.getAttribute('value'), 10), ); } } const accessibilities = XmlUtils.findChildren(elem, 'Accessibility'); const LanguageUtils = shaka.util.LanguageUtils; const closedCaptions = new Map(); /** @type {?shaka.media.ManifestParser.AccessibilityPurpose} */ let accessibilityPurpose; for (const prop of accessibilities) { const schemeId = prop.getAttribute('schemeIdUri'); const value = prop.getAttribute('value'); if (schemeId == 'urn:scte:dash:cc:cea-608:2015' ) { let channelId = 1; if (value != null) { const channelAssignments = value.split(';'); for (const captionStr of channelAssignments) { let channel; let language; // Some closed caption descriptions have channel number and // language ("CC1=eng") others may only have language ("eng,spa"). if (!captionStr.includes('=')) { // When the channel assignemnts are not explicitly provided and // there are only 2 values provided, it is highly likely that the // assignments are CC1 and CC3 (most commonly used CC streams). // Otherwise, cycle through all channels arbitrarily (CC1 - CC4) // in order of provided langs. channel = `CC${channelId}`; if (channelAssignments.length == 2) { channelId += 2; } else { channelId ++; } language = captionStr; } else { const channelAndLanguage = captionStr.split('='); // The channel info can be '1' or 'CC1'. // If the channel info only has channel number(like '1'), add 'CC' // as prefix so that it can be a full channel id (like 'CC1'). channel = channelAndLanguage[0].startsWith('CC') ? channelAndLanguage[0] : `CC${channelAndLanguage[0]}`; // 3 letters (ISO 639-2). In b/187442669, we saw a blank string // (CC2=;CC3=), so default to "und" (the code for "undetermined"). language = channelAndLanguage[1] || 'und'; } closedCaptions.set(channel, LanguageUtils.normalize(language)); } } else { // If channel and language information has not been provided, assign // 'CC1' as channel id and 'und' as language info. closedCaptions.set('CC1', 'und'); } } else if (schemeId == 'urn:scte:dash:cc:cea-708:2015') { let serviceNumber = 1; if (value != null) { for (const captionStr of value.split(';')) { let service; let language; // Similar to CEA-608, it is possible that service # assignments // are not explicitly provided e.g. "eng;deu;swe" In this case, // we just cycle through the services for each language one by one. if (!captionStr.includes('=')) { service = `svc${serviceNumber}`; serviceNumber ++; language = captionStr; } else { // Otherwise, CEA-708 caption values take the form " // 1=lang:eng;2=lang:deu" i.e. serviceNumber=lang:threelettercode. const serviceAndLanguage = captionStr.split('='); service = `svc${serviceAndLanguage[0]}`; // The language info can be different formats, lang:eng', // or 'lang:eng,war:1,er:1'. Extract the language info. language = serviceAndLanguage[1].split(',')[0].split(':').pop(); } closedCaptions.set(service, LanguageUtils.normalize(language)); } } else { // If service and language information has not been provided, assign // 'svc1' as service number and 'und' as language info. closedCaptions.set('svc1', 'und'); } } else if (schemeId == 'urn:mpeg:dash:role:2011') { // See DASH IOP 3.9.2 Table 4. if (value != null) { roleValues.push(value); if (value == 'captions') { kind = ManifestParserUtils.TextStreamKind.CLOSED_CAPTION; } } } else if (schemeId == 'urn:tva:metadata:cs:AudioPurposeCS:2007') { // See DASH DVB Document A168 Rev.6 Table 5. if (value == '1') { accessibilityPurpose = shaka.media.ManifestParser.AccessibilityPurpose.VISUALLY_IMPAIRED; } else if (value == '2') { accessibilityPurpose = shaka.media.ManifestParser.AccessibilityPurpose.HARD_OF_HEARING; } } } // According to DASH spec (2014) section 5.8.4.8, "the successful processing // of the descriptor is essential to properly use the information in the // parent element". According to DASH IOP v3.3, section 3.3.4, "if the // scheme or the value" for EssentialProperty is not recognized, "the DASH // client shall ignore the parent element." if (unrecognizedEssentialProperty) { // Stop parsing this AdaptationSet and let the caller filter out the // nulls. return null; } const contentProtectionElems = XmlUtils.findChildren(elem, 'ContentProtection'); const contentProtection = ContentProtection.parseFromAdaptationSet( contentProtectionElems, this.config_.dash.ignoreDrmInfo, this.config_.dash.keySystemsByURI); const language = shaka.util.LanguageUtils.normalize( context.adaptationSet.language || 'und'); // This attribute is currently non-standard, but it is supported by Kaltura. let label = elem.getAttribute('label'); // See DASH IOP 4.3 here https://dashif.org/docs/DASH-IF-IOP-v4.3.pdf (page 35) const labelElements = XmlUtils.findChildren(elem, 'Label'); if (labelElements && labelElements.length) { // NOTE: Right now only one label field is supported. const firstLabelElement = labelElements[0]; if (firstLabelElement.textContent) { label = firstLabelElement.textContent; } } // Parse Representations into Streams. const representations = XmlUtils.findChildren(elem, 'Representation'); const streams = representations.map((representation) => { const parsedRepresentation = this.parseRepresentation_(context, contentProtection, kind, language, label, main, roleValues, closedCaptions, representation, accessibilityPurpose); if (parsedRepresentation) { parsedRepresentation.hdr = parsedRepresentation.hdr || videoRange; } return parsedRepresentation; }).filter((s) => !!s); if (streams.length == 0) { const isImage = context.adaptationSet.contentType == ContentType.IMAGE; // Ignore empty AdaptationSets if ignoreEmptyAdaptationSet is true // or they are for text/image content. if (this.config_.dash.ignoreEmptyAdaptationSet || isText || isImage) { return null; } throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_EMPTY_ADAPTATION_SET); } // If AdaptationSet's type is unknown or is ambiguously "application", // guess based on the information in the first stream. If the attributes // mimeType and codecs are split across levels, they will both be inherited // down to the stream level by this point, so the stream will have all the // necessary information. if (!context.adaptationSet.contentType || context.adaptationSet.contentType == ContentType.APPLICATION) { const mimeType = streams[0].mimeType; const codecs = streams[0].codecs; context.adaptationSet.contentType = shaka.dash.DashParser.guessContentType_(mimeType, codecs); for (const stream of streams) { stream.type = context.adaptationSet.contentType; } } const adaptationId = context.adaptationSet.id || ('__fake__' + this.globalId_++); for (const stream of streams) { // Some DRM license providers require that we have a default // key ID from the manifest in the wrapped license request. // Thus, it should be put in drmInfo to be accessible to request filters. for (const drmInfo of contentProtection.drmInfos) { drmInfo.keyIds = drmInfo.keyIds && stream.keyIds ? new Set([...drmInfo.keyIds, ...stream.keyIds]) : drmInfo.keyIds || stream.keyIds; } if (this.config_.dash.enableAudioGroups) { stream.groupId = adaptationId; } } const repIds = representations .map((node) => { return node.getAttribute('id'); }) .filter(shaka.util.Functional.isNotNull); return { id: adaptationId, contentType: context.adaptationSet.contentType, language: language, main: main, streams: streams, drmInfos: contentProtection.drmInfos, trickModeFor: trickModeFor, representationIds: repIds, }; } /** * Parses a Representation XML element. * * @param {shaka.dash.DashParser.Context} context * @param {shaka.dash.ContentProtection.Context} contentProtection * @param {(string|undefined)} kind * @param {string} language * @param {string} label * @param {boolean} isPrimary * @param {!Array.<string>} roles * @param {Map.<string, string>} closedCaptions * @param {!Element} node * @param {?shaka.media.ManifestParser.AccessibilityPurpose} * accessibilityPurpose * * @return {?shaka.extern.Stream} The Stream, or null when there is a * non-critical parsing error. * @private */ parseRepresentation_(context, contentProtection, kind, language, label, isPrimary, roles, closedCaptions, node, accessibilityPurpose) { const XmlUtils = shaka.util.XmlUtils; const ContentType = shaka.util.ManifestParserUtils.ContentType; context.representation = this.createFrame_(node, context.adaptationSet, null); this.minTotalAvailabilityTimeOffset_ = Math.min(this.minTotalAvailabilityTimeOffset_, context.representation.availabilityTimeOffset); if (!this.verifyRepresentation_(context.representation)) { shaka.log.warning('Skipping Representation', context.representation); return null; } const periodStart = context.periodInfo.start; // NOTE: bandwidth is a mandatory attribute according to the spec, and zero // does not make sense in the DASH spec's bandwidth formulas. // In some content, however, the attribute is missing or zero. // To avoid NaN at the variant level on broken content, fall back to zero. // https://github.com/shaka-project/shaka-player/issues/938#issuecomment-317278180 context.bandwidth = XmlUtils.parseAttr(node, 'bandwidth', XmlUtils.parsePositiveInt) || 0; /** @type {?shaka.dash.DashParser.StreamInfo} */ let streamInfo; const contentType = context.representation.contentType; const isText = contentType == ContentType.TEXT || contentType == ContentType.APPLICATION; const isImage = contentType == ContentType.IMAGE; try { /** @type {shaka.extern.aes128Key|undefined} */ let aes128Key = undefined; if (contentProtection.aes128Info) { const baseUris = context.representation.baseUris; const uris = shaka.util.ManifestParserUtils.resolveUris( baseUris, [contentProtection.aes128Info.keyUri]); const requestType = shaka.net.NetworkingEngine.RequestType.KEY; const request = shaka.net.NetworkingEngine.makeRequest( uris, this.config_.retryParameters); aes128Key = { method: 'AES-128', iv: contentProtection.aes128Info.iv, firstMediaSequenceNumber: 0, }; // Don't download the key object until the segment is parsed, to // avoid a startup delay for long manifests with lots of keys. aes128Key.fetchKey = async () => { const keyResponse = await this.makeNetworkRequest_(request, requestType); // keyResponse.status is undefined when URI is // "data:text/plain;base64," if (!keyResponse.data || keyResponse.data.byteLength != 16) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.AES_128_INVALID_KEY_LENGTH); } const algorithm = { name: 'AES-CBC', }; aes128Key.cryptoKey = await window.crypto.subtle.importKey( 'raw', keyResponse.data, algorithm, true, ['decrypt']); aes128Key.fetchKey = undefined; // No longer needed. }; } const requestSegment = (uris, startByte, endByte, isInit) => { return this.requestSegment_(uris, startByte, endByte, isInit); }; if (context.representation.segmentBase) { streamInfo = shaka.dash.SegmentBase.createStreamInfo( context, requestSegment, aes128Key); } else if (context.representation.segmentList) { streamInfo = shaka.dash.SegmentList.createStreamInfo( context, this.streamMap_, aes128Key); } else if (context.representation.segmentTemplate) { const hasManifest = !!this.manifest_; streamInfo = shaka.dash.SegmentTemplate.createStreamInfo( context, requestSegment, this.streamMap_, hasManifest, this.config_.dash.initialSegmentLimit, this.periodDurations_, aes128Key); } else { goog.asserts.assert(isText, 'Must have Segment* with non-text streams.'); const baseUris = context.representation.baseUris; const duration = context.periodInfo.duration || 0; streamInfo = { generateSegmentIndex: () => { return Promise.resolve(shaka.media.SegmentIndex.forSingleSegment( periodStart, duration, baseUris)); }, }; } } catch (error) { if ((isText || isImage) && error.code == shaka.util.Error.Code.DASH_NO_SEGMENT_INFO) { // We will ignore any DASH_NO_SEGMENT_INFO errors for text/image // streams. return null; } // For anything else, re-throw. throw error; } const contentProtectionElems = XmlUtils.findChildren(node, 'ContentProtection'); const keyId = shaka.dash.ContentProtection.parseFromRepresentation( contentProtectionElems, contentProtection, this.config_.dash.ignoreDrmInfo, this.config_.dash.keySystemsByURI); const keyIds = new Set(keyId ? [keyId] : []); // Detect the presence of E-AC3 JOC audio content, using DD+JOC signaling. // See: ETSI TS 103 420 V1.2.1 (2018-10) const supplementalPropertyElems = XmlUtils.findChildren(node, 'SupplementalProperty'); const hasJoc = supplementalPropertyElems.some((element) => { const expectedUri = 'tag:dolby.com,2018:dash:EC3_ExtensionType:2018'; const expectedValue = 'JOC'; return element.getAttribute('schemeIdUri') == expectedUri && element.getAttribute('value') == expectedValue; }); let spatialAudio = false; if (hasJoc) { spatialAudio = true; } let forced = false; if (isText) { // See: h