rx-player
Version:
Canal+ HTML5 Video Player
724 lines (652 loc) • 23.4 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 { SUPPORTED_ADAPTATIONS_TYPE } from "../../../manifest";
import arrayIncludes from "../../../utils/array_includes";
import assert from "../../../utils/assert";
import { concat, itobe4 } from "../../../utils/byte_parsing";
import isNonEmptyString from "../../../utils/is_non_empty_string";
import isNullOrUndefined from "../../../utils/is_null_or_undefined";
import getMonotonicTimeStamp from "../../../utils/monotonic_timestamp";
import objectAssign from "../../../utils/object_assign";
import { hexToBytes } from "../../../utils/string_parsing";
import { getFilenameIndexInUrl } from "../../../utils/url-utils";
import { createBox } from "../../containers/isobmff";
import type {
IParsedAdaptation,
IParsedAdaptations,
IParsedManifest,
IParsedRepresentation,
} from "../types";
import checkManifestIDs from "../utils/check_manifest_ids";
import { getAudioCodecs, getVideoCodecs } from "./get_codecs";
import parseCNodes from "./parse_C_nodes";
import type { IContentProtectionSmooth, IKeySystem } from "./parse_protection_node";
import parseProtectionNode from "./parse_protection_node";
import RepresentationIndex from "./representation_index";
import SharedSmoothSegmentTimeline from "./shared_smooth_segment_timeline";
import parseBoolean from "./utils/parseBoolean";
import reduceChildren from "./utils/reduceChildren";
import { replaceRepresentationSmoothTokens } from "./utils/tokens";
interface IAdaptationParserArguments {
root: Element;
baseUrl: string;
timescale: number;
protections: IContentProtectionSmooth[];
isLive: boolean;
timeShiftBufferDepth?: number | undefined;
manifestReceivedTime?: number | undefined;
}
type IAdaptationType = "audio" | "video" | "text";
const DEFAULT_MIME_TYPES: Partial<Record<string, string>> = {
audio: "audio/mp4",
video: "video/mp4",
text: "application/ttml+xml",
};
const MIME_TYPES: Partial<Record<string, string>> = {
AACL: "audio/mp4",
AVC1: "video/mp4",
H264: "video/mp4",
TTML: "application/ttml+xml+mp4",
DFXP: "application/ttml+xml+mp4",
};
export interface IHSSParserConfiguration {
suggestedPresentationDelay?: number | undefined;
referenceDateTime?: number | undefined;
minRepresentationBitrate?: number | undefined;
keySystems?: ((hex: Uint8Array | undefined) => IKeySystem[]) | undefined;
serverSyncInfos?:
| {
serverTimestamp: number;
clientTime: number;
}
| undefined;
}
interface ISmoothParsedQualityLevel {
// required
bitrate: number;
codecPrivateData: string;
customAttributes: string[];
// optional
audiotag?: number | undefined;
bitsPerSample?: number | undefined;
channels?: number | undefined;
codecs?: string | undefined;
height?: number | undefined;
id?: string | undefined;
mimeType?: string | undefined;
packetSize?: number | undefined;
samplingRate?: number | undefined;
width?: number | undefined;
}
/**
* @param {Object|undefined} parserOptions
* @returns {Function}
*/
function createSmoothStreamingParser(
parserOptions: IHSSParserConfiguration = {},
): (
manifest: Document,
url?: string | undefined,
manifestReceivedTime?: number | undefined,
) => IParsedManifest {
const referenceDateTime =
parserOptions.referenceDateTime === undefined
? Date.UTC(1970, 0, 1, 0, 0, 0, 0) / 1000
: parserOptions.referenceDateTime;
const minRepresentationBitrate =
parserOptions.minRepresentationBitrate === undefined
? 0
: parserOptions.minRepresentationBitrate;
const { serverSyncInfos } = parserOptions;
const serverTimeOffset =
serverSyncInfos !== undefined
? serverSyncInfos.serverTimestamp - serverSyncInfos.clientTime
: undefined;
/**
* @param {Element} q
* @param {string} streamType
* @return {Object}
*/
function parseQualityLevel(
q: Element,
streamType: string,
): ISmoothParsedQualityLevel | null {
const customAttributes = reduceChildren<string[]>(
q,
(acc, qName, qNode) => {
if (qName === "CustomAttributes") {
acc.push(
...reduceChildren<string[]>(
qNode,
(cAttrs, cName, cNode) => {
if (cName === "Attribute") {
const name = cNode.getAttribute("Name");
const value = cNode.getAttribute("Value");
if (name !== null && value !== null) {
cAttrs.push(name + "=" + value);
}
}
return cAttrs;
},
[],
),
);
}
return acc;
},
[],
);
/**
* @param {string} name
* @returns {string|undefined}
*/
function getAttribute(name: string): string | undefined {
const attr = q.getAttribute(name);
return attr === null ? undefined : attr;
}
switch (streamType) {
case "audio": {
const audiotag = getAttribute("AudioTag");
const bitsPerSample = getAttribute("BitsPerSample");
const channels = getAttribute("Channels");
const codecPrivateData = getAttribute("CodecPrivateData");
const fourCC = getAttribute("FourCC");
const packetSize = getAttribute("PacketSize");
const samplingRate = getAttribute("SamplingRate");
const bitrateAttr = getAttribute("Bitrate");
let bitrate = bitrateAttr === undefined ? 0 : parseInt(bitrateAttr, 10);
bitrate = isNaN(bitrate) ? 0 : bitrate;
if (
(fourCC !== undefined && MIME_TYPES[fourCC] === undefined) ||
codecPrivateData === undefined
) {
log.warn("smooth", "Unsupported audio codec. Ignoring quality level.", {
fourCC,
});
return null;
}
const codecs = getAudioCodecs(codecPrivateData, fourCC);
return {
audiotag: audiotag !== undefined ? parseInt(audiotag, 10) : audiotag,
bitrate,
bitsPerSample:
bitsPerSample !== undefined ? parseInt(bitsPerSample, 10) : bitsPerSample,
channels: channels !== undefined ? parseInt(channels, 10) : channels,
codecPrivateData,
codecs,
customAttributes,
mimeType: fourCC !== undefined ? MIME_TYPES[fourCC] : fourCC,
packetSize: packetSize !== undefined ? parseInt(packetSize, 10) : packetSize,
samplingRate:
samplingRate !== undefined ? parseInt(samplingRate, 10) : samplingRate,
};
}
case "video": {
const codecPrivateData = getAttribute("CodecPrivateData");
const fourCC = getAttribute("FourCC");
const width = getAttribute("MaxWidth");
const height = getAttribute("MaxHeight");
const bitrateAttr = getAttribute("Bitrate");
let bitrate = bitrateAttr === undefined ? 0 : parseInt(bitrateAttr, 10);
bitrate = isNaN(bitrate) ? 0 : bitrate;
if (
(fourCC !== undefined && MIME_TYPES[fourCC] === undefined) ||
codecPrivateData === undefined
) {
log.warn("smooth", "Unsupported video codec. Ignoring quality level.", {
fourCC,
});
return null;
}
const codecs = getVideoCodecs(codecPrivateData);
return {
bitrate,
customAttributes,
mimeType: fourCC !== undefined ? MIME_TYPES[fourCC] : fourCC,
codecPrivateData,
codecs,
width: width !== undefined ? parseInt(width, 10) : undefined,
height: height !== undefined ? parseInt(height, 10) : undefined,
};
}
case "text": {
const codecPrivateData = getAttribute("CodecPrivateData");
const fourCC = getAttribute("FourCC");
const bitrateAttr = getAttribute("Bitrate");
let bitrate = bitrateAttr === undefined ? 0 : parseInt(bitrateAttr, 10);
bitrate = isNaN(bitrate) ? 0 : bitrate;
return {
bitrate,
customAttributes,
mimeType: fourCC !== undefined ? MIME_TYPES[fourCC] : fourCC,
codecPrivateData: codecPrivateData ?? "",
};
}
default:
log.error("smooth", "Unrecognized StreamIndex type: " + streamType);
return null;
}
}
/**
* Parse the adaptations (<StreamIndex>) tree containing
* representations (<QualityLevels>) and timestamp indexes (<c>).
* Indexes can be quite huge, and this function needs to
* to be optimized.
* @param {Object} args
* @returns {Object}
*/
function parseAdaptation(args: IAdaptationParserArguments): IParsedAdaptation | null {
const {
root,
timescale,
baseUrl,
protections,
timeShiftBufferDepth,
manifestReceivedTime,
isLive,
} = args;
const timescaleAttr = root.getAttribute("Timescale");
let _timescale = timescaleAttr === null ? timescale : +timescaleAttr;
if (isNaN(_timescale)) {
_timescale = timescale;
}
const typeAttribute = root.getAttribute("Type");
if (typeAttribute === null) {
throw new Error("StreamIndex without type.");
}
if (!arrayIncludes(SUPPORTED_ADAPTATIONS_TYPE, typeAttribute)) {
log.warn("smooth", "Unrecognized adaptation type:", typeAttribute);
}
const adaptationType = typeAttribute as IAdaptationType;
const subType = root.getAttribute("Subtype");
const language = root.getAttribute("Language");
const UrlAttr = root.getAttribute("Url");
const UrlPathWithTokens = UrlAttr === null ? "" : UrlAttr;
assert(UrlPathWithTokens !== "");
const { qualityLevels, cNodes } = reduceChildren<{
qualityLevels: ISmoothParsedQualityLevel[];
cNodes: Element[];
}>(
root,
(res, _name, node) => {
switch (_name) {
case "QualityLevel": {
const qualityLevel = parseQualityLevel(node, adaptationType);
if (qualityLevel === null) {
return res;
}
// filter out video qualityLevels with small bitrates
if (
adaptationType !== "video" ||
qualityLevel.bitrate > minRepresentationBitrate
) {
res.qualityLevels.push(qualityLevel);
}
break;
}
case "c":
res.cNodes.push(node);
break;
}
return res;
},
{ qualityLevels: [], cNodes: [] },
);
const sharedSmoothTimeline = new SharedSmoothSegmentTimeline({
timeline: parseCNodes(cNodes),
timescale: _timescale,
timeShiftBufferDepth,
manifestReceivedTime,
});
// we assume that all qualityLevels have the same
// codec and mimeType
assert(
qualityLevels.length !== 0,
"Adaptation should have at least one playable representation.",
);
const adaptationID =
adaptationType + (isNonEmptyString(language) ? "_" + language : "");
const representations = qualityLevels.map((qualityLevel) => {
const media = replaceRepresentationSmoothTokens(
UrlPathWithTokens,
qualityLevel.bitrate,
qualityLevel.customAttributes,
);
const mimeType = isNonEmptyString(qualityLevel.mimeType)
? qualityLevel.mimeType
: DEFAULT_MIME_TYPES[adaptationType];
const codecs = qualityLevel.codecs;
const id =
adaptationID +
"_" +
(!isNullOrUndefined(adaptationType) ? adaptationType + "-" : "") +
(!isNullOrUndefined(mimeType) ? mimeType + "-" : "") +
(!isNullOrUndefined(codecs) ? codecs + "-" : "") +
String(qualityLevel.bitrate);
const keyIDs: Uint8Array[] = [];
let firstProtection: IContentProtectionSmooth | undefined;
if (protections.length > 0) {
firstProtection = protections[0];
protections.forEach((protection) => {
keyIDs.push(protection.keyId);
});
}
const segmentPrivateInfos = {
bitsPerSample: qualityLevel.bitsPerSample,
channels: qualityLevel.channels,
codecPrivateData: qualityLevel.codecPrivateData,
packetSize: qualityLevel.packetSize,
samplingRate: qualityLevel.samplingRate,
height: qualityLevel.height,
width: qualityLevel.width,
// TODO set multiple protections here
// instead of the first one
protection: !isNullOrUndefined(firstProtection)
? {
keyId: firstProtection.keyId,
}
: undefined,
};
const reprIndex = new RepresentationIndex({
isLive,
sharedSmoothTimeline,
media,
segmentPrivateInfos,
});
const representation: IParsedRepresentation = objectAssign({}, qualityLevel, {
index: reprIndex,
cdnMetadata: [{ baseUrl }],
mimeType,
codecs,
id,
});
if (keyIDs.length > 0 || firstProtection !== undefined) {
const initDataValues: Array<{ systemId: string; data: Uint8Array }> =
firstProtection === undefined
? []
: firstProtection.keySystems.map((keySystemData) => {
const { systemId, privateData } = keySystemData;
const cleanedSystemId = systemId.replace(/-/g, "");
const pssh = createPSSHBox(cleanedSystemId, privateData);
return { systemId: cleanedSystemId, data: pssh };
});
if (initDataValues.length > 0) {
const initData = [{ type: "cenc", values: initDataValues }];
representation.contentProtections = { keyIds: keyIDs, initData };
} else {
representation.contentProtections = { keyIds: keyIDs, initData: [] };
}
}
return representation;
});
// TODO(pierre): real ad-insert support
if (subType === "ADVT") {
return null;
}
const parsedAdaptation: IParsedAdaptation = {
id: adaptationID,
type: adaptationType,
representations,
language: language === null ? undefined : language,
};
if (adaptationType === "text" && subType === "DESC") {
parsedAdaptation.closedCaption = true;
}
return parsedAdaptation;
}
function parseFromDocument(
doc: Document,
url?: string,
manifestReceivedTime?: number,
): IParsedManifest {
let baseUrl: string = "";
if (url !== undefined) {
const filenameIdx = getFilenameIndexInUrl(url);
baseUrl = url.substring(0, filenameIdx);
}
const root = doc.documentElement;
if (isNullOrUndefined(root) || root.nodeName !== "SmoothStreamingMedia") {
throw new Error("document root should be SmoothStreamingMedia");
}
const majorVersionAttr = root.getAttribute("MajorVersion");
const minorVersionAttr = root.getAttribute("MinorVersion");
if (
majorVersionAttr === null ||
minorVersionAttr === null ||
!/^[2]-[0-2]$/.test(majorVersionAttr + "-" + minorVersionAttr)
) {
throw new Error("Version should be 2.0, 2.1 or 2.2");
}
const timescaleAttr = root.getAttribute("Timescale");
let timescale = !isNonEmptyString(timescaleAttr) ? 10000000 : +timescaleAttr;
if (isNaN(timescale)) {
timescale = 10000000;
}
const { protections, adaptationNodes } = reduceChildren<{
protections: IContentProtectionSmooth[];
adaptationNodes: Element[];
}>(
root,
(res, name, node) => {
switch (name) {
case "Protection": {
res.protections.push(parseProtectionNode(node, parserOptions.keySystems));
break;
}
case "StreamIndex":
res.adaptationNodes.push(node);
break;
}
return res;
},
{
adaptationNodes: [],
protections: [],
},
);
const initialAdaptations: IParsedAdaptations = {};
const isLive = parseBoolean(root.getAttribute("IsLive"));
let timeShiftBufferDepth: number | undefined;
if (isLive) {
const dvrWindowLength = root.getAttribute("DVRWindowLength");
if (
dvrWindowLength !== null &&
!isNaN(+dvrWindowLength) &&
+dvrWindowLength !== 0
) {
timeShiftBufferDepth = +dvrWindowLength / timescale;
}
}
const adaptations: IParsedAdaptations = adaptationNodes.reduce(
(acc: IParsedAdaptations, node: Element) => {
const adaptation = parseAdaptation({
root: node,
baseUrl,
timescale,
protections,
isLive,
timeShiftBufferDepth,
manifestReceivedTime,
});
if (adaptation === null) {
return acc;
}
const type = adaptation.type;
const adaps = acc[type];
if (adaps === undefined) {
acc[type] = [adaptation];
} else {
adaps.push(adaptation);
}
return acc;
},
initialAdaptations,
);
let suggestedPresentationDelay: number | undefined;
let availabilityStartTime: number | undefined;
let minimumTime: number | undefined;
let timeshiftDepth: number | null = null;
let maximumTimeData: {
isLinear: boolean;
maximumSafePosition: number;
livePosition: number | undefined;
time: number;
};
const firstVideoAdaptation =
adaptations.video !== undefined ? adaptations.video[0] : undefined;
const firstAudioAdaptation =
adaptations.audio !== undefined ? adaptations.audio[0] : undefined;
/** Minimum time that can be reached regardless of the StreamIndex chosen. */
let safeMinimumTime: number | undefined;
/** Maximum time that can be reached regardless of the StreamIndex chosen. */
let safeMaximumTime: number | undefined;
/** Maximum time that can be reached in absolute on the content. */
let unsafeMaximumTime: number | undefined;
if (firstVideoAdaptation !== undefined || firstAudioAdaptation !== undefined) {
const firstTimeReferences: number[] = [];
const lastTimeReferences: number[] = [];
if (firstVideoAdaptation !== undefined) {
const firstVideoRepresentation = firstVideoAdaptation.representations[0];
if (firstVideoRepresentation !== undefined) {
const firstVideoTimeReference =
firstVideoRepresentation.index.getFirstAvailablePosition();
const lastVideoTimeReference =
firstVideoRepresentation.index.getLastAvailablePosition();
if (!isNullOrUndefined(firstVideoTimeReference)) {
firstTimeReferences.push(firstVideoTimeReference);
}
if (!isNullOrUndefined(lastVideoTimeReference)) {
lastTimeReferences.push(lastVideoTimeReference);
}
}
}
if (firstAudioAdaptation !== undefined) {
const firstAudioRepresentation = firstAudioAdaptation.representations[0];
if (firstAudioRepresentation !== undefined) {
const firstAudioTimeReference =
firstAudioRepresentation.index.getFirstAvailablePosition();
const lastAudioTimeReference =
firstAudioRepresentation.index.getLastAvailablePosition();
if (!isNullOrUndefined(firstAudioTimeReference)) {
firstTimeReferences.push(firstAudioTimeReference);
}
if (!isNullOrUndefined(lastAudioTimeReference)) {
lastTimeReferences.push(lastAudioTimeReference);
}
}
}
if (firstTimeReferences.length > 0) {
safeMinimumTime = Math.max(...firstTimeReferences);
}
if (lastTimeReferences.length > 0) {
safeMaximumTime = Math.min(...lastTimeReferences);
unsafeMaximumTime = Math.max(...lastTimeReferences);
}
}
const manifestDuration = root.getAttribute("Duration");
const duration =
manifestDuration !== null && +manifestDuration !== 0
? +manifestDuration / timescale
: undefined;
if (isLive) {
suggestedPresentationDelay = parserOptions.suggestedPresentationDelay;
availabilityStartTime = referenceDateTime;
minimumTime = safeMinimumTime ?? availabilityStartTime;
let livePosition = unsafeMaximumTime;
if (livePosition === undefined) {
livePosition = Date.now() / 1000 - availabilityStartTime;
}
let maximumSafePosition = safeMaximumTime;
if (maximumSafePosition === undefined) {
maximumSafePosition = livePosition;
}
maximumTimeData = {
isLinear: true,
maximumSafePosition,
livePosition,
time: getMonotonicTimeStamp(),
};
timeshiftDepth = timeShiftBufferDepth ?? null;
} else {
minimumTime = safeMinimumTime ?? 0;
let maximumTime = safeMaximumTime;
if (maximumTime === undefined) {
maximumTime = duration !== undefined ? minimumTime + duration : Infinity;
}
maximumTimeData = {
isLinear: false,
maximumSafePosition: maximumTime,
livePosition: undefined,
time: getMonotonicTimeStamp(),
};
}
const periodStart = isLive ? 0 : minimumTime;
const periodEnd = isLive ? undefined : maximumTimeData.maximumSafePosition;
const manifest = {
availabilityStartTime:
availabilityStartTime === undefined ? 0 : availabilityStartTime,
clockOffset: serverTimeOffset,
isLive,
isDynamic: isLive,
isLastPeriodKnown: true,
timeBounds: {
minimumSafePosition: minimumTime,
timeshiftDepth,
maximumTimeData,
},
periods: [
{
adaptations,
duration: periodEnd !== undefined ? periodEnd - periodStart : duration,
end: periodEnd,
id: "gen-smooth-period-0",
start: periodStart,
thumbnailTracks: [],
},
],
suggestedPresentationDelay,
transportType: "smooth",
uris: isNullOrUndefined(url) ? [] : [url],
};
checkManifestIDs(manifest);
return manifest;
}
return parseFromDocument;
}
/**
* @param {string} systemId - Hex string representing the CDM, 16 bytes.
* @param {Uint8Array|undefined} privateData - Data associated to protection
* specific system.
* @returns {Uint8Array}
*/
function createPSSHBox(systemId: string, privateData: Uint8Array): Uint8Array {
if (systemId.length !== 32) {
throw new Error("HSS: wrong system id length");
}
const version = 0;
return createBox(
"pssh",
concat(
[version, 0, 0, 0],
hexToBytes(systemId),
/** To put there KIDs if it exists (necessitate PSSH v1) */
itobe4(privateData.length),
privateData,
),
);
}
export default createSmoothStreamingParser;