/** * Copyright 2015 CANAL+ Group * * 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. */ import log from "../../../../log"; import type { IPeriod } from "../../../../manifest"; import { SUPPORTED_ADAPTATIONS_TYPE } from "../../../../manifest"; import type { ITrackType } from "../../../../public_types"; import arrayFind from "../../../../utils/array_find"; import arrayFindIndex from "../../../../utils/array_find_index"; import arrayIncludes from "../../../../utils/array_includes"; import isNonEmptyString from "../../../../utils/is_non_empty_string"; import isNullOrUndefined from "../../../../utils/is_null_or_undefined"; import type { IParsedAdaptation, IParsedAdaptations, IParsedRepresentation, IParsedThumbnailTrack, } from "../../types"; import type { IAdaptationSetIntermediateRepresentation, ISegmentTemplateIntermediateRepresentation, } from "../node_parser_types"; import attachTrickModeTrack from "./attach_trickmode_track"; import type ContentProtectionParser from "./content_protection_parser"; import inferAdaptationType, { getThumbnailAdaptationSetInfo, } from "./infer_adaptation_type"; import type { IRepresentationContext } from "./parse_representations"; import parseRepresentations from "./parse_representations"; import resolveBaseURLs from "./resolve_base_urls"; /** * Supplementary information for "switchable" AdaptationSets of the same Period. * * This Object keeps record of which AdaptationSet (identified by its `id` * attribute in the MPD) is "switchable" (meaning we're allowed to switch from * one another at any time) to which other AdaptationSet(s) (by their IDs as * anounced in the MPD). * * AdaptationSets switchable between one another are considered as a single * Adaptation by the RxPlayer. * * This type of configuration is mostly encountered when different qualities for * the same track depend on different encryption information. * As the DASH-IF defines that some of this information should be put at the * AdaptationSet-level, packager are often forced to generate multiple ones even * when only one "real" track exists. * * The RxPlayer moves that information down to the `Representation` level. As * such, it can merge AdaptationSets that look like they should be. */ interface IAdaptationSwitchingInfos { /** `id` attribute of the AdaptationSet as announced in the MPD. */ [originalID: string]: { /** `id` property of the resulting parsed `Adaptation` object. */ newID: string; /** * `id` attribute (as announced in the MPD) of all the `AdaptationSet`s * this current one can seamlessly switch to. */ adaptationSetSwitchingIDs: string[]; // IDs (as announced in the MPD), // this AdaptationSet can be // seamlessly switched to }; } /** * Detect if the accessibility given defines an adaptation for the visually * impaired. * Based on DVB Document A168 (DVB-DASH) and DASH-IF 4.3. * @param {Object} accessibility * @returns {Boolean} */ function isVisuallyImpaired( accessibility: | { schemeIdUri?: string | undefined; value?: string | undefined } | undefined, ): boolean { if (accessibility === undefined) { return false; } const isVisuallyImpairedAudioDvbDash = accessibility.schemeIdUri === "urn:tva:metadata:cs:AudioPurposeCS:2007" && accessibility.value === "1"; const isVisuallyImpairedDashIf = accessibility.schemeIdUri === "urn:mpeg:dash:role:2011" && accessibility.value === "description"; return isVisuallyImpairedAudioDvbDash || isVisuallyImpairedDashIf; } /** * Detect if the accessibility given defines an adaptation for the hard of * hearing. * Based on DVB Document A168 (DVB-DASH) and DASH specification. * @param {Array.} accessibilities * @param {Array.} roles * @returns {Boolean} */ function isCaptionning( accessibilities: | Array<{ schemeIdUri?: string | undefined; value?: string | undefined }> | undefined, roles: | Array<{ schemeIdUri?: string | undefined; value?: string | undefined }> | undefined, ): boolean { if (accessibilities !== undefined) { const hasDvbClosedCaptionSignaling = accessibilities.some( (accessibility) => accessibility.schemeIdUri === "urn:tva:metadata:cs:AudioPurposeCS:2007" && accessibility.value === "2", ); if (hasDvbClosedCaptionSignaling) { return true; } } if (roles !== undefined) { const hasDashCaptionSinaling = roles.some( (role) => role.schemeIdUri === "urn:mpeg:dash:role:2011" && role.value === "caption", ); if (hasDashCaptionSinaling) { return true; } } return false; } /** * Detect if the accessibility given defines an AdaptationSet containing a sign * language interpretation. * Based on DASH-IF 4.3. * @param {Object} accessibility * @returns {Boolean} */ function hasSignLanguageInterpretation( accessibility: | { schemeIdUri?: string | undefined; value?: string | undefined } | undefined, ): boolean { if (accessibility === undefined) { return false; } return ( accessibility.schemeIdUri === "urn:mpeg:dash:role:2011" && accessibility.value === "sign" ); } /** * Contruct Adaptation ID from the information we have. * @param {Object} adaptation * @param {Object} infos * @returns {string} */ function getAdaptationID( adaptation: IAdaptationSetIntermediateRepresentation, infos: { isClosedCaption: boolean | undefined; isForcedSubtitle: boolean | undefined; isAudioDescription: boolean | undefined; isSignInterpreted: boolean | undefined; isTrickModeTrack: boolean; type: string; }, ): string { if (isNonEmptyString(adaptation.attributes.id)) { return adaptation.attributes.id; } const { isClosedCaption, isForcedSubtitle, isAudioDescription, isSignInterpreted, isTrickModeTrack, type, } = infos; let idString = type; if (isNonEmptyString(adaptation.attributes.language)) { idString += `-${adaptation.attributes.language}`; } if (isClosedCaption === true) { idString += "-cc"; } if (isForcedSubtitle === true) { idString += "-cc"; } if (isAudioDescription === true) { idString += "-ad"; } if (isSignInterpreted === true) { idString += "-si"; } if (isTrickModeTrack) { idString += "-trickMode"; } if (isNonEmptyString(adaptation.attributes.contentType)) { idString += `-${adaptation.attributes.contentType}`; } if (isNonEmptyString(adaptation.attributes.codecs)) { idString += `-${adaptation.attributes.codecs}`; } if (isNonEmptyString(adaptation.attributes.mimeType)) { idString += `-${adaptation.attributes.mimeType}`; } if (adaptation.attributes.frameRate !== undefined) { idString += `-${String(adaptation.attributes.frameRate)}`; } return idString; } /** * Returns a list of ID this adaptation can be seamlessly switched to * @param {Object} adaptation * @returns {Array.} */ function getAdaptationSetSwitchingIDs( adaptation: IAdaptationSetIntermediateRepresentation, ): string[] { if (!isNullOrUndefined(adaptation.children.supplementalProperties)) { const { supplementalProperties } = adaptation.children; for (const supplementalProperty of supplementalProperties) { if ( supplementalProperty.schemeIdUri === "urn:mpeg:dash:adaptation-set-switching:2016" && !isNullOrUndefined(supplementalProperty.value) ) { return supplementalProperty.value .split(",") .map((id) => id.trim()) .filter((id) => isNonEmptyString(id)); } } } return []; } /** * Process AdaptationSets intermediate representations to return under its final * form. * Note that the AdaptationSets returned are sorted by priority (from the most * priority to the least one). * @param {Array.} adaptationsIR * @param {Object} context * @returns {Array.} */ export default function parseAdaptationSets( adaptationsIR: IAdaptationSetIntermediateRepresentation[], context: IAdaptationSetContext, ): { adaptations: IParsedAdaptations; thumbnailTracks: IParsedThumbnailTrack[]; } { const parsedAdaptations: Record< ITrackType, Array<[IParsedAdaptation, IAdaptationSetOrderingData]> > = { video: [], audio: [], text: [] }; const parsedThumbnailTracks: IParsedThumbnailTrack[] = []; const trickModeAdaptations: Array<{ adaptation: IParsedAdaptation; trickModeAttachedAdaptationIds: string[]; }> = []; const adaptationSwitchingInfos: IAdaptationSwitchingInfos = {}; const parsedAdaptationsIDs: string[] = []; for (let adaptationIdx = 0; adaptationIdx < adaptationsIR.length; adaptationIdx++) { const adaptation = adaptationsIR[adaptationIdx]; const adaptationChildren = adaptation.children; const { essentialProperties, roles, label } = adaptationChildren; const isMainAdaptation = Array.isArray(roles) && roles.some((role) => role.value === "main") && roles.some((role) => role.schemeIdUri === "urn:mpeg:dash:role:2011"); const representationsIR = adaptation.children.representations; const availabilityTimeComplete = adaptation.attributes.availabilityTimeComplete ?? context.availabilityTimeComplete; let availabilityTimeOffset; if ( adaptation.attributes.availabilityTimeOffset !== undefined || context.availabilityTimeOffset !== undefined ) { availabilityTimeOffset = (adaptation.attributes.availabilityTimeOffset ?? 0) + (context.availabilityTimeOffset ?? 0); } const type = inferAdaptationType(adaptation, representationsIR); if (type === undefined) { continue; } const priority = adaptation.attributes.selectionPriority ?? 1; const originalID = adaptation.attributes.id; const adaptationSetSwitchingIDs = getAdaptationSetSwitchingIDs(adaptation); const parentSegmentTemplates = []; if (context.segmentTemplate !== undefined) { parentSegmentTemplates.push(context.segmentTemplate); } if (adaptation.children.segmentTemplate !== undefined) { parentSegmentTemplates.push(adaptation.children.segmentTemplate); } const reprCtxt: IRepresentationContext = { availabilityTimeComplete, availabilityTimeOffset, baseURLs: resolveBaseURLs(context.baseURLs, adaptationChildren.baseURLs), contentProtectionParser: context.contentProtectionParser, manifestBoundsCalculator: context.manifestBoundsCalculator, end: context.end, isDynamic: context.isDynamic, isLastPeriod: context.isLastPeriod, manifestProfiles: context.manifestProfiles, parentSegmentTemplates, receivedTime: context.receivedTime, start: context.start, unsafelyBaseOnPreviousAdaptation: null, }; const trickModeProperty = Array.isArray(essentialProperties) ? arrayFind(essentialProperties, (scheme) => { return scheme.schemeIdUri === "http://dashif.org/guidelines/trickmode"; }) : undefined; const trickModeAttachedAdaptationIds: string[] | undefined = trickModeProperty?.value?.split(" "); const isTrickModeTrack = trickModeAttachedAdaptationIds !== undefined; const { accessibilities } = adaptationChildren; let isDub: boolean | undefined; if (roles !== undefined && roles.some((role) => role.value === "dub")) { isDub = true; } let isClosedCaption; if (type !== "text") { isClosedCaption = false; } else { isClosedCaption = isCaptionning(accessibilities, roles); } let isForcedSubtitle; if ( type === "text" && roles !== undefined && roles.some( (role) => role.value === "forced-subtitle" || role.value === "forced_subtitle", ) ) { isForcedSubtitle = true; } let isAudioDescription; if (type !== "audio") { isAudioDescription = false; } else if (accessibilities !== undefined) { isAudioDescription = accessibilities.some(isVisuallyImpaired); } let isSignInterpreted; if (type !== "video") { isSignInterpreted = false; } else if (accessibilities !== undefined) { isSignInterpreted = accessibilities.some(hasSignLanguageInterpretation); } let adaptationID = getAdaptationID(adaptation, { isAudioDescription, isForcedSubtitle, isClosedCaption, isSignInterpreted, isTrickModeTrack, type, }); // Avoid duplicate IDs while (arrayIncludes(parsedAdaptationsIDs, adaptationID)) { adaptationID += "-dup"; } const newID = adaptationID; parsedAdaptationsIDs.push(adaptationID); reprCtxt.unsafelyBaseOnPreviousAdaptation = context.unsafelyBaseOnPreviousPeriod?.getAdaptation(adaptationID) ?? null; const representations = parseRepresentations(representationsIR, adaptation, reprCtxt); if (type === "thumbnails") { const track = createThumbnailTracks(adaptation, representations); if (track !== null) { parsedThumbnailTracks.push(...track); } continue; } const parsedAdaptationSet: IParsedAdaptation = { id: adaptationID, representations, type, isTrickModeTrack, }; if (!isNullOrUndefined(adaptation.attributes.language)) { parsedAdaptationSet.language = adaptation.attributes.language; } if (!isNullOrUndefined(isClosedCaption)) { parsedAdaptationSet.closedCaption = isClosedCaption; } if (!isNullOrUndefined(isAudioDescription)) { parsedAdaptationSet.audioDescription = isAudioDescription; } if (isDub === true) { parsedAdaptationSet.isDub = true; } if (isForcedSubtitle !== undefined) { parsedAdaptationSet.forcedSubtitles = isForcedSubtitle; } if (isSignInterpreted === true) { parsedAdaptationSet.isSignInterpreted = true; } if (label !== undefined) { parsedAdaptationSet.label = label; } if (trickModeAttachedAdaptationIds !== undefined) { trickModeAdaptations.push({ adaptation: parsedAdaptationSet, trickModeAttachedAdaptationIds, }); } else { // look if we have to merge this into another Adaptation let mergedIntoIdx = -1; for (const id of adaptationSetSwitchingIDs) { const switchingInfos = adaptationSwitchingInfos[id]; if ( switchingInfos !== undefined && switchingInfos.newID !== newID && arrayIncludes(switchingInfos.adaptationSetSwitchingIDs, originalID) ) { mergedIntoIdx = arrayFindIndex(parsedAdaptations[type], (a) => a[0].id === id); const mergedInto = parsedAdaptations[type][mergedIntoIdx]; if ( mergedInto !== undefined && mergedInto[0].audioDescription === parsedAdaptationSet.audioDescription && mergedInto[0].closedCaption === parsedAdaptationSet.closedCaption && mergedInto[0].language === parsedAdaptationSet.language ) { log.info("dash", 'merging "switchable" AdaptationSets', { originalID, id }); mergedInto[0].representations.push(...parsedAdaptationSet.representations); mergedInto[1] = { priority: Math.max(priority, mergedInto[1].priority), isMainAdaptation: isMainAdaptation || mergedInto[1].isMainAdaptation, indexInMpd: Math.min(adaptationIdx, mergedInto[1].indexInMpd), }; break; } } } if (mergedIntoIdx < 0) { parsedAdaptations[type].push([ parsedAdaptationSet, { priority, isMainAdaptation, indexInMpd: adaptationIdx }, ]); } } if ( !isNullOrUndefined(originalID) && isNullOrUndefined(adaptationSwitchingInfos[originalID]) ) { adaptationSwitchingInfos[originalID] = { newID, adaptationSetSwitchingIDs, }; } } const adaptationsPerType = SUPPORTED_ADAPTATIONS_TYPE.reduce( (acc: IParsedAdaptations, adaptationType: ITrackType) => { const adaptationsParsedForType = parsedAdaptations[adaptationType]; if (adaptationsParsedForType.length > 0) { adaptationsParsedForType.sort(compareAdaptations); acc[adaptationType] = adaptationsParsedForType.map( ([parsedAdaptation]) => parsedAdaptation, ); } return acc; }, {}, ); parsedAdaptations.video.sort(compareAdaptations); attachTrickModeTrack(adaptationsPerType, trickModeAdaptations); return { adaptations: adaptationsPerType, thumbnailTracks: parsedThumbnailTracks, }; } /** Metadata allowing to order AdaptationSets between one another. */ interface IAdaptationSetOrderingData { /** * If `true`, this AdaptationSet is considered as a "main" one (e.g. it had a * Role set to "main"). */ isMainAdaptation: boolean; /** * Set to the `selectionPriority` attribute of the corresponding AdaptationSet * or to `1` by default. */ priority: number; /** Index of this AdaptationSet in the original MPD, starting from `0`. */ indexInMpd: number; } /** * From the given attributes, returns a parsed thumbnail track, or null if it * fails to do so. * @param {Object} adaptation * @param {Array.} representations * @returns {Object|null} */ function createThumbnailTracks( adaptation: IAdaptationSetIntermediateRepresentation, representations: IParsedRepresentation[], ): IParsedThumbnailTrack[] { const tracks = []; for (let i = 0; i < representations.length; i++) { const representation = representations[i]; if (representation !== undefined) { if (representation.mimeType === undefined) { log.warn("dash", "Invalid thumbnails Representation, no mime-type"); continue; } const tileInfo = getThumbnailAdaptationSetInfo( adaptation, adaptation.children.representations[i], ); if (tileInfo === null) { continue; } if (representation.height === undefined) { log.warn("dash", "Invalid thumbnails Representation, no height information"); continue; } if (representation.width === undefined) { log.warn("dash", "Invalid thumbnails Representation, no width information"); continue; } const start = representation.index.getFirstAvailablePosition() ?? undefined; const end = representation.index.getEnd() ?? undefined; let segmentDuration; const targetDuration = representation.index.getTargetSegmentDuration(); if (targetDuration !== undefined && targetDuration.isPrecize) { segmentDuration = targetDuration.duration; } else { log.warn("dash", "Cannot produce duration estimate for thumbnail track"); } tracks.push({ id: representation.id, cdnMetadata: representation.cdnMetadata, index: representation.index, mimeType: representation.mimeType, height: representation.height, width: representation.width, horizontalTiles: tileInfo.horizontalTiles, verticalTiles: tileInfo.verticalTiles, start, end, tileDuration: segmentDuration === undefined ? undefined : segmentDuration / (tileInfo.horizontalTiles * tileInfo.verticalTiles), }); } } return tracks; } /** * Compare groups of parsed AdaptationSet, alongside some ordering metadata, * allowing to easily sort them through JavaScript's `Array.prototype.sort` * method. * @param {Array.} a * @param {Array.} b * @returns {number} */ function compareAdaptations( a: [IParsedAdaptation, IAdaptationSetOrderingData], b: [IParsedAdaptation, IAdaptationSetOrderingData], ): number { const priorityDiff = b[1].priority - a[1].priority; if (priorityDiff !== 0) { return priorityDiff; } if (a[1].isMainAdaptation !== b[1].isMainAdaptation) { return a[1].isMainAdaptation ? -1 : 1; } return a[1].indexInMpd - b[1].indexInMpd; } /** Context needed when calling `parseAdaptationSets`. */ export interface IAdaptationSetContext extends IInheritedRepresentationContext { /** SegmentTemplate parsed in the Period, if found. */ segmentTemplate?: ISegmentTemplateIntermediateRepresentation | undefined; /** * The parser should take this Period - which is from a previously parsed * Manifest for the same dynamic content - as a base to speed-up the parsing * process. * /!\ If unexpected differences exist between both, there is a risk of * de-synchronization with what is actually on the server, * Use with moderation. */ unsafelyBaseOnPreviousPeriod: IPeriod | null; /** Parses contentProtection elements. */ contentProtectionParser: ContentProtectionParser; } /** * Supplementary context needed to parse a Representation common with * `IRepresentationContext`. */ type IInheritedRepresentationContext = Omit< IRepresentationContext, "unsafelyBaseOnPreviousAdaptation" | "parentSegmentTemplates" >;