UNPKG

rx-player

Version:
724 lines (652 loc) 23.4 kB
/** * 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;