rx-player
Version:
Canal+ HTML5 Video Player
657 lines (604 loc) • 21.6 kB
text/typescript
/**
* 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.<Object>} accessibilities
* @param {Array.<Object>} 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.<string>}
*/
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.<Object>} adaptationsIR
* @param {Object} context
* @returns {Array.<Object>}
*/
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.<Object>} 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.<Object>} a
* @param {Array.<Object>} 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"
>;