You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

573 lines
20 KiB

import { flatten } from './utils/list';
import { merge } from './utils/object';
import { findChildren, getContent } from './utils/xml';
import { parseAttributes } from './parseAttributes';
import errors from './errors';
import resolveUrl from '@videojs/vhs-utils/es/resolve-url';
import decodeB64ToUint8Array from '@videojs/vhs-utils/es/decode-b64-to-uint8-array';
const keySystemsMap = {
'urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b': 'org.w3.clearkey',
'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed': 'com.widevine.alpha',
'urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95': 'com.microsoft.playready',
'urn:uuid:f239e769-efa3-4850-9c16-a903c6932efb': 'com.adobe.primetime'
};
/**
* Builds a list of urls that is the product of the reference urls and BaseURL values
*
* @param {string[]} referenceUrls
* List of reference urls to resolve to
* @param {Node[]} baseUrlElements
* List of BaseURL nodes from the mpd
* @return {string[]}
* List of resolved urls
*/
export const buildBaseUrls = (referenceUrls, baseUrlElements) => {
if (!baseUrlElements.length) {
return referenceUrls;
}
return flatten(referenceUrls.map(function(reference) {
return baseUrlElements.map(function(baseUrlElement) {
return resolveUrl(reference, getContent(baseUrlElement));
});
}));
};
/**
* Contains all Segment information for its containing AdaptationSet
*
* @typedef {Object} SegmentInformation
* @property {Object|undefined} template
* Contains the attributes for the SegmentTemplate node
* @property {Object[]|undefined} segmentTimeline
* Contains a list of atrributes for each S node within the SegmentTimeline node
* @property {Object|undefined} list
* Contains the attributes for the SegmentList node
* @property {Object|undefined} base
* Contains the attributes for the SegmentBase node
*/
/**
* Returns all available Segment information contained within the AdaptationSet node
*
* @param {Node} adaptationSet
* The AdaptationSet node to get Segment information from
* @return {SegmentInformation}
* The Segment information contained within the provided AdaptationSet
*/
export const getSegmentInformation = (adaptationSet) => {
const segmentTemplate = findChildren(adaptationSet, 'SegmentTemplate')[0];
const segmentList = findChildren(adaptationSet, 'SegmentList')[0];
const segmentUrls = segmentList && findChildren(segmentList, 'SegmentURL')
.map(s => merge({ tag: 'SegmentURL' }, parseAttributes(s)));
const segmentBase = findChildren(adaptationSet, 'SegmentBase')[0];
const segmentTimelineParentNode = segmentList || segmentTemplate;
const segmentTimeline = segmentTimelineParentNode &&
findChildren(segmentTimelineParentNode, 'SegmentTimeline')[0];
const segmentInitializationParentNode = segmentList || segmentBase || segmentTemplate;
const segmentInitialization = segmentInitializationParentNode &&
findChildren(segmentInitializationParentNode, 'Initialization')[0];
// SegmentTemplate is handled slightly differently, since it can have both
// @initialization and an <Initialization> node. @initialization can be templated,
// while the node can have a url and range specified. If the <SegmentTemplate> has
// both @initialization and an <Initialization> subelement we opt to override with
// the node, as this interaction is not defined in the spec.
const template = segmentTemplate && parseAttributes(segmentTemplate);
if (template && segmentInitialization) {
template.initialization =
(segmentInitialization && parseAttributes(segmentInitialization));
} else if (template && template.initialization) {
// If it is @initialization we convert it to an object since this is the format that
// later functions will rely on for the initialization segment. This is only valid
// for <SegmentTemplate>
template.initialization = { sourceURL: template.initialization };
}
const segmentInfo = {
template,
segmentTimeline: segmentTimeline &&
findChildren(segmentTimeline, 'S').map(s => parseAttributes(s)),
list: segmentList && merge(
parseAttributes(segmentList),
{
segmentUrls,
initialization: parseAttributes(segmentInitialization)
}
),
base: segmentBase && merge(parseAttributes(segmentBase), {
initialization: parseAttributes(segmentInitialization)
})
};
Object.keys(segmentInfo).forEach(key => {
if (!segmentInfo[key]) {
delete segmentInfo[key];
}
});
return segmentInfo;
};
/**
* Contains Segment information and attributes needed to construct a Playlist object
* from a Representation
*
* @typedef {Object} RepresentationInformation
* @property {SegmentInformation} segmentInfo
* Segment information for this Representation
* @property {Object} attributes
* Inherited attributes for this Representation
*/
/**
* Maps a Representation node to an object containing Segment information and attributes
*
* @name inheritBaseUrlsCallback
* @function
* @param {Node} representation
* Representation node from the mpd
* @return {RepresentationInformation}
* Representation information needed to construct a Playlist object
*/
/**
* Returns a callback for Array.prototype.map for mapping Representation nodes to
* Segment information and attributes using inherited BaseURL nodes.
*
* @param {Object} adaptationSetAttributes
* Contains attributes inherited by the AdaptationSet
* @param {string[]} adaptationSetBaseUrls
* Contains list of resolved base urls inherited by the AdaptationSet
* @param {SegmentInformation} adaptationSetSegmentInfo
* Contains Segment information for the AdaptationSet
* @return {inheritBaseUrlsCallback}
* Callback map function
*/
export const inheritBaseUrls =
(adaptationSetAttributes, adaptationSetBaseUrls, adaptationSetSegmentInfo) =>
(representation) => {
const repBaseUrlElements = findChildren(representation, 'BaseURL');
const repBaseUrls = buildBaseUrls(adaptationSetBaseUrls, repBaseUrlElements);
const attributes = merge(adaptationSetAttributes, parseAttributes(representation));
const representationSegmentInfo = getSegmentInformation(representation);
return repBaseUrls.map(baseUrl => {
return {
segmentInfo: merge(adaptationSetSegmentInfo, representationSegmentInfo),
attributes: merge(attributes, { baseUrl })
};
});
};
/**
* Tranforms a series of content protection nodes to
* an object containing pssh data by key system
*
* @param {Node[]} contentProtectionNodes
* Content protection nodes
* @return {Object}
* Object containing pssh data by key system
*/
const generateKeySystemInformation = (contentProtectionNodes) => {
return contentProtectionNodes.reduce((acc, node) => {
const attributes = parseAttributes(node);
// Although it could be argued that according to the UUID RFC spec the UUID string (a-f chars) should be generated
// as a lowercase string it also mentions it should be treated as case-insensitive on input. Since the key system
// UUIDs in the keySystemsMap are hardcoded as lowercase in the codebase there isn't any reason not to do
// .toLowerCase() on the input UUID string from the manifest (at least I could not think of one).
if (attributes.schemeIdUri) {
attributes.schemeIdUri = attributes.schemeIdUri.toLowerCase();
}
const keySystem = keySystemsMap[attributes.schemeIdUri];
if (keySystem) {
acc[keySystem] = { attributes };
const psshNode = findChildren(node, 'cenc:pssh')[0];
if (psshNode) {
const pssh = getContent(psshNode);
acc[keySystem].pssh = pssh && decodeB64ToUint8Array(pssh);
}
}
return acc;
}, {});
};
// defined in ANSI_SCTE 214-1 2016
export const parseCaptionServiceMetadata = (service) => {
// 608 captions
if (service.schemeIdUri === 'urn:scte:dash:cc:cea-608:2015') {
const values = typeof service.value !== 'string' ? [] : service.value.split(';');
return values.map((value) => {
let channel;
let language;
// default language to value
language = value;
if (/^CC\d=/.test(value)) {
[channel, language] = value.split('=');
} else if (/^CC\d$/.test(value)) {
channel = value;
}
return {channel, language};
});
} else if (service.schemeIdUri === 'urn:scte:dash:cc:cea-708:2015') {
const values = typeof service.value !== 'string' ? [] : service.value.split(';');
return values.map((value) => {
const flags = {
// service or channel number 1-63
'channel': undefined,
// language is a 3ALPHA per ISO 639.2/B
// field is required
'language': undefined,
// BIT 1/0 or ?
// default value is 1, meaning 16:9 aspect ratio, 0 is 4:3, ? is unknown
'aspectRatio': 1,
// BIT 1/0
// easy reader flag indicated the text is tailed to the needs of beginning readers
// default 0, or off
'easyReader': 0,
// BIT 1/0
// If 3d metadata is present (CEA-708.1) then 1
// default 0
'3D': 0
};
if (/=/.test(value)) {
const [channel, opts = ''] = value.split('=');
flags.channel = channel;
flags.language = value;
opts.split(',').forEach((opt) => {
const [name, val] = opt.split(':');
if (name === 'lang') {
flags.language = val;
// er for easyReadery
} else if (name === 'er') {
flags.easyReader = Number(val);
// war for wide aspect ratio
} else if (name === 'war') {
flags.aspectRatio = Number(val);
} else if (name === '3D') {
flags['3D'] = Number(val);
}
});
} else {
flags.language = value;
}
if (flags.channel) {
flags.channel = 'SERVICE' + flags.channel;
}
return flags;
});
}
};
/**
* A map callback that will parse all event stream data for a collection of periods
* DASH ISO_IEC_23009 5.10.2.2
* https://dashif-documents.azurewebsites.net/Events/master/event.html#mpd-event-timing
*
* @param {PeriodInformation} period object containing necessary period information
* @return a collection of parsed eventstream event objects
*/
export const toEventStream = (period) => {
// get and flatten all EventStreams tags and parse attributes and children
return flatten(findChildren(period.node, 'EventStream').map((eventStream) => {
const eventStreamAttributes = parseAttributes(eventStream);
const schemeIdUri = eventStreamAttributes.schemeIdUri;
// find all Events per EventStream tag and map to return objects
return findChildren(eventStream, 'Event').map((event) => {
const eventAttributes = parseAttributes(event);
const presentationTime = eventAttributes.presentationTime || 0;
const timescale = eventStreamAttributes.timescale || 1;
const duration = eventAttributes.duration || 0;
const start = (presentationTime / timescale) + period.attributes.start;
return {
schemeIdUri,
value: eventStreamAttributes.value,
id: eventAttributes.id,
start,
end: start + (duration / timescale),
messageData: getContent(event) || eventAttributes.messageData,
contentEncoding: eventStreamAttributes.contentEncoding,
presentationTimeOffset: eventStreamAttributes.presentationTimeOffset || 0
};
});
}));
};
/**
* Maps an AdaptationSet node to a list of Representation information objects
*
* @name toRepresentationsCallback
* @function
* @param {Node} adaptationSet
* AdaptationSet node from the mpd
* @return {RepresentationInformation[]}
* List of objects containing Representaion information
*/
/**
* Returns a callback for Array.prototype.map for mapping AdaptationSet nodes to a list of
* Representation information objects
*
* @param {Object} periodAttributes
* Contains attributes inherited by the Period
* @param {string[]} periodBaseUrls
* Contains list of resolved base urls inherited by the Period
* @param {string[]} periodSegmentInfo
* Contains Segment Information at the period level
* @return {toRepresentationsCallback}
* Callback map function
*/
export const toRepresentations =
(periodAttributes, periodBaseUrls, periodSegmentInfo) => (adaptationSet) => {
const adaptationSetAttributes = parseAttributes(adaptationSet);
const adaptationSetBaseUrls = buildBaseUrls(
periodBaseUrls,
findChildren(adaptationSet, 'BaseURL')
);
const role = findChildren(adaptationSet, 'Role')[0];
const roleAttributes = { role: parseAttributes(role) };
let attrs = merge(
periodAttributes,
adaptationSetAttributes,
roleAttributes
);
const accessibility = findChildren(adaptationSet, 'Accessibility')[0];
const captionServices = parseCaptionServiceMetadata(parseAttributes(accessibility));
if (captionServices) {
attrs = merge(attrs, { captionServices });
}
const label = findChildren(adaptationSet, 'Label')[0];
if (label && label.childNodes.length) {
const labelVal = label.childNodes[0].nodeValue.trim();
attrs = merge(attrs, { label: labelVal });
}
const contentProtection = generateKeySystemInformation(findChildren(adaptationSet, 'ContentProtection'));
if (Object.keys(contentProtection).length) {
attrs = merge(attrs, { contentProtection });
}
const segmentInfo = getSegmentInformation(adaptationSet);
const representations = findChildren(adaptationSet, 'Representation');
const adaptationSetSegmentInfo = merge(periodSegmentInfo, segmentInfo);
return flatten(representations.map(inheritBaseUrls(attrs, adaptationSetBaseUrls, adaptationSetSegmentInfo)));
};
/**
* Contains all period information for mapping nodes onto adaptation sets.
*
* @typedef {Object} PeriodInformation
* @property {Node} period.node
* Period node from the mpd
* @property {Object} period.attributes
* Parsed period attributes from node plus any added
*/
/**
* Maps a PeriodInformation object to a list of Representation information objects for all
* AdaptationSet nodes contained within the Period.
*
* @name toAdaptationSetsCallback
* @function
* @param {PeriodInformation} period
* Period object containing necessary period information
* @param {number} periodStart
* Start time of the Period within the mpd
* @return {RepresentationInformation[]}
* List of objects containing Representaion information
*/
/**
* Returns a callback for Array.prototype.map for mapping Period nodes to a list of
* Representation information objects
*
* @param {Object} mpdAttributes
* Contains attributes inherited by the mpd
* @param {string[]} mpdBaseUrls
* Contains list of resolved base urls inherited by the mpd
* @return {toAdaptationSetsCallback}
* Callback map function
*/
export const toAdaptationSets = (mpdAttributes, mpdBaseUrls) => (period, index) => {
const periodBaseUrls = buildBaseUrls(mpdBaseUrls, findChildren(period.node, 'BaseURL'));
const periodAttributes = merge(mpdAttributes, {
periodStart: period.attributes.start
});
if (typeof period.attributes.duration === 'number') {
periodAttributes.periodDuration = period.attributes.duration;
}
const adaptationSets = findChildren(period.node, 'AdaptationSet');
const periodSegmentInfo = getSegmentInformation(period.node);
return flatten(adaptationSets.map(toRepresentations(periodAttributes, periodBaseUrls, periodSegmentInfo)));
};
/**
* Gets Period@start property for a given period.
*
* @param {Object} options
* Options object
* @param {Object} options.attributes
* Period attributes
* @param {Object} [options.priorPeriodAttributes]
* Prior period attributes (if prior period is available)
* @param {string} options.mpdType
* The MPD@type these periods came from
* @return {number|null}
* The period start, or null if it's an early available period or error
*/
export const getPeriodStart = ({ attributes, priorPeriodAttributes, mpdType }) => {
// Summary of period start time calculation from DASH spec section 5.3.2.1
//
// A period's start is the first period's start + time elapsed after playing all
// prior periods to this one. Periods continue one after the other in time (without
// gaps) until the end of the presentation.
//
// The value of Period@start should be:
// 1. if Period@start is present: value of Period@start
// 2. if previous period exists and it has @duration: previous Period@start +
// previous Period@duration
// 3. if this is first period and MPD@type is 'static': 0
// 4. in all other cases, consider the period an "early available period" (note: not
// currently supported)
// (1)
if (typeof attributes.start === 'number') {
return attributes.start;
}
// (2)
if (priorPeriodAttributes &&
typeof priorPeriodAttributes.start === 'number' &&
typeof priorPeriodAttributes.duration === 'number') {
return priorPeriodAttributes.start + priorPeriodAttributes.duration;
}
// (3)
if (!priorPeriodAttributes && mpdType === 'static') {
return 0;
}
// (4)
// There is currently no logic for calculating the Period@start value if there is
// no Period@start or prior Period@start and Period@duration available. This is not made
// explicit by the DASH interop guidelines or the DASH spec, however, since there's
// nothing about any other resolution strategies, it's implied. Thus, this case should
// be considered an early available period, or error, and null should suffice for both
// of those cases.
return null;
};
/**
* Traverses the mpd xml tree to generate a list of Representation information objects
* that have inherited attributes from parent nodes
*
* @param {Node} mpd
* The root node of the mpd
* @param {Object} options
* Available options for inheritAttributes
* @param {string} options.manifestUri
* The uri source of the mpd
* @param {number} options.NOW
* Current time per DASH IOP. Default is current time in ms since epoch
* @param {number} options.clientOffset
* Client time difference from NOW (in milliseconds)
* @return {RepresentationInformation[]}
* List of objects containing Representation information
*/
export const inheritAttributes = (mpd, options = {}) => {
const {
manifestUri = '',
NOW = Date.now(),
clientOffset = 0
} = options;
const periodNodes = findChildren(mpd, 'Period');
if (!periodNodes.length) {
throw new Error(errors.INVALID_NUMBER_OF_PERIOD);
}
const locations = findChildren(mpd, 'Location');
const mpdAttributes = parseAttributes(mpd);
const mpdBaseUrls = buildBaseUrls([ manifestUri ], findChildren(mpd, 'BaseURL'));
// See DASH spec section 5.3.1.2, Semantics of MPD element. Default type to 'static'.
mpdAttributes.type = mpdAttributes.type || 'static';
mpdAttributes.sourceDuration = mpdAttributes.mediaPresentationDuration || 0;
mpdAttributes.NOW = NOW;
mpdAttributes.clientOffset = clientOffset;
if (locations.length) {
mpdAttributes.locations = locations.map(getContent);
}
const periods = [];
// Since toAdaptationSets acts on individual periods right now, the simplest approach to
// adding properties that require looking at prior periods is to parse attributes and add
// missing ones before toAdaptationSets is called. If more such properties are added, it
// may be better to refactor toAdaptationSets.
periodNodes.forEach((node, index) => {
const attributes = parseAttributes(node);
// Use the last modified prior period, as it may contain added information necessary
// for this period.
const priorPeriod = periods[index - 1];
attributes.start = getPeriodStart({
attributes,
priorPeriodAttributes: priorPeriod ? priorPeriod.attributes : null,
mpdType: mpdAttributes.type
});
periods.push({
node,
attributes
});
});
return {
locations: mpdAttributes.locations,
representationInfo: flatten(periods.map(toAdaptationSets(mpdAttributes, mpdBaseUrls))),
eventStream: flatten(periods.map(toEventStream))
};
};