diff --git a/README.md b/README.md index fd058950ab1..24652f24142 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # ![Shaka Player](docs/shaka-player-logo.png) Shaka Player is an open-source JavaScript library for adaptive media. It plays -adaptive media formats (such as [DASH][] and [HLS][]) in a browser, without -using plugins or Flash. Instead, Shaka Player uses the open web standards -[MediaSource Extensions][] and [Encrypted Media Extensions][]. +adaptive media formats (such as [DASH][], [HLS][] and [MSS][]) in a browser, +without using plugins or Flash. Instead, Shaka Player uses the open web +standards [MediaSource Extensions][] and [Encrypted Media Extensions][]. Shaka Player also supports [offline storage and playback][] of media using [IndexedDB][]. Content can be stored on any browser. Storage of licenses @@ -18,6 +18,7 @@ For details on what's coming next, see our [development roadmap](roadmap.md). [DASH]: http://dashif.org/ [HLS]: https://developer.apple.com/streaming/ +[MSS]: https://learn.microsoft.com/en-us/iis/media/smooth-streaming/smooth-streaming-transport-protocol [MediaSource Extensions]: https://www.w3.org/TR/media-source/ [Encrypted Media Extensions]: https://www.w3.org/TR/encrypted-media/ [IndexedDB]: https://www.w3.org/TR/IndexedDB-2/ @@ -84,6 +85,7 @@ supported. This supports both DASH and HLS manifests. |:----:|:-------------:|:---:|:---:|:-------------------:| |DASH |**Y** |**Y**| - |**Y** | |HLS |**Y** |**Y**|**Y**| - | +|MSS |**Y** | - | - | - | You can also create a [manifest parser plugin][] to support custom manifest formats. @@ -159,6 +161,20 @@ HLS features **not** supported: [MPEG-5 Part2 LCEVC]: https://www.lcevc.org +## MSS features + +MSS features supported: + - VOD + - AAC and H.264 + - Encrypted content (PlayReady) + - TTML/DFXP + - Only supported with [codem-isoboxer][] + +MSS features **not** supported: + - Live + +[codem-isoboxer]: https://github.com/Dash-Industry-Forum/codem-isoboxer + ## DRM support matrix |Browser |Widevine |PlayReady|FairPlay |ClearKey⁶ | @@ -196,6 +212,7 @@ NOTES: |:--------:|:--------:|:-------:|:-------:|:--------:| |DASH |**Y** |**Y** | - |**Y** | |HLS |**Y** |**Y** |**Y** ¹ | - | +|MSS | - |**Y** | - | - | NOTES: - ¹: By default, FairPlay is handled using Apple's native HLS player, when on @@ -211,6 +228,7 @@ Shaka Player supports: - Can parse "sidx" box for DASH's SegmentBase@indexRange and SegmentTemplate@index - Can find and parse "tfdt" box to find segment start time in HLS + - For MSS it's necessary [codem-isoboxer][] v0.3.7+ - WebM - Depends on browser support for the container via MediaSource - Can parse [cueing data][] elements for DASH's SegmentBase@indexRange and diff --git a/build/types/manifests b/build/types/manifests index 29426067ac7..5e5b0450a54 100644 --- a/build/types/manifests +++ b/build/types/manifests @@ -2,4 +2,5 @@ +@dash +@hls ++@mss +@offline diff --git a/build/types/mss b/build/types/mss new file mode 100644 index 00000000000..a9f330d2ec7 --- /dev/null +++ b/build/types/mss @@ -0,0 +1,7 @@ +# The MSS manifest parser plugin. + ++../../lib/mss/content_protection.js ++../../lib/mss/mss_parser.js ++../../lib/mss/mss_utils.js + ++../../lib/transmuxer/mss_transmuxer.js diff --git a/demo/common/assets.js b/demo/common/assets.js index 9b52e56f33f..35537d7372f 100644 --- a/demo/common/assets.js +++ b/demo/common/assets.js @@ -141,6 +141,8 @@ shakaAssets.Feature = { DASH: shakaDemo.MessageIds.DASH, // Set if the asset is an HLS manifest. HLS: shakaDemo.MessageIds.HLS, + // Set if the asset is an MSS manifest. + MSS: shakaDemo.MessageIds.MSS, // Set if the asset has at least one image stream. THUMBNAILS: shakaDemo.MessageIds.THUMBNAILS, @@ -903,6 +905,14 @@ shakaAssets.testAssets = [ .addFeature(shakaAssets.Feature.MP4) .addFeature(shakaAssets.Feature.LIVE) .addFeature(shakaAssets.Feature.THUMBNAILS), + new ShakaDemoAssetInfo( + /* name= */ 'Microsoft Smooth Streaming', + /* iconUri= */ 'https://reference.dashif.org/dash.js/latest/samples/lib/img/mss-1.jpg', + /* manifestUri= */ 'https://playready.directtaps.net/smoothstreaming/SSWSS720H264/SuperSpeedway_720.ism/Manifest', + /* source= */ shakaAssets.Source.DASH_IF) + .addFeature(shakaAssets.Feature.MSS) + .addFeature(shakaAssets.Feature.HIGH_DEFINITION) + .addFeature(shakaAssets.Feature.MP4), // End DASH-IF Assets }}} // bitcodin assets {{{ diff --git a/demo/common/message_ids.js b/demo/common/message_ids.js index c938b82673d..6ec01581be4 100644 --- a/demo/common/message_ids.js +++ b/demo/common/message_ids.js @@ -21,6 +21,7 @@ shakaDemo.MessageIds = { LIVE: 'DEMO_LIVE', MP2TS: 'DEMO_MP2TS', MP4: 'DEMO_MP4', + MSS: 'DEMO_MSS', MULTIPLE_LANGUAGES: 'DEMO_MULTIPLE_LANGUAGES', OFFLINE: 'DEMO_OFFLINE', STORED: 'DEMO_STORED', @@ -86,6 +87,7 @@ shakaDemo.MessageIds = { UNSUPPORTED_NO_OFFLINE: 'DEMO_UNSUPPORTED_NO_OFFLINE', UNSUPPORTED_NO_KEY_SUPPORT: 'DEMO_UNSUPPORTED_NO_KEY_SUPPORT', UNSUPPORTED_NO_LICENSE_SUPPORT: 'DEMO_UNSUPPORTED_NO_LICENSE_SUPPORT', + UNSUPPORTED_NO_MSS_SUPPORT: 'DEMO_UNSUPPORTED_NO_MSS_SUPPORT', /* Visualizer. */ VISUALIZER_AUTO_SCREENSHOT_TOGGLE: 'DEMO_VISUALIZER_AUTO_SCREENSHOT_TOGGLE', VISUALIZER_BUTTON: 'DEMO_VISUALIZER_BUTTON', @@ -241,6 +243,7 @@ shakaDemo.MessageIds = { MIN_PIXELS: 'DEMO_MIN_PIXELS', MIN_TOTAL_BYTES: 'DEMO_MIN_TOTAL_BYTES', MIN_WIDTH: 'DEMO_MIN_WIDTH', + MSS_SEQUENCE_MODE: 'DEMO_MSS_SEQUENCE_MODE', NETWORK_INFORMATION: 'DEMO_NETWORK_INFORMATION', NUMBER_DECIMAL_WARNING: 'DEMO_NUMBER_DECIMAL_WARNING', NUMBER_INTEGER_WARNING: 'DEMO_NUMBER_INTEGER_WARNING', diff --git a/demo/config.js b/demo/config.js index b72ab6293c2..999b2f43da4 100644 --- a/demo/config.js +++ b/demo/config.js @@ -252,7 +252,9 @@ shakaDemo.Config = class { .addBoolInput_(MessageIds.DISABLE_THUMBNAILS, 'manifest.disableThumbnails') .addBoolInput_(MessageIds.SEGMENT_RELATIVE_VTT_TIMING, - 'manifest.segmentRelativeVttTiming'); + 'manifest.segmentRelativeVttTiming') + .addBoolInput_(MessageIds.MSS_SEQUENCE_MODE, + 'manifest.mss.sequenceMode'); this.addRetrySection_('manifest', MessageIds.MANIFEST_RETRY_SECTION_HEADER); } diff --git a/demo/index.html b/demo/index.html index db4a3a342ba..eb8c41a3890 100644 --- a/demo/index.html +++ b/demo/index.html @@ -38,6 +38,8 @@ + + diff --git a/demo/locales/en.json b/demo/locales/en.json index 8e529baeab5..1b2d20ff9f0 100644 --- a/demo/locales/en.json +++ b/demo/locales/en.json @@ -167,6 +167,8 @@ "DEMO_MIN_WIDTH": "Min Width", "DEMO_MP2TS": "MPEG-2 TS", "DEMO_MP4": "MP4", + "DEMO_MSS": "MSS", + "DEMO_MSS_SEQUENCE_MODE": "Enable MSS sequence mode", "DEMO_MULTIPLE_LANGUAGES": "Multiple languages", "DEMO_NAME": "Name", "DEMO_NAME_ERROR": "Must be a unique name.", @@ -244,6 +246,7 @@ "DEMO_UNSUPPORTED_NO_HLS_SUPPORT": "Your browser does not support HLS manifests.", "DEMO_UNSUPPORTED_NO_KEY_SUPPORT": "Your browser does not support the required key systems.", "DEMO_UNSUPPORTED_NO_LICENSE_SUPPORT": "Your browser does not support offline licenses for the required key systems.", + "DEMO_UNSUPPORTED_NO_MSS_SUPPORT": "Your browser does not support MSS manifests.", "DEMO_UNSUPPORTED_NO_OFFLINE": "Your browser does not support offline storage.", "DEMO_UPDATE_EXPIRATION_TIME": "Update expiration time", "DEMO_UPDATE_INTERVAL_SECONDS": "Update interval seconds", diff --git a/demo/locales/source.json b/demo/locales/source.json index 1069b210a50..dd1e80135f6 100644 --- a/demo/locales/source.json +++ b/demo/locales/source.json @@ -675,6 +675,14 @@ "description": "Text that describes an asset that uses the MP4 container.", "message": "[JARGON:MP4]" }, + "DEMO_MSS": { + "description": "Text that describes an asset that is packaged in an MSS manifest.", + "message": "[PROPER_NAME:MSS]" + }, + "DEMO_MSS_SEQUENCE_MODE": { + "description": "The name of a configuration value.", + "message": "Enable MSS sequence mode" + }, "DEMO_MULTIPLE_LANGUAGES": { "description": "A tag that marks an asset as having multiple languages.", "message": "Multiple languages" @@ -979,6 +987,10 @@ "description": "An error message that shows why an asset cannot be stored offline: the browser cannot store protected content offline.", "message": "Your browser does not support offline licenses for the required key systems." }, + "DEMO_UNSUPPORTED_NO_MSS_SUPPORT": { + "description": "An error message that shows why an asset cannot be stored offline: the browser cannot play MSS (https://en.wikipedia.org/wiki/Adaptive_bitrate_streaming#Microsoft_Smooth_Streaming_(MSS)) content.", + "message": "Your browser does not support [PROPER_NAME:MSS] manifests." + }, "DEMO_UNSUPPORTED_NO_OFFLINE": { "description": "An error message that shows why an asset cannot be stored offline: the browser does not support storing things offline, in general.", "message": "Your browser does not support offline storage." diff --git a/demo/main.js b/demo/main.js index e8fb3b87b11..79f69ccff23 100644 --- a/demo/main.js +++ b/demo/main.js @@ -743,6 +743,10 @@ shakaDemo.Main = class { !this.support_.manifest['m3u8']) { return shakaDemo.MessageIds.UNSUPPORTED_NO_HLS_SUPPORT; } + if (asset.features.includes(shakaAssets.Feature.MSS) && + !this.support_.manifest['ism']) { + return shakaDemo.MessageIds.UNSUPPORTED_NO_MSS_SUPPORT; + } // Does the asset contain a playable mime type? const mimeTypes = []; diff --git a/demo/search.js b/demo/search.js index 0d5bcefd185..28215be439a 100644 --- a/demo/search.js +++ b/demo/search.js @@ -359,7 +359,7 @@ shakaDemo.Search = class { /* docLink= */ null); this.makeSelectInput_(coreContainer, shakaDemo.MessageIds.MANIFEST_SEARCH, - [Feature.DASH, Feature.HLS], FEATURE); + [Feature.DASH, Feature.HLS, Feature.MSS], FEATURE); this.makeSelectInput_(coreContainer, shakaDemo.MessageIds.CONTAINER_SEARCH, [Feature.MP4, Feature.MP2TS, Feature.WEBM, Feature.CONTAINERLESS], diff --git a/demo/service_worker.js b/demo/service_worker.js index 64dd61ba71e..1d2f3b5cc93 100644 --- a/demo/service_worker.js +++ b/demo/service_worker.js @@ -95,6 +95,9 @@ const OPTIONAL_RESOURCES = [ // The mux.js transmuxing library for MPEG-2 TS and CEA support. '../node_modules/mux.js/dist/mux.min.js', + // The codem-isoboxer library for MSS support. + '../node_modules/codem-isoboxer/dist/iso_boxer.min.js', + // The cast sender SDK. 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js', diff --git a/externs/isoboxer.js b/externs/isoboxer.js new file mode 100644 index 00000000000..0aafd0e9f6a --- /dev/null +++ b/externs/isoboxer.js @@ -0,0 +1,94 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Externs for codem-isoboxer library. + * @externs + */ + + +/** + * @typedef {{ + * Utils: !ISOBoxerUtils, + * parseBuffer: function(!(ArrayBuffer|ArrayBufferView)):!ISOBoxer, + * createBox: function(string, !ISOBoxer, boolean=):!ISOBoxer, + * createFullBox: function(string, !ISOBoxer, ?ISOBoxer=):!ISOBoxer, + * addBoxProcessor: function(string, function()):!ISOBoxer, + * createFile: function():!ISOBoxer, + * write: function():!ArrayBuffer, + * fetch: function(!ArrayBuffer):!ISOBoxer + * }} + * @property {!ISOBoxerUtils} Utils + * @property {function(!(ArrayBuffer|ArrayBufferView)):!ISOBoxer} parseBuffer + * @property {function(string, !ISOBoxer, boolean=):!ISOBoxer} createBox + * @property {function(string, !ISOBoxer, ?ISOBoxer=):!ISOBoxer} createFullBox + * @property {function(string, function()):!ISOBoxer} addBoxProcessor + * @property {function():!ISOBoxer} createFile + * @property {function():!ArrayBuffer} write + * @property {function(!ArrayBuffer):!ISOBoxer} fetch + * @const + */ +var ISOBoxer; + + +/** + * @typedef {{ + * appendBox: function(!ISOBoxer, !ISOBoxer):!ISOBox + * }} + * @const + */ +var ISOBoxerUtils; + + +/** + * @typedef {{ + * type: string, + * size: number, + * _parent: ISOBox, + * boxes: !Array., + * entry: !Array., + * version: !number, + * flags: !number, + * sample_count: !number, + * default_sample_info_size: !number, + * entry_count: !number, + * _procFullBox: function(), + * _procField: function(!string, !string, !number), + * _procFieldArray: function(!string, !number, !string, !number), + * _procEntries: function(!string, !number, !function(!ISOEntry)), + * _procEntryField: function(!ISOBox, !string, !string, !number), + * _procSubEntries: function(!ISOBox, !string, !number, !function(!ISOEntry)) + * }} + * @property {string} type + * @property {number} size + * @property {ISOBox} _parent + * @property {!Array.} boxes + * @property {!Array.} entry + * @property {!number} version + * @property {!number} flags + * @property {!number} sample_count + * @property {!number} default_sample_info_sizes + * @property {!number} entry_count + * @property {function()} _procFullBox + * @property {function(!string, !string, !number)} _procField + * @property {function(!string, !number, !string, !number)} _procFieldArray + * @property {function(!string, !number, !function(!ISOEntry))} _procEntries + * @property {function(!ISOBox, !string, !string, !number)} _procEntryField + * @property {function(!ISOBox, !string, !number, !function(!ISOEntry))} + * _procSubEntries + * @const + */ +var ISOBox; + + +/** + * @typedef {{ + * NumberOfEntries: number + * }} + * @property {number} NumberOfEntries + * @const + */ +var ISOEntry; diff --git a/externs/shaka/manifest.js b/externs/shaka/manifest.js index 4f8d30ed50b..390d36b965c 100644 --- a/externs/shaka/manifest.js +++ b/externs/shaka/manifest.js @@ -325,7 +325,8 @@ shaka.extern.FetchCryptoKeysFunction; * tilesLayout: (string|undefined), * matchedStreams: * (!Array.|!Array.| - * undefined) + * undefined), + * mssPrivateData: (shaka.extern.MssPrivateData|undefined) * }} * * @description @@ -438,7 +439,35 @@ shaka.extern.FetchCryptoKeysFunction; * @property {(!Array.|!Array.| * undefined)} matchedStreams * The streams in all periods which match the stream. Used for Dash. + * @property {(shaka.extern.MssPrivateData|undefined)} mssPrivateData + * Microsoft Smooth Streaming only.
+ * Private MSS data that is necessary to be able to do transmuxing.. * * @exportDoc */ shaka.extern.Stream; + + +/** + * @typedef {{ + * duration: number, + * timescale: number, + * codecPrivateData: string + * }} + * + * @description + * Private MSS data that is necessary to be able to do transmuxing. + * + * @property {number} duration + * Required.
+ * MSS Stream duration. + * @property {number} timescale + * Required.
+ * MSS timescale. + * @property {?string} codecPrivateData + * Required.
+ * MSS codecPrivateData. + * + * @exportDoc + */ +shaka.extern.MssPrivateData; diff --git a/externs/shaka/player.js b/externs/shaka/player.js index 5c901660c9c..971ad27f7ee 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -892,6 +892,29 @@ shaka.extern.DashManifestConfiguration; shaka.extern.HlsManifestConfiguration; +/** + * @typedef {{ + * manifestPreprocessor: function(!Element), + * sequenceMode: boolean, + * keySystemsBySystemId: !Object. + * }} + * + * @property {function(!Element)} manifestPreprocessor + * Called immediately after the MSS manifest has been parsed into an + * XMLDocument. Provides a way for applications to perform efficient + * preprocessing of the manifest. + * @property {boolean} sequenceMode + * If true, the media segments are appended to the SourceBuffer in + * "sequence mode" (ignoring their internal timestamps). + * Defaults to false. + * @property {Object.} keySystemsBySystemId + * A map of system id to key system name. Defaults to default key systems + * mapping handled by Shaka. + * @exportDoc + */ +shaka.extern.MssManifestConfiguration; + + /** * @typedef {{ * retryParameters: shaka.extern.RetryParameters, @@ -903,7 +926,8 @@ shaka.extern.HlsManifestConfiguration; * defaultPresentationDelay: number, * segmentRelativeVttTiming: boolean, * dash: shaka.extern.DashManifestConfiguration, - * hls: shaka.extern.HlsManifestConfiguration + * hls: shaka.extern.HlsManifestConfiguration, + * mss: shaka.extern.MssManifestConfiguration * }} * * @property {shaka.extern.RetryParameters} retryParameters @@ -941,6 +965,8 @@ shaka.extern.HlsManifestConfiguration; * Advanced parameters used by the DASH manifest parser. * @property {shaka.extern.HlsManifestConfiguration} hls * Advanced parameters used by the HLS manifest parser. + * @property {shaka.extern.MssManifestConfiguration} mss + * Advanced parameters used by the MSS manifest parser. * * @exportDoc */ diff --git a/externs/shaka/transmuxer.js b/externs/shaka/transmuxer.js index 091581754d2..c61c434b8a4 100644 --- a/externs/shaka/transmuxer.js +++ b/externs/shaka/transmuxer.js @@ -42,9 +42,12 @@ shaka.extern.Transmuxer = class { /** * Transmux a input data to MP4. * @param {BufferSource} data + * @param {shaka.extern.Stream} stream + * @param {?shaka.media.SegmentReference} reference The segment reference, or + * null for init segments * @return {!Promise.} */ - transmux(data) {} + transmux(data, stream, reference) {} }; diff --git a/karma.conf.js b/karma.conf.js index ffddd3bb4ae..de08c470fa6 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -164,6 +164,9 @@ module.exports = (config) => { // muxjs module next 'node_modules/mux.js/dist/mux.min.js', + // codem-isoboxer module next + 'node_modules/codem-isoboxer/dist/iso_boxer.min.js', + // EME encryption scheme polyfill, compiled into Shaka Player, but outside // of the Closure deps system, so not in shaka-player.uncompiled.js. This // is specifically the compiled, minified, cross-browser build of it. diff --git a/lib/dependencies/all.js b/lib/dependencies/all.js index faab54c306d..cf12fa8b856 100644 --- a/lib/dependencies/all.js +++ b/lib/dependencies/all.js @@ -41,6 +41,12 @@ shaka.dependencies = class { return /** @type {?muxjs} */ (shaka.dependencies.dependencies_.get( shaka.dependencies.Allowed.muxjs)()); } + + /** @return {?ISOBoxer} */ + static isoBoxer() { + return /** @type {?ISOBoxer} */ (shaka.dependencies.dependencies_.get( + shaka.dependencies.Allowed.ISOBoxer)()); + } }; /** @@ -49,6 +55,7 @@ shaka.dependencies = class { */ shaka.dependencies.Allowed = { muxjs: 'muxjs', + ISOBoxer: 'ISOBoxer', }; /** @@ -59,4 +66,5 @@ shaka.dependencies.Allowed = { */ shaka.dependencies.dependencies_ = new Map([ [shaka.dependencies.Allowed.muxjs, () => window.muxjs], + [shaka.dependencies.Allowed.ISOBoxer, () => window.ISOBoxer], ]); diff --git a/lib/media/manifest_parser.js b/lib/media/manifest_parser.js index 94d2f2b8945..7f48da48ad9 100644 --- a/lib/media/manifest_parser.js +++ b/lib/media/manifest_parser.js @@ -262,6 +262,12 @@ shaka.media.ManifestParser.HLS = 'HLS'; shaka.media.ManifestParser.DASH = 'DASH'; +/** + * @const {string} + */ +shaka.media.ManifestParser.MSS = 'MSS'; + + /** * @const {string} */ diff --git a/lib/media/media_source_engine.js b/lib/media/media_source_engine.js index bab68e038e4..0f65a890c17 100644 --- a/lib/media/media_source_engine.js +++ b/lib/media/media_source_engine.js @@ -573,6 +573,7 @@ shaka.media.MediaSourceEngine = class { * @param {!BufferSource} data * @param {?shaka.media.SegmentReference} reference The segment reference * we are appending, or null for init segments + * @param {shaka.extern.Stream} stream * @param {?boolean} hasClosedCaptions True if the buffer contains CEA closed * captions * @param {boolean=} seeked True if we just seeked @@ -581,7 +582,7 @@ shaka.media.MediaSourceEngine = class { * @return {!Promise} */ async appendBuffer( - contentType, data, reference, hasClosedCaptions, seeked = false, + contentType, data, reference, stream, hasClosedCaptions, seeked = false, adaptation = false) { const ContentType = shaka.util.ManifestParserUtils.ContentType; @@ -730,7 +731,8 @@ shaka.media.MediaSourceEngine = class { } if (this.transmuxers_[contentType]) { - data = await this.transmuxers_[contentType].transmux(data); + data = await this.transmuxers_[contentType].transmux( + data, stream, reference); } data = this.workAroundBrokenPlatforms_( diff --git a/lib/media/segment_reference.js b/lib/media/segment_reference.js index cfde258c8f1..1100f2543de 100644 --- a/lib/media/segment_reference.js +++ b/lib/media/segment_reference.js @@ -10,6 +10,7 @@ goog.provide('shaka.media.SegmentReference'); goog.require('goog.asserts'); goog.require('shaka.log'); goog.require('shaka.util.ArrayUtils'); +goog.require('shaka.util.BufferUtils'); /** @@ -30,8 +31,10 @@ shaka.media.InitSegmentReference = class { * @param {null|shaka.extern.MediaQualityInfo=} mediaQuality Information about * the quality of the media associated with this init segment. * @param {number=} timescale + * @param {(null|BufferSource)=} segmentData */ - constructor(uris, startByte, endByte, mediaQuality = null, timescale) { + constructor(uris, startByte, endByte, mediaQuality = null, timescale, + segmentData = null) { /** @type {function():!Array.} */ this.getUris = uris; @@ -46,6 +49,9 @@ shaka.media.InitSegmentReference = class { /** @type {number|undefined} */ this.timescale = timescale; + + /** @type {BufferSource|null} */ + this.segmentData = segmentData; } /** @@ -93,6 +99,16 @@ shaka.media.InitSegmentReference = class { return this.mediaQuality; } + /** + * Return the segment data. + * + * @return {?BufferSource} + */ + getSegmentData() { + return this.segmentData; + } + + /** * Check if two initSegmentReference have all the same values. * @param {?shaka.media.InitSegmentReference} reference1 @@ -101,12 +117,15 @@ shaka.media.InitSegmentReference = class { */ static equal(reference1, reference2) { const ArrayUtils = shaka.util.ArrayUtils; + const BufferUtils = shaka.util.BufferUtils; if (!reference1 || !reference2) { return reference1 == reference2; } else { return reference1.getStartByte() == reference2.getStartByte() && reference1.getEndByte() == reference2.getEndByte() && - ArrayUtils.equal(reference1.getUris(), reference2.getUris()); + ArrayUtils.equal(reference1.getUris(), reference2.getUris()) && + BufferUtils.equal(reference1.getSegmentData(), + reference2.getSegmentData()); } } }; diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index d454f0f99d6..b93b7ec5248 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -1686,11 +1686,18 @@ shaka.media.StreamingEngine = class { if (reference.initSegmentReference) { shaka.log.v1(logPrefix, 'fetching init segment'); - const fetchInit = - this.fetch_(mediaState, reference.initSegmentReference); + let fetchInit; + if (!reference.initSegmentReference.getSegmentData()) { + fetchInit = + this.fetch_(mediaState, reference.initSegmentReference); + } const append = async () => { try { - const initSegment = await fetchInit; + let initSegment = + reference.initSegmentReference.getSegmentData(); + if (!initSegment) { + initSegment = await fetchInit; + } this.destroyer_.ensureNotDestroyed(); const parser = new shaka.util.Mp4Parser(); @@ -1710,7 +1717,7 @@ shaka.media.StreamingEngine = class { mediaState.type, initSegment); await this.playerInterface_.mediaSourceEngine.appendBuffer( mediaState.type, initSegment, /* reference= */ null, - hasClosedCaptions); + mediaState.stream, hasClosedCaptions); } catch (error) { mediaState.lastInitSegmentReference = null; throw error; @@ -1804,6 +1811,7 @@ shaka.media.StreamingEngine = class { mediaState.type, segment, reference, + stream, hasClosedCaptions, seeked, adaptation); diff --git a/lib/mss/content_protection.js b/lib/mss/content_protection.js new file mode 100644 index 00000000000..9d2e7203164 --- /dev/null +++ b/lib/mss/content_protection.js @@ -0,0 +1,337 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.mss.ContentProtection'); + +goog.require('shaka.log'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.ManifestParserUtils'); +goog.require('shaka.util.Pssh'); +goog.require('shaka.util.StringUtils'); +goog.require('shaka.util.Uint8ArrayUtils'); +goog.require('shaka.util.XmlUtils'); + + +/** + * @summary A set of functions for parsing and interpreting Protection + * elements. + */ +shaka.mss.ContentProtection = class { + /** + * Parses info from the Protection elements. + * + * @param {!Array.} elems + * @param {!Object.} keySystemsBySystemId + * @return {!Array.} + */ + static parseFromProtection(elems, keySystemsBySystemId) { + const ContentProtection = shaka.mss.ContentProtection; + const XmlUtils = shaka.util.XmlUtils; + + /** @type {!Array.} */ + let protectionHeader = []; + for (const elem of elems) { + protectionHeader = protectionHeader.concat( + XmlUtils.findChildren(elem, 'ProtectionHeader')); + } + if (!protectionHeader.length) { + return []; + } + return ContentProtection.convertElements_( + protectionHeader, keySystemsBySystemId); + } + + /** + * Parses an Array buffer starting at byteOffset for PlayReady Object Records. + * Each PRO Record is preceded by its PlayReady Record type and length in + * bytes. + * + * PlayReady Object Record format: https://goo.gl/FTcu46 + * + * @param {!DataView} view + * @param {number} byteOffset + * @return {!Array.} + * @private + */ + static parseMsProRecords_(view, byteOffset) { + const records = []; + + while (byteOffset < view.byteLength - 1) { + const type = view.getUint16(byteOffset, true); + byteOffset += 2; + + const byteLength = view.getUint16(byteOffset, true); + byteOffset += 2; + + if ((byteLength & 1) != 0 || byteLength + byteOffset > view.byteLength) { + shaka.log.warning('Malformed MS PRO object'); + return []; + } + + const recordValue = shaka.util.BufferUtils.toUint8( + view, byteOffset, byteLength); + records.push({ + type: type, + value: recordValue, + }); + + byteOffset += byteLength; + } + + return records; + } + + /** + * Parses a buffer for PlayReady Objects. The data + * should contain a 32-bit integer indicating the length of + * the PRO in bytes. Following that, a 16-bit integer for + * the number of PlayReady Object Records in the PRO. Lastly, + * a byte array of the PRO Records themselves. + * + * PlayReady Object format: https://goo.gl/W8yAN4 + * + * @param {BufferSource} data + * @return {!Array.} + * @private + */ + static parseMsPro_(data) { + let byteOffset = 0; + const view = shaka.util.BufferUtils.toDataView(data); + + // First 4 bytes is the PRO length (DWORD) + const byteLength = view.getUint32(byteOffset, /* littleEndian= */ true); + byteOffset += 4; + + if (byteLength != data.byteLength) { + // Malformed PRO + shaka.log.warning('PlayReady Object with invalid length encountered.'); + return []; + } + + // Skip PRO Record count (WORD) + byteOffset += 2; + + // Rest of the data contains the PRO Records + const ContentProtection = shaka.mss.ContentProtection; + return ContentProtection.parseMsProRecords_(view, byteOffset); + } + + /** + * PlayReady Header format: https://goo.gl/dBzxNA + * + * @param {!Element} xml + * @return {string} + * @private + */ + static getLaurl_(xml) { + // LA_URL element is optional and no more than one is + // allowed inside the DATA element. Only absolute URLs are allowed. + // If the LA_URL element exists, it must not be empty. + for (const elem of xml.getElementsByTagName('DATA')) { + for (const child of elem.childNodes) { + if (child instanceof Element && child.tagName == 'LA_URL') { + return child.textContent; + } + } + } + + // Not found + return ''; + } + + /** + * Gets a PlayReady license URL from a protection element + * containing a PlayReady Header Object + * + * @param {!Element} element + * @return {string} + */ + static getPlayReadyLicenseUrl(element) { + const ContentProtection = shaka.mss.ContentProtection; + const PLAYREADY_RECORD_TYPES = ContentProtection.PLAYREADY_RECORD_TYPES; + + const bytes = shaka.util.Uint8ArrayUtils.fromBase64(element.textContent); + const records = ContentProtection.parseMsPro_(bytes); + const record = records.filter((record) => { + return record.type === PLAYREADY_RECORD_TYPES.RIGHTS_MANAGEMENT; + })[0]; + + if (!record) { + return ''; + } + + const xml = shaka.util.StringUtils.fromUTF16(record.value, true); + const rootElement = shaka.util.XmlUtils.parseXmlString(xml, 'WRMHEADER'); + if (!rootElement) { + return ''; + } + + return ContentProtection.getLaurl_(rootElement); + } + + /** + * PlayReady Header format: https://goo.gl/dBzxNA + * + * @param {!Element} xml + * @return {?string} + * @private + */ + static getKID_(xml) { + // KID element is optional and no more than one is + // allowed inside the DATA element. + for (const elem of xml.getElementsByTagName('DATA')) { + for (const child of elem.childNodes) { + if (child instanceof Element && child.tagName == 'KID') { + return child.textContent; + } + } + } + + // Not found + return null; + } + + /** + * Gets a PlayReady KID from a protection element + * containing a PlayReady Header Object + * + * @param {!Element} element + * @return {?string} + */ + static getPlayReadyKID(element) { + const ContentProtection = shaka.mss.ContentProtection; + const PLAYREADY_RECORD_TYPES = ContentProtection.PLAYREADY_RECORD_TYPES; + + const bytes = shaka.util.Uint8ArrayUtils.fromBase64(element.textContent); + const records = ContentProtection.parseMsPro_(bytes); + const record = records.filter((record) => { + return record.type === PLAYREADY_RECORD_TYPES.RIGHTS_MANAGEMENT; + })[0]; + + if (!record) { + return null; + } + + const xml = shaka.util.StringUtils.fromUTF16(record.value, true); + const rootElement = shaka.util.XmlUtils.parseXmlString(xml, 'WRMHEADER'); + if (!rootElement) { + return null; + } + + return ContentProtection.getKID_(rootElement); + } + + /** + * Gets a initData from a protection element. + * + * @param {!Element} element + * @param {string} systemID + * @param {?string} keyId + * @return {?Array.} + * @private + */ + static getInitDataFromPro_(element, systemID, keyId) { + const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; + const data = Uint8ArrayUtils.fromBase64(element.textContent); + const systemId = Uint8ArrayUtils.fromHex(systemID.replace(/-/g, '')); + const keyIds = new Set(); + const psshVersion = 0; + const pssh = + shaka.util.Pssh.createPssh(data, systemId, keyIds, psshVersion); + return [ + { + initData: pssh, + initDataType: 'cenc', + keyId: keyId, + }, + ]; + } + + /** + * Creates DrmInfo objects from the given element. + * + * @param {!Array.} elements + * @param {!Object.} keySystemsBySystemId + * @return {!Array.} + * @private + */ + static convertElements_(elements, keySystemsBySystemId) { + const ContentProtection = shaka.mss.ContentProtection; + const ManifestParserUtils = shaka.util.ManifestParserUtils; + const licenseUrlParsers = ContentProtection.licenseUrlParsers_; + + /** @type {!Array.} */ + const out = []; + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const systemID = element.getAttribute('SystemID'); + const keySystem = keySystemsBySystemId[systemID]; + if (keySystem) { + const KID = ContentProtection.getPlayReadyKID(element); + const initData = ContentProtection.getInitDataFromPro_( + element, systemID, KID); + + const info = ManifestParserUtils.createDrmInfo(keySystem, initData); + if (KID) { + info.keyIds.add(KID); + } + + const licenseParser = licenseUrlParsers.get(keySystem); + if (licenseParser) { + info.licenseServerUri = licenseParser(element); + } + + out.push(info); + } + } + + return out; + } +}; + +/** + * @typedef {{ + * type: number, + * value: !Uint8Array + * }} + * + * @description + * The parsed result of a PlayReady object record. + * + * @property {number} type + * Type of data stored in the record. + * @property {!Uint8Array} value + * Record content. + */ +shaka.mss.ContentProtection.PlayReadyRecord; + +/** + * Enum for PlayReady record types. + * @enum {number} + */ +shaka.mss.ContentProtection.PLAYREADY_RECORD_TYPES = { + RIGHTS_MANAGEMENT: 0x001, + RESERVED: 0x002, + EMBEDDED_LICENSE: 0x003, +}; + +/** + * A map of key system name to license server url parser. + * + * @const {!Map.} + * @private + */ +shaka.mss.ContentProtection.licenseUrlParsers_ = new Map() + .set('com.microsoft.playready', + shaka.mss.ContentProtection.getPlayReadyLicenseUrl) + .set('com.microsoft.playready.recommendation', + shaka.mss.ContentProtection.getPlayReadyLicenseUrl) + .set('com.microsoft.playready.software', + shaka.mss.ContentProtection.getPlayReadyLicenseUrl) + .set('com.microsoft.playready.hardware', + shaka.mss.ContentProtection.getPlayReadyLicenseUrl); + diff --git a/lib/mss/mss_parser.js b/lib/mss/mss_parser.js new file mode 100644 index 00000000000..9babb80ee1b --- /dev/null +++ b/lib/mss/mss_parser.js @@ -0,0 +1,967 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.mss.MssParser'); + +goog.require('goog.asserts'); +goog.require('shaka.abr.Ewma'); +goog.require('shaka.log'); +goog.require('shaka.media.InitSegmentReference'); +goog.require('shaka.media.ManifestParser'); +goog.require('shaka.media.PresentationTimeline'); +goog.require('shaka.media.SegmentIndex'); +goog.require('shaka.media.SegmentReference'); +goog.require('shaka.mss.ContentProtection'); +goog.require('shaka.mss.MssUtils'); +goog.require('shaka.net.NetworkingEngine'); +goog.require('shaka.util.CmcdManager'); +goog.require('shaka.util.Error'); +goog.require('shaka.util.ManifestParserUtils'); +goog.require('shaka.util.OperationManager'); +goog.require('shaka.util.Timer'); +goog.require('shaka.util.XmlUtils'); +goog.require('shaka.dependencies'); + + +/** + * Creates a new MSS parser. + * + * @implements {shaka.extern.ManifestParser} + * @export + */ +shaka.mss.MssParser = class { + /** Creates a new MSS parser. */ + constructor() { + /** @private {?shaka.extern.ManifestConfiguration} */ + this.config_ = null; + + /** @private {?shaka.extern.ManifestParser.PlayerInterface} */ + this.playerInterface_ = null; + + /** @private {!Array.} */ + this.manifestUris_ = []; + + /** @private {?shaka.extern.Manifest} */ + this.manifest_ = null; + + /** @private {number} */ + this.globalId_ = 1; + + /** + * The update period in seconds, or 0 for no updates. + * @private {number} + */ + this.updatePeriod_ = 0; + + /** + * An ewma that tracks how long updates take. + * This is to mitigate issues caused by slow parsing on embedded devices. + * @private {!shaka.abr.Ewma} + */ + this.averageUpdateDuration_ = new shaka.abr.Ewma(5); + + /** @private {shaka.util.Timer} */ + this.updateTimer_ = new shaka.util.Timer(() => { + this.onUpdate_(); + }); + + /** @private {!shaka.util.OperationManager} */ + this.operationManager_ = new shaka.util.OperationManager(); + + /** + * @private {!Map.} + */ + this.initSegmentDataByStreamId_ = new Map(); + } + + /** + * @override + * @exportInterface + */ + configure(config) { + goog.asserts.assert(config.mss != null, + 'MssManifestConfiguration should not be null!'); + + this.config_ = config; + } + + /** + * @override + * @exportInterface + */ + async start(uri, playerInterface) { + goog.asserts.assert(this.config_, 'Must call configure() before start()!'); + this.manifestUris_ = [uri]; + this.playerInterface_ = playerInterface; + + await this.requestManifest_(); + + if (this.playerInterface_) { + this.setUpdateTimer_(); + } + + // Make sure that the parser has not been destroyed. + if (!this.playerInterface_) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.PLAYER, + shaka.util.Error.Code.OPERATION_ABORTED); + } + + goog.asserts.assert(this.manifest_, 'Manifest should be non-null!'); + return this.manifest_; + } + + /** + * Called when the update timer ticks. + * + * @return {!Promise} + * @private + */ + async onUpdate_() { + goog.asserts.assert(this.updatePeriod_ >= 0, + 'There should be an update period'); + + shaka.log.info('Updating manifest...'); + + try { + await this.requestManifest_(); + } catch (error) { + goog.asserts.assert(error instanceof shaka.util.Error, + 'Should only receive a Shaka error'); + + // Try updating again, but ensure we haven't been destroyed. + if (this.playerInterface_) { + // We will retry updating, so override the severity of the error. + error.severity = shaka.util.Error.Severity.RECOVERABLE; + this.playerInterface_.onError(error); + } + } + + // Detect a call to stop() + if (!this.playerInterface_) { + return; + } + + this.setUpdateTimer_(); + } + + /** + * Sets the update timer. Does nothing if the manifest is not live. + * + * @private + */ + setUpdateTimer_() { + if (this.updatePeriod_ <= 0) { + return; + } + + const finalDelay = Math.max( + shaka.mss.MssParser.MIN_UPDATE_PERIOD_, + this.updatePeriod_, + this.averageUpdateDuration_.getEstimate()); + + // We do not run the timer as repeating because part of update is async and + // we need schedule the update after it finished. + this.updateTimer_.tickAfter(/* seconds= */ finalDelay); + } + + /** + * @override + * @exportInterface + */ + stop() { + this.playerInterface_ = null; + this.config_ = null; + this.manifestUris_ = []; + this.manifest_ = null; + + if (this.updateTimer_ != null) { + this.updateTimer_.stop(); + this.updateTimer_ = null; + } + + this.initSegmentDataByStreamId_.clear(); + + return this.operationManager_.destroy(); + } + + /** + * @override + * @exportInterface + */ + async update() { + try { + await this.requestManifest_(); + } catch (error) { + if (!this.playerInterface_ || !error) { + return; + } + goog.asserts.assert(error instanceof shaka.util.Error, 'Bad error type'); + this.playerInterface_.onError(error); + } + } + + /** + * @override + * @exportInterface + */ + onExpirationUpdated(sessionId, expiration) { + // No-op + } + + /** + * Makes a network request for the manifest and parses the resulting data. + * + * @return {!Promise.} Resolves with the time it took, in seconds, to + * fulfill the request and parse the data. + * @private + */ + async requestManifest_() { + const requestType = shaka.net.NetworkingEngine.RequestType.MANIFEST; + const request = shaka.net.NetworkingEngine.makeRequest( + this.manifestUris_, this.config_.retryParameters); + const networkingEngine = this.playerInterface_.networkingEngine; + + const format = shaka.util.CmcdManager.StreamingFormat.SMOOTH; + this.playerInterface_.modifyManifestRequest(request, {format: format}); + + const startTime = Date.now(); + const operation = networkingEngine.request(requestType, request); + this.operationManager_.manage(operation); + + const response = await operation.promise; + + // Detect calls to stop(). + if (!this.playerInterface_) { + return 0; + } + + // For redirections add the response uri to the first entry in the + // Manifest Uris array. + if (response.uri && !this.manifestUris_.includes(response.uri)) { + this.manifestUris_.unshift(response.uri); + } + + // This may throw, but it will result in a failed promise. + this.parseManifest_(response.data, response.uri); + // Keep track of how long the longest manifest update took. + const endTime = Date.now(); + const updateDuration = (endTime - startTime) / 1000.0; + this.averageUpdateDuration_.sample(1, updateDuration); + + // Let the caller know how long this update took. + return updateDuration; + } + + /** + * Parses the manifest XML. This also handles updates and will update the + * stored manifest. + * + * @param {BufferSource} data + * @param {string} finalManifestUri The final manifest URI, which may + * differ from this.manifestUri_ if there has been a redirect. + * @return {!Promise} + * @private + */ + parseManifest_(data, finalManifestUri) { + const mss = shaka.util.XmlUtils.parseXml(data, 'SmoothStreamingMedia'); + if (!mss) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.MSS_INVALID_XML, + finalManifestUri); + } + this.processManifest_(mss, finalManifestUri); + return Promise.resolve(); + } + + + /** + * Takes a formatted MSS and converts it into a manifest. + * + * @param {!Element} mss + * @param {string} finalManifestUri The final manifest URI, which may + * differ from this.manifestUri_ if there has been a redirect. + * @private + */ + processManifest_(mss, finalManifestUri) { + const XmlUtils = shaka.util.XmlUtils; + + const manifestPreprocessor = this.config_.mss.manifestPreprocessor; + if (manifestPreprocessor) { + manifestPreprocessor(mss); + } + + /** @type {!shaka.media.PresentationTimeline} */ + let presentationTimeline; + if (this.manifest_) { + presentationTimeline = this.manifest_.presentationTimeline; + } else { + presentationTimeline = new shaka.media.PresentationTimeline( + /* presentationStartTime= */ null, /* delay= */ 0); + } + + const isLive = XmlUtils.parseAttr(mss, 'IsLive', + XmlUtils.parseBoolean, /* defaultValue= */ false); + + if (isLive) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.MSS_LIVE_CONTENT_NOT_SUPPORTED); + } + + presentationTimeline.setStatic(!isLive); + + const timescale = XmlUtils.parseAttr(mss, 'TimeScale', + XmlUtils.parseNonNegativeInt, shaka.mss.MssParser.DEFAULT_TIME_SCALE_); + goog.asserts.assert(timescale && timescale >= 0, + 'Timescale must be defined!'); + + let dvrWindowLength = XmlUtils.parseAttr(mss, 'DVRWindowLength', + XmlUtils.parseNonNegativeInt); + // If the DVRWindowLength field is omitted for a live presentation or set + // to 0, the DVR window is effectively infinite + if (isLive && (dvrWindowLength === 0 || isNaN(dvrWindowLength))) { + dvrWindowLength = Infinity; + } + // Star-over + const canSeek = XmlUtils.parseAttr(mss, 'CanSeek', + XmlUtils.parseBoolean, /* defaultValue= */ false); + if (dvrWindowLength === 0 && canSeek) { + dvrWindowLength = Infinity; + } + + let segmentAvailabilityDuration = null; + if (dvrWindowLength && dvrWindowLength > 0) { + segmentAvailabilityDuration = dvrWindowLength / timescale; + } + + // If it's live, we check for an override. + if (isLive && !isNaN(this.config_.availabilityWindowOverride)) { + segmentAvailabilityDuration = this.config_.availabilityWindowOverride; + } + + // If it's null, that means segments are always available. This is always + // the case for VOD, and sometimes the case for live. + if (segmentAvailabilityDuration == null) { + segmentAvailabilityDuration = Infinity; + } + + presentationTimeline.setSegmentAvailabilityDuration( + segmentAvailabilityDuration); + + const duration = XmlUtils.parseAttr(mss, 'Duration', + XmlUtils.parseNonNegativeInt, Infinity); + goog.asserts.assert(duration && duration >= 0, + 'Duration must be defined!'); + + if (!isLive) { + presentationTimeline.setDuration(duration / timescale); + } + + /** @type {!shaka.mss.MssParser.Context} */ + const context = { + variants: [], + textStreams: [], + timescale: timescale, + duration: duration / timescale, + }; + + this.parseStreamIndexes_(mss, context); + + // These steps are not done on manifest update. + if (!this.manifest_) { + this.manifest_ = { + presentationTimeline: presentationTimeline, + variants: context.variants, + textStreams: context.textStreams, + imageStreams: [], + offlineSessionIds: [], + minBufferTime: 0, + sequenceMode: this.config_.mss.sequenceMode, + type: shaka.media.ManifestParser.MSS, + }; + + // This is the first point where we have a meaningful presentation start + // time, and we need to tell PresentationTimeline that so that it can + // maintain consistency from here on. + presentationTimeline.lockStartTime(); + } else { + // Just update the variants and text streams. + this.manifest_.variants = context.variants; + this.manifest_.textStreams = context.textStreams; + + // Re-filter the manifest. This will check any configured restrictions on + // new variants, and will pass any new init data to DrmEngine to ensure + // that key rotation works correctly. + this.playerInterface_.filter(this.manifest_); + } + } + + /** + * @param {!Element} mss + * @param {!shaka.mss.MssParser.Context} context + * @private + */ + parseStreamIndexes_(mss, context) { + const ContentProtection = shaka.mss.ContentProtection; + const XmlUtils = shaka.util.XmlUtils; + const ContentType = shaka.util.ManifestParserUtils.ContentType; + + const protectionElems = XmlUtils.findChildren(mss, 'Protection'); + const drmInfos = ContentProtection.parseFromProtection( + protectionElems, this.config_.mss.keySystemsBySystemId); + + const audioStreams = []; + const videoStreams = []; + const textStreams = []; + const streamIndexes = XmlUtils.findChildren(mss, 'StreamIndex'); + for (const streamIndex of streamIndexes) { + const qualityLevels = XmlUtils.findChildren(streamIndex, 'QualityLevel'); + const timeline = this.createTimeline( + streamIndex, context.timescale, context.duration); + // For each QualityLevel node, create a stream element + for (const qualityLevel of qualityLevels) { + const stream = this.createStream_( + streamIndex, qualityLevel, timeline, drmInfos, context); + if (!stream) { + // Skip unsupported stream + continue; + } + if (stream.type == ContentType.AUDIO && + !this.config_.disableAudio) { + audioStreams.push(stream); + } else if (stream.type == ContentType.VIDEO && + !this.config_.disableVideo) { + videoStreams.push(stream); + } else if (stream.type == ContentType.TEXT && + !this.config_.disableText) { + textStreams.push(stream); + } + } + } + + const variants = []; + if (!audioStreams.length) { + for (const videoStream of videoStreams) { + const variant = this.createVariant_( + /* audioStream= */ null, videoStream); + variants.push(variant); + } + } else if (!videoStreams.length) { + for (const audioStream of audioStreams) { + const variant = this.createVariant_( + audioStream, /* videoStream= */ null); + variants.push(variant); + } + } else { + for (const audioStream of audioStreams) { + for (const videoStream of videoStreams) { + const variant = this.createVariant_(audioStream, videoStream); + variants.push(variant); + } + } + } + context.variants = variants; + context.textStreams = textStreams; + } + + /** + * @param {!Element} streamIndex + * @param {!Element} qualityLevel + * @param {!Array.} timeline + * @param {!Array.} drmInfos + * @param {!shaka.mss.MssParser.Context} context + * @return {?shaka.extern.Stream} + * @private + */ + createStream_(streamIndex, qualityLevel, timeline, drmInfos, context) { + const XmlUtils = shaka.util.XmlUtils; + const ContentType = shaka.util.ManifestParserUtils.ContentType; + const MssParser = shaka.mss.MssParser; + + const type = streamIndex.getAttribute('Type'); + const isValidType = type === 'audio' || type === 'video' || + type === 'text'; + if (!isValidType) { + shaka.log.alwaysWarn('Ignoring unrecognized type:', type); + return null; + } + + const lang = streamIndex.getAttribute('Language'); + const id = this.globalId_++; + + const bandwidth = XmlUtils.parseAttr( + qualityLevel, 'Bitrate', XmlUtils.parsePositiveInt); + const width = XmlUtils.parseAttr( + qualityLevel, 'MaxWidth', XmlUtils.parsePositiveInt); + const height = XmlUtils.parseAttr( + qualityLevel, 'MaxHeight', XmlUtils.parsePositiveInt); + const channelsCount = XmlUtils.parseAttr( + qualityLevel, 'Channels', XmlUtils.parsePositiveInt); + const audioSamplingRate = XmlUtils.parseAttr( + qualityLevel, 'SamplingRate', XmlUtils.parsePositiveInt); + const codecPrivateData = + qualityLevel.getAttribute('CodecPrivateData'); + + /** @type {!shaka.extern.Stream} */ + const stream = { + id: id, + originalId: streamIndex.getAttribute('Name') || String(id), + createSegmentIndex: () => Promise.resolve(), + closeSegmentIndex: () => Promise.resolve(), + segmentIndex: null, + mimeType: '', + codecs: '', + frameRate: undefined, + pixelAspectRatio: undefined, + bandwidth: bandwidth || 0, + width: width || undefined, + height: height || undefined, + kind: '', + encrypted: drmInfos.length > 0, + drmInfos: drmInfos, + keyIds: new Set(), + language: lang || 'und', + label: '', + type: '', + primary: false, + trickModeVideo: null, + emsgSchemeIdUris: [], + roles: [], + forced: false, + channelsCount: channelsCount, + audioSamplingRate: audioSamplingRate, + spatialAudio: false, + closedCaptions: null, + hdr: undefined, + tilesLayout: undefined, + matchedStreams: [], + mssPrivateData: { + duration: context.duration, + timescale: context.timescale, + codecPrivateData: codecPrivateData, + }, + }; + + const subType = streamIndex.getAttribute('Subtype'); + if (subType) { + const role = MssParser.ROLE_MAPPING_[subType]; + if (role) { + stream.roles.push(role); + } + if (role === 'main') { + stream.primary = true; + } + } + + let fourCCValue = qualityLevel.getAttribute('FourCC'); + + // If FourCC not defined at QualityLevel level, + // then get it from StreamIndex level + if (fourCCValue === null || fourCCValue === '') { + fourCCValue = streamIndex.getAttribute('FourCC'); + } + + // If still not defined (optionnal for audio stream, + // see https://msdn.microsoft.com/en-us/library/ff728116%28v=vs.95%29.aspx), + // then we consider the stream is an audio AAC stream + if (fourCCValue === null || fourCCValue === '') { + if (type === 'audio') { + fourCCValue = 'AAC'; + } else if (type === 'video') { + shaka.log.alwaysWarn('FourCC is not defined whereas it is required ' + + 'for a QualityLevel element for a StreamIndex of type "video"'); + return null; + } + } + + // Check if codec is supported + if (!MssParser.SUPPORTED_CODECS_.includes(fourCCValue.toUpperCase())) { + shaka.log.alwaysWarn('Codec not supported:', fourCCValue); + return null; + } + + switch (type) { + case 'audio': + stream.type = ContentType.AUDIO; + // This mimetype is fake to allow the transmuxing. + stream.mimeType = 'mss/audio/mp4'; + stream.codecs = this.getAACCodec_(qualityLevel, fourCCValue); + break; + case 'video': + stream.type = ContentType.VIDEO; + // This mimetype is fake to allow the transmuxing. + stream.mimeType = 'mss/video/mp4'; + stream.codecs = this.getH264Codec_(qualityLevel); + break; + case 'text': + stream.type = ContentType.TEXT; + stream.mimeType = 'application/mp4'; + if (fourCCValue === 'TTML' || fourCCValue === 'DFXP') { + stream.codecs = 'stpp'; + } + break; + } + + // Lazy-Load the segment index to avoid create all init segment at the + // same time + stream.createSegmentIndex = () => { + let initSegmentData; + if (this.initSegmentDataByStreamId_.has(stream.id)) { + initSegmentData = this.initSegmentDataByStreamId_.get(stream.id); + } else { + initSegmentData = shaka.mss.MssUtils.generateInitSegment(stream); + this.initSegmentDataByStreamId_.set(stream.id, initSegmentData); + } + const initSegmentRef = new shaka.media.InitSegmentReference( + () => [], + /* startByte= */ 0, + /* endByte= */ null, + /* mediaQuality= */ null, + /* timescale= */ undefined, + initSegmentData); + + const segments = this.createSegments_(initSegmentRef, + stream, streamIndex, timeline, context); + + stream.segmentIndex = new shaka.media.SegmentIndex(segments); + return Promise.resolve(); + }; + stream.closeSegmentIndex = () => { + // If we have a segment index, release it. + if (stream.segmentIndex) { + stream.segmentIndex.release(); + stream.segmentIndex = null; + } + }; + + return stream; + } + + /** + * @param {!Element} qualityLevel + * @param {string} fourCCValue + * @return {string} + * @private + */ + getAACCodec_(qualityLevel, fourCCValue) { + const codecPrivateData = qualityLevel.getAttribute('CodecPrivateData'); + let objectType = 0; + + // Chrome problem, in implicit AAC HE definition, so when AACH is detected + // in FourCC set objectType to 5 => strange, it should be 2 + if (fourCCValue === 'AACH') { + objectType = 0x05; + } + if (codecPrivateData === undefined || codecPrivateData === '') { + // AAC Main Low Complexity => object Type = 2 + objectType = 0x02; + if (fourCCValue === 'AACH') { + // High Efficiency AAC Profile = object Type = 5 SBR + objectType = 0x05; + } + } else if (objectType === 0) { + objectType = (parseInt(codecPrivateData.substr(0, 2), 16) & 0xF8) >> 3; + } + + return 'mp4a.40.' + objectType; + } + + /** + * @param {!Element} qualityLevel + * @return {string} + * @private + */ + getH264Codec_(qualityLevel) { + const codecPrivateData = qualityLevel.getAttribute('CodecPrivateData'); + + // Extract from the CodecPrivateData field the hexadecimal representation + // of the following three bytes in the sequence parameter set NAL unit. + // => Find the SPS nal header + const nalHeader = /00000001[0-9]7/.exec(codecPrivateData); + if (!nalHeader.length) { + return ''; + } + // => Find the 6 characters after the SPS nalHeader (if it exists) + const avcoti = codecPrivateData.substr( + codecPrivateData.indexOf(nalHeader[0]) + 10, 6); + + return 'avc1.' + avcoti; + } + + /** + * @param {!shaka.media.InitSegmentReference} initSegmentRef + * @param {!shaka.extern.Stream} stream + * @param {!Element} streamIndex + * @param {!Array.} timeline + * @param {!shaka.mss.MssParser.Context} context + * @return {!Array.} + * @private + */ + createSegments_(initSegmentRef, stream, streamIndex, timeline, context) { + const ManifestParserUtils = shaka.util.ManifestParserUtils; + const url = streamIndex.getAttribute('Url'); + goog.asserts.assert(url, 'Missing URL for segments'); + + const mediaUrl = url.replace('{bitrate}', String(stream.bandwidth)); + + const segments = []; + for (const time of timeline) { + const getUris = () => { + return ManifestParserUtils.resolveUris(this.manifestUris_, + [mediaUrl.replace('{start time}', String(time.unscaledStart))]); + }; + segments.push(new shaka.media.SegmentReference( + time.start, + time.end, + getUris, + /* startByte= */ 0, + /* endByte= */ null, + initSegmentRef, + /* timestampOffset= */ 0, + /* appendWindowStart= */ 0, + /* appendWindowEnd= */ context.duration)); + } + return segments; + } + + /** + * Expands a streamIndex into an array-based timeline. The results are in + * seconds. + * + * @param {!Element} streamIndex + * @param {number} timescale + * @param {number} duration The duration in seconds. + * @return {!Array.} + */ + createTimeline(streamIndex, timescale, duration) { + goog.asserts.assert( + timescale > 0 && timescale < Infinity, + 'timescale must be a positive, finite integer'); + goog.asserts.assert( + duration > 0, 'duration must be a positive integer'); + + const XmlUtils = shaka.util.XmlUtils; + + const timePoints = XmlUtils.findChildren(streamIndex, 'c'); + + /** @type {!Array.} */ + const timeline = []; + let lastEndTime = 0; + + for (let i = 0; i < timePoints.length; ++i) { + const timePoint = timePoints[i]; + const next = timePoints[i + 1]; + const t = + XmlUtils.parseAttr(timePoint, 't', XmlUtils.parseNonNegativeInt); + const d = + XmlUtils.parseAttr(timePoint, 'd', XmlUtils.parseNonNegativeInt); + const r = XmlUtils.parseAttr(timePoint, 'r', XmlUtils.parseInt); + + if (!d) { + shaka.log.warning( + '"c" element must have a duration:', + 'ignoring the remaining "c" elements.', timePoint); + return timeline; + } + + let startTime = t != null ? t : lastEndTime; + + let repeat = r || 0; + if (repeat < 0) { + if (next) { + const nextStartTime = + XmlUtils.parseAttr(next, 't', XmlUtils.parseNonNegativeInt); + if (nextStartTime == null) { + shaka.log.warning( + 'An "c" element cannot have a negative repeat', + 'if the next "c" element does not have a valid start time:', + 'ignoring the remaining "c" elements.', timePoint); + return timeline; + } else if (startTime >= nextStartTime) { + shaka.log.warning( + 'An "c" element cannot have a negative repeatif its start ', + 'time exceeds the next "c" element\'s start time:', + 'ignoring the remaining "c" elements.', timePoint); + return timeline; + } + repeat = Math.ceil((nextStartTime - startTime) / d) - 1; + } else { + if (duration == Infinity) { + // The MSS spec. actually allows the last "c" element to have a + // negative repeat value even when it has an infinite + // duration. No one uses this feature and no one ever should, + // ever. + shaka.log.warning( + 'The last "c" element cannot have a negative repeat', + 'if the Period has an infinite duration:', + 'ignoring the last "c" element.', timePoint); + return timeline; + } else if (startTime / timescale >= duration) { + shaka.log.warning( + 'The last "c" element cannot have a negative repeat', + 'if its start time exceeds the duration:', + 'igoring the last "c" element.', timePoint); + return timeline; + } + repeat = Math.ceil((duration * timescale - startTime) / d) - 1; + } + } + + for (let j = 0; j <= repeat; ++j) { + const endTime = startTime + d; + const item = { + start: startTime / timescale, + end: endTime / timescale, + unscaledStart: startTime, + }; + timeline.push(item); + + startTime = endTime; + lastEndTime = endTime; + } + } + + return timeline; + } + + /** + * @param {?shaka.extern.Stream} audioStream + * @param {?shaka.extern.Stream} videoStream + * @return {!shaka.extern.Variant} + * @private + */ + createVariant_(audioStream, videoStream) { + const ContentType = shaka.util.ManifestParserUtils.ContentType; + + goog.asserts.assert(!audioStream || + audioStream.type == ContentType.AUDIO, 'Audio parameter mismatch!'); + goog.asserts.assert(!videoStream || + videoStream.type == ContentType.VIDEO, 'Video parameter mismatch!'); + + let bandwidth = 0; + if (audioStream && audioStream.bandwidth && audioStream.bandwidth > 0) { + bandwidth += audioStream.bandwidth; + } + if (videoStream && videoStream.bandwidth && videoStream.bandwidth > 0) { + bandwidth += videoStream.bandwidth; + } + + return { + id: this.globalId_++, + language: audioStream ? audioStream.language : 'und', + disabledUntilTime: 0, + primary: (!!audioStream && audioStream.primary) || + (!!videoStream && videoStream.primary), + audio: audioStream, + video: videoStream, + bandwidth: bandwidth, + allowedByApplication: true, + allowedByKeySystem: true, + decodingInfos: [], + }; + } +}; + + +/** + * Contains the minimum amount of time, in seconds, between manifest update + * requests. + * + * @private + * @const {number} + */ +shaka.mss.MssParser.MIN_UPDATE_PERIOD_ = 3; + + +/** + * @private + * @const {number} + */ +shaka.mss.MssParser.DEFAULT_TIME_SCALE_ = 10000000; + + +/** + * MSS supported codecs. + * + * @private + * @const {!Array.} + */ +shaka.mss.MssParser.SUPPORTED_CODECS_ = [ + 'AAC', + 'AACL', + 'AACH', + 'AACP', + 'AVC1', + 'H264', + 'TTML', + 'DFXP', +]; + + +/** + * MPEG-DASH Role and accessibility mapping for text tracks according to + * ETSI TS 103 285 v1.1.1 (section 7.1.2) + * + * @const {!Object.} + * @private + */ +shaka.mss.MssParser.ROLE_MAPPING_ = { + 'CAPT': 'main', + 'SUBT': 'alternate', + 'DESC': 'main', +}; + + +/** + * @typedef {{ + * variants: !Array., + * textStreams: !Array., + * timescale: number, + * duration: number + * }} + * + * @property {!Array.} variants + * The presentation's Variants. + * @property {!Array.} textStreams + * The presentation's text streams. + * @property {number} timescale + * The presentation's timescale. + * @property {number} duration + * The presentation's duration. + */ +shaka.mss.MssParser.Context; + + +/** + * @typedef {{ + * start: number, + * unscaledStart: number, + * end: number + * }} + * + * @description + * Defines a time range of a media segment. Times are in seconds. + * + * @property {number} start + * The start time of the range. + * @property {number} unscaledStart + * The start time of the range in representation timescale units. + * @property {number} end + * The end time (exclusive) of the range. + */ +shaka.mss.MssParser.TimeRange; + +if (shaka.dependencies.isoBoxer()) { + shaka.media.ManifestParser.registerParserByExtension( + 'ism', () => new shaka.mss.MssParser()); + shaka.media.ManifestParser.registerParserByMime( + 'application/vnd.ms-sstr+xml', () => new shaka.mss.MssParser()); +} diff --git a/lib/mss/mss_utils.js b/lib/mss/mss_utils.js new file mode 100644 index 00000000000..9f8adf2a3d1 --- /dev/null +++ b/lib/mss/mss_utils.js @@ -0,0 +1,799 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.mss.MssUtils'); + +goog.require('goog.asserts'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.Error'); +goog.require('shaka.util.ManifestParserUtils'); +goog.require('shaka.dependencies'); + + +/** + * @summary MSS processing utility functions. + */ +shaka.mss.MssUtils = class { + /** + * Generate a Init Segment (MP4) for a MSS stream. + * + * @param {shaka.extern.Stream} stream + * @return {!BufferSource} + */ + static generateInitSegment(stream) { + const MssUtils = shaka.mss.MssUtils; + const isoBoxer = shaka.dependencies.isoBoxer(); + goog.asserts.assert(isoBoxer, 'ISOBoxer should be defined.'); + const isoFile = isoBoxer.createFile(); + MssUtils.createFtypBox_(isoBoxer, isoFile); + MssUtils.createMoovBox_(isoBoxer, isoFile, stream); + return shaka.util.BufferUtils.toUint8(isoFile.write()); + } + + /** + * Create ftyp box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} isoFile + * @private + */ + static createFtypBox_(isoBoxer, isoFile) { + const ftyp = isoBoxer.createBox('ftyp', isoFile); + ftyp.major_brand = 'iso6'; + // is an informative integer for the minor version of the major brand + ftyp.minor_version = 1; + // is a list, to the end of the box, of brands isom, iso6 and msdh + ftyp.compatible_brands = []; + // => decimal ASCII value for isom + ftyp.compatible_brands[0] = 'isom'; + // => decimal ASCII value for iso6 + ftyp.compatible_brands[1] = 'iso6'; + // => decimal ASCII value for msdh + ftyp.compatible_brands[2] = 'msdh'; + } + + /** + * Create moov box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} isoFile + * @param {shaka.extern.Stream} stream + * @private + */ + static createMoovBox_(isoBoxer, isoFile, stream) { + const MssUtils = shaka.mss.MssUtils; + const ContentType = shaka.util.ManifestParserUtils.ContentType; + // moov box + const moov = isoBoxer.createBox('moov', isoFile); + // moov/mvhd + MssUtils.createMvhdBox_(isoBoxer, moov, stream); + // moov/trak + const trak = isoBoxer.createBox('trak', moov); + // moov/trak/tkhd + MssUtils.createTkhdBox_(isoBoxer, trak, stream); + // moov/trak/mdia + const mdia = isoBoxer.createBox('mdia', trak); + // moov/trak/mdia/mdhd + MssUtils.createMdhdBox_(isoBoxer, mdia, stream); + // moov/trak/mdia/hdlr + MssUtils.createHdlrBox_(isoBoxer, mdia, stream); + // moov/trak/mdia/minf + const minf = isoBoxer.createBox('minf', mdia); + switch (stream.type) { + case ContentType.VIDEO: + // moov/trak/mdia/minf/vmhd + MssUtils.createVmhdBox_(isoBoxer, minf); + break; + case ContentType.AUDIO: + // moov/trak/mdia/minf/smhd + MssUtils.createSmhdBox_(isoBoxer, minf); + break; + } + // moov/trak/mdia/minf/dinf + const dinf = isoBoxer.createBox('dinf', minf); + // moov/trak/mdia/minf/dinf/dref + MssUtils.createDrefBox_(isoBoxer, dinf); + // moov/trak/mdia/minf/stbl + const stbl = isoBoxer.createBox('stbl', minf); + // Create empty stts, stsc, stco and stsz boxes + // Use data field as for codem-isoboxer unknown boxes for setting + // fields value + // moov/trak/mdia/minf/stbl/stts + const stts = isoBoxer.createFullBox('stts', stbl); + // version = 0, flags = 0, entry_count = 0 + stts._data = [0, 0, 0, 0, 0, 0, 0, 0]; + // moov/trak/mdia/minf/stbl/stsc + const stsc = isoBoxer.createFullBox('stsc', stbl); + // version = 0, flags = 0, entry_count = 0 + stsc._data = [0, 0, 0, 0, 0, 0, 0, 0]; + // moov/trak/mdia/minf/stbl/stco + const stco = isoBoxer.createFullBox('stco', stbl); + // version = 0, flags = 0, entry_count = 0 + stco._data = [0, 0, 0, 0, 0, 0, 0, 0]; + // moov/trak/mdia/minf/stbl/stsz + const stsz = isoBoxer.createFullBox('stsz', stbl); + // version = 0, flags = 0, sample_size = 0, sample_count = 0 + stsz._data = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + // moov/trak/mdia/minf/stbl/stsd + MssUtils.createStsdBox_(isoBoxer, stbl, stream); + // moov/mvex + const mvex = isoBoxer.createBox('mvex', moov); + // moov/mvex/trex + MssUtils.createTrexBox_(isoBoxer, mvex, stream); + if (stream.encrypted) { + MssUtils.createProtectionSystemSpecificHeaderBox_(isoBoxer, moov, stream); + } + } + + /** + * Create mvhd box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} moov + * @param {shaka.extern.Stream} stream + * @private + */ + static createMvhdBox_(isoBoxer, moov, stream) { + const mvhd = isoBoxer.createFullBox('mvhd', moov); + // version = 1 in order to have 64bits duration value + mvhd.version = 1; + // the creation time of the presentation => ignore (set to 0) + mvhd.creation_time = 0; + // the most recent time the presentation was modified => ignore (set to 0) + mvhd.modification_time = 0; + // the time-scale for the entire presentation => 10000000 for MSS + const timescale = stream.mssPrivateData.timescale; + mvhd.timescale = timescale; + // the length of the presentation (in the indicated timescale) + const duration = stream.mssPrivateData.duration; + mvhd.duration = duration === Infinity ? + 0x1FFFFFFFFFFFFF : Math.round(duration * timescale); + // 16.16 number, '1.0' = normal playback + mvhd.rate = 1.0; + // 8.8 number, '1.0' = full volume + mvhd.volume = 1.0; + mvhd.reserved1 = 0; + mvhd.reserved2 = [0x0, 0x0]; + mvhd.matrix = [ + 1, 0, 0, // provides a transformation matrix for the video; + 0, 1, 0, // (u,v,w) are restricted here to (0,0,1) + 0, 0, 16384, + ]; + mvhd.pre_defined = [0, 0, 0, 0, 0, 0]; + // indicates a value to use for the track ID of the next track to be + // added to this presentation + mvhd.next_track_ID = (stream.id + 1) + 1; + } + + /** + * Create tkhd box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} trak + * @param {shaka.extern.Stream} stream + * @private + */ + static createTkhdBox_(isoBoxer, trak, stream) { + const tkhd = isoBoxer.createFullBox('tkhd', trak); + // version = 1 in order to have 64bits duration value + tkhd.version = 1; + // Track_enabled (0x000001): Indicates that the track is enabled + // Track_in_movie (0x000002): Indicates that the track is used in + // the presentation + // Track_in_preview (0x000004): Indicates that the track is used when + // previewing the presentation + tkhd.flags = 0x1 | 0x2 | 0x4; + // the creation time of the presentation => ignore (set to 0) + tkhd.creation_time = 0; + // the most recent time the presentation was modified => ignore (set to 0) + tkhd.modification_time = 0; + // uniquely identifies this track over the entire life-time of this + // presentation + tkhd.track_ID = (stream.id + 1); + tkhd.reserved1 = 0; + // the duration of this track (in the timescale indicated in the Movie + // Header Box) + const duration = stream.mssPrivateData.duration; + const timescale = stream.mssPrivateData.timescale; + tkhd.duration = duration === Infinity ? + 0x1FFFFFFFFFFFFF : Math.round(duration * timescale); + tkhd.reserved2 = [0x0, 0x0]; + // specifies the front-to-back ordering of video tracks; tracks with lower + // numbers are closer to the viewer => 0 since only one video track + tkhd.layer = 0; + // specifies a group or collection of tracks => ignore + tkhd.alternate_group = 0; + // '1.0' = full volume + tkhd.volume = 1.0; + tkhd.reserved3 = 0; + tkhd.matrix = [ + 1, 0, 0, // provides a transformation matrix for the video; + 0, 1, 0, // (u,v,w) are restricted here to (0,0,1) + 0, 0, 16384, + ]; + // visual presentation width + tkhd.width = stream.width; + // visual presentation height + tkhd.height = stream.height; + } + + /** + * Create mdhd box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} mdia + * @param {shaka.extern.Stream} stream + * @private + */ + static createMdhdBox_(isoBoxer, mdia, stream) { + const mdhd = isoBoxer.createFullBox('mdhd', mdia); + // version = 1 in order to have 64bits duration value + mdhd.version = 1; + // the creation time of the presentation => ignore (set to 0) + mdhd.creation_time = 0; + // the most recent time the presentation was modified => ignore (set to 0) + mdhd.modification_time = 0; + // the time-scale for the entire presentation + const timescale = stream.mssPrivateData.timescale; + mdhd.timescale = timescale; + // the duration of this media (in the scale of the timescale). + // If the duration cannot be determined then duration is set to all 1s. + const duration = stream.mssPrivateData.duration; + mdhd.duration = duration === Infinity ? + 0x1FFFFFFFFFFFFF : Math.round(duration * timescale); + // declares the language code for this media + mdhd.language = stream.language; + mdhd.pre_defined = 0; + } + + /** + * Create hdlr box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} mdia + * @param {shaka.extern.Stream} stream + * @private + */ + static createHdlrBox_(isoBoxer, mdia, stream) { + const ContentType = shaka.util.ManifestParserUtils.ContentType; + const hdlr = isoBoxer.createFullBox('hdlr', mdia); + hdlr.pre_defined = 0; + switch (stream.type) { + case ContentType.VIDEO: + hdlr.handler_type = 'vide'; + break; + case ContentType.AUDIO: + hdlr.handler_type = 'soun'; + break; + default: + hdlr.handler_type = 'meta'; + break; + } + hdlr.name = stream.originalId; + hdlr.reserved = [0, 0, 0]; + } + + /** + * Create vmhd box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} minf + * @private + */ + static createVmhdBox_(isoBoxer, minf) { + const vmhd = isoBoxer.createFullBox('vmhd', minf); + vmhd.flags = 1; + // specifies a composition mode for this video track, from the following + // enumerated set, which may be extended by derived specifications: + // copy = 0 copy over the existing image + vmhd.graphicsmode = 0; + // is a set of 3 colour values (red, green, blue) available for use by + // graphics modes + vmhd.opcolor = [0, 0, 0]; + } + + /** + * Create smhd box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} minf + * @private + */ + static createSmhdBox_(isoBoxer, minf) { + const smhd = isoBoxer.createFullBox('smhd', minf); + smhd.flags = 1; + // is a fixed-point 8.8 number that places mono audio tracks in a stereo + // space; 0 is centre (the normal value); full left is -1.0 and full + // right is 1.0. + smhd.balance = 0; + smhd.reserved = 0; + } + + /** + * Create dref box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} dinf + * @private + */ + static createDrefBox_(isoBoxer, dinf) { + const dref = isoBoxer.createFullBox('dref', dinf); + dref.entry_count = 1; + dref.entries = []; + const url = isoBoxer.createFullBox('url ', dref, false); + url.location = ''; + url.flags = 1; + dref.entries.push(url); + } + + /** + * Create stsd box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} stbl + * @param {shaka.extern.Stream} stream + * @private + */ + static createStsdBox_(isoBoxer, stbl, stream) { + const MssUtils = shaka.mss.MssUtils; + const ContentType = shaka.util.ManifestParserUtils.ContentType; + const stsd = isoBoxer.createFullBox('stsd', stbl); + stsd.entries = []; + switch (stream.type) { + case ContentType.VIDEO: + case ContentType.AUDIO: + stsd.entries.push(MssUtils.createSampleEntry_(isoBoxer, stsd, stream)); + break; + default: + break; + } + // is an integer that counts the actual entries + stsd.entry_count = stsd.entries.length; + } + + /** + * Create sample entry box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} stsd + * @param {shaka.extern.Stream} stream + * @private + */ + static createSampleEntry_(isoBoxer, stsd, stream) { + const MssUtils = shaka.mss.MssUtils; + const codec = stream.codecs.substring(0, stream.codecs.indexOf('.')); + switch (codec) { + case 'avc1': + return MssUtils.createAVCVisualSampleEntry_( + isoBoxer, stsd, codec, stream); + case 'mp4a': + return MssUtils.createMP4AudioSampleEntry_( + isoBoxer, stsd, codec, stream); + default: + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.MSS_TRANSMUXING_CODEC_UNKNOWN, + codec); + } + } + + /** + * Create AVC Visual Sample Entry box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} stsd + * @param {string} codec + * @param {shaka.extern.Stream} stream + * @private + */ + static createAVCVisualSampleEntry_(isoBoxer, stsd, codec, stream) { + const MssUtils = shaka.mss.MssUtils; + let avc1; + if (stream.encrypted) { + avc1 = isoBoxer.createBox('encv', stsd, false); + } else { + avc1 = isoBoxer.createBox('avc1', stsd, false); + } + // SampleEntry fields + avc1.reserved1 = [0x0, 0x0, 0x0, 0x0, 0x0, 0x0]; + avc1.data_reference_index = 1; + // VisualSampleEntry fields + avc1.pre_defined1 = 0; + avc1.reserved2 = 0; + avc1.pre_defined2 = [0, 0, 0]; + avc1.height = stream.height; + avc1.width = stream.width; + // 72 dpi + avc1.horizresolution = 72; + // 72 dpi + avc1.vertresolution = 72; + avc1.reserved3 = 0; + // 1 compressed video frame per sample + avc1.frame_count = 1; + avc1.compressorname = [ + 0x0A, 0x41, 0x56, 0x43, 0x20, 0x43, 0x6F, 0x64, // = 'AVC Coding'; + 0x69, 0x6E, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + // 0x0018 – images are in colour with no alpha. + avc1.depth = 0x0018; + avc1.pre_defined3 = 65535; + avc1.config = MssUtils.createAVC1ConfigurationRecord_(isoBoxer, stream); + if (stream.encrypted) { + // Create and add Protection Scheme Info Box + const sinf = isoBoxer.createBox('sinf', avc1); + // Create and add Original Format Box => indicate codec type of the + // encrypted content + MssUtils.createOriginalFormatBox_(isoBoxer, sinf, codec); + // Create and add Scheme Type box + MssUtils.createSchemeTypeBox_(isoBoxer, sinf); + // Create and add Scheme Information Box + MssUtils.createSchemeInformationBox_(isoBoxer, sinf, stream); + } + return avc1; + } + + /** + * Create AVC1 configuration record. + * + * @param {ISOBoxer} isoBoxer + * @param {shaka.extern.Stream} stream + * @private + */ + static createAVC1ConfigurationRecord_(isoBoxer, stream) { + const MssUtils = shaka.mss.MssUtils; + + const NALUTYPE_SPS = 7; + const NALUTYPE_PPS = 8; + + // length = 15 by default (0 SPS and 0 PPS) + let avcCLength = 15; + // First get all SPS and PPS from codecPrivateData + const sps = []; + const pps = []; + let AVCProfileIndication = 0; + let AVCLevelIndication = 0; + let profileCompatibility = 0; + const codecPrivateData = stream.mssPrivateData.codecPrivateData; + const nalus = codecPrivateData.split('00000001').slice(1); + for (let i = 0; i < nalus.length; i++) { + const naluBytes = MssUtils.hexStringtoBuffer_(nalus[i]); + const naluType = naluBytes[0] & 0x1F; + switch (naluType) { + case NALUTYPE_SPS: + sps.push(naluBytes); + // 2 = sequenceParameterSetLength field length + avcCLength += naluBytes.length + 2; + break; + case NALUTYPE_PPS: + pps.push(naluBytes); + // 2 = pictureParameterSetLength field length + avcCLength += naluBytes.length + 2; + break; + default: + break; + } + } + // Get profile and level from SPS + if (sps.length > 0) { + AVCProfileIndication = sps[0][1]; + profileCompatibility = sps[0][2]; + AVCLevelIndication = sps[0][3]; + } + // Generate avcC buffer + const avcC = new Uint8Array(avcCLength); + let i = 0; + // length + avcC[i++] = (avcCLength & 0xFF000000) >> 24; + avcC[i++] = (avcCLength & 0x00FF0000) >> 16; + avcC[i++] = (avcCLength & 0x0000FF00) >> 8; + avcC[i++] = (avcCLength & 0x000000FF); + // type = 'avcC' + avcC.set([0x61, 0x76, 0x63, 0x43], i); + i += 4; + // configurationVersion = 1 + avcC[i++] = 1; + avcC[i++] = AVCProfileIndication; + avcC[i++] = profileCompatibility; + avcC[i++] = AVCLevelIndication; + // '11111' + lengthSizeMinusOne = 3 + avcC[i++] = 0xFF; + // '111' + numOfSequenceParameterSets + avcC[i++] = 0xE0 | sps.length; + for (let n = 0; n < sps.length; n++) { + avcC[i++] = (sps[n].length & 0xFF00) >> 8; + avcC[i++] = (sps[n].length & 0x00FF); + avcC.set(sps[n], i); + i += sps[n].length; + } + // numOfPictureParameterSets + avcC[i++] = pps.length; + for (let n = 0; n < pps.length; n++) { + avcC[i++] = (pps[n].length & 0xFF00) >> 8; + avcC[i++] = (pps[n].length & 0x00FF); + avcC.set(pps[n], i); + i += pps[n].length; + } + return avcC; + } + + /** + * Create MP4 Audio Sample Entry box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} stsd + * @param {string} codec + * @param {shaka.extern.Stream} stream + * @private + */ + static createMP4AudioSampleEntry_(isoBoxer, stsd, codec, stream) { + const MssUtils = shaka.mss.MssUtils; + // By default assumes stereo + const channelsCount = stream.channelsCount || 2; + // By default assumes 44.1khz + const audioSamplingRate = stream.audioSamplingRate || 44100; + let mp4a; + if (stream.encrypted) { + mp4a = isoBoxer.createBox('enca', stsd, false); + } else { + mp4a = isoBoxer.createBox('mp4a', stsd, false); + } + // SampleEntry fields + mp4a.reserved1 = [0x0, 0x0, 0x0, 0x0, 0x0, 0x0]; + mp4a.data_reference_index = 1; + // AudioSampleEntry fields + mp4a.reserved2 = [0x0, 0x0]; + mp4a.channelcount = channelsCount; + mp4a.samplesize = 16; + mp4a.pre_defined = 0; + mp4a.reserved_3 = 0; + mp4a.samplerate = audioSamplingRate << 16; + mp4a.esds = MssUtils.createMPEG4AACESDescriptor_(isoBoxer, stream); + if (stream.encrypted) { + // Create and add Protection Scheme Info Box + const sinf = isoBoxer.createBox('sinf', mp4a); + // Create and add Original Format Box => indicate codec type of the + // encrypted content + MssUtils.createOriginalFormatBox_(isoBoxer, sinf, codec); + // Create and add Scheme Type box + MssUtils.createSchemeTypeBox_(isoBoxer, sinf); + // Create and add Scheme Information Box + MssUtils.createSchemeInformationBox_(isoBoxer, sinf, stream); + } + return mp4a; + } + + /** + * Create ESDS descriptor. + * + * @param {ISOBoxer} isoBoxer + * @param {shaka.extern.Stream} stream + * @private + */ + static createMPEG4AACESDescriptor_(isoBoxer, stream) { + const MssUtils = shaka.mss.MssUtils; + const codecPrivateData = stream.mssPrivateData.codecPrivateData; + // AudioSpecificConfig (see ISO/IEC 14496-3, subpart 1) => corresponds to + // hex bytes contained in 'codecPrivateData' field + const audioSpecificConfig = MssUtils.hexStringtoBuffer_(codecPrivateData); + + // ESDS length = esds box header length (= 12) + + // ES_Descriptor header length (= 5) + + // DecoderConfigDescriptor header length (= 15) + + // decoderSpecificInfo header length (= 2) + + // AudioSpecificConfig length (= codecPrivateData length) + const esdsLength = 34 + audioSpecificConfig.length; + const esds = new Uint8Array(esdsLength); + let i = 0; + // esds box + // esds box length + esds[i++] = (esdsLength & 0xFF000000) >> 24; + esds[i++] = (esdsLength & 0x00FF0000) >> 16; + esds[i++] = (esdsLength & 0x0000FF00) >> 8; + esds[i++] = (esdsLength & 0x000000FF); + // type = 'esds' + esds.set([0x65, 0x73, 0x64, 0x73], i); + i += 4; + // version = 0, flags = 0 + esds.set([0, 0, 0, 0], i); + i += 4; + // ES_Descriptor (see ISO/IEC 14496-1 (Systems)) + // tag = 0x03 (ES_DescrTag) + esds[i++] = 0x03; + // size + esds[i++] = 20 + audioSpecificConfig.length; + // ES_ID = track_id + esds[i++] = ((stream.id + 1) & 0xFF00) >> 8; + esds[i++] = ((stream.id + 1) & 0x00FF); + // flags and streamPriority + esds[i++] = 0; + // DecoderConfigDescriptor (see ISO/IEC 14496-1 (Systems)) + // tag = 0x04 (DecoderConfigDescrTag) + esds[i++] = 0x04; + // size + esds[i++] = 15 + audioSpecificConfig.length; + // objectTypeIndication = 0x40 (MPEG-4 AAC) + esds[i++] = 0x40; + // streamType = 0x05 (Audiostream) + esds[i] = 0x05 << 2; + // upStream = 0 + esds[i] |= 0 << 1; + // reserved = 1 + esds[i++] |= 1; + // buffersizeDB = undefined + esds[i++] = 0xFF; + esds[i++] = 0xFF; + esds[i++] = 0xFF; + const bandwidth = stream.bandwidth || 0; + // maxBitrate + esds[i++] = (bandwidth & 0xFF000000) >> 24; + esds[i++] = (bandwidth & 0x00FF0000) >> 16; + esds[i++] = (bandwidth & 0x0000FF00) >> 8; + esds[i++] = (bandwidth & 0x000000FF); + // avgbitrate + esds[i++] = (bandwidth & 0xFF000000) >> 24; + esds[i++] = (bandwidth & 0x00FF0000) >> 16; + esds[i++] = (bandwidth & 0x0000FF00) >> 8; + esds[i++] = (bandwidth & 0x000000FF); + + // DecoderSpecificInfo (see ISO/IEC 14496-1 (Systems)) + // tag = 0x05 (DecSpecificInfoTag) + esds[i++] = 0x05; + // size + esds[i++] = audioSpecificConfig.length; + // AudioSpecificConfig bytes + esds.set(audioSpecificConfig, i); + + return esds; + } + + /** + * Create frma box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} sinf + * @param {string} codec + * @private + */ + static createOriginalFormatBox_(isoBoxer, sinf, codec) { + const MssUtils = shaka.mss.MssUtils; + const frma = isoBoxer.createBox('frma', sinf); + frma.data_format = MssUtils.stringToCharCode_(codec); + } + + /** + * Create schm box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} sinf + * @private + */ + static createSchemeTypeBox_(isoBoxer, sinf) { + const schm = isoBoxer.createFullBox('schm', sinf); + schm.flags = 0; + schm.version = 0; + // 'cenc' => common encryption + schm.scheme_type = 0x63656E63; + // version set to 0x00010000 (Major version 1, Minor version 0) + schm.scheme_version = 0x00010000; + } + + /** + * Create schi box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} sinf + * @param {shaka.extern.Stream} stream + * @private + */ + static createSchemeInformationBox_(isoBoxer, sinf, stream) { + const MssUtils = shaka.mss.MssUtils; + const schi = isoBoxer.createBox('schi', sinf); + // Create and add Track Encryption Box + MssUtils.createTrackEncryptionBox_(isoBoxer, schi, stream); + } + + /** + * Create tenc box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} schi + * @param {shaka.extern.Stream} stream + * @private + */ + static createTrackEncryptionBox_(isoBoxer, schi, stream) { + const tenc = isoBoxer.createFullBox('tenc', schi); + tenc.flags = 0; + tenc.version = 0; + tenc.default_IsEncrypted = 0x1; + tenc.default_IV_size = 8; + let defaultKID = [0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0]; + for (const drmInfo of stream.drmInfos) { + if (drmInfo && drmInfo.keyId && drmInfo.keyIds.size) { + for (const keyId of drmInfo.keyIds) { + defaultKID = keyId; + } + } + } + tenc.default_KID = defaultKID; + } + + /** + * Create trex box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} moov + * @param {shaka.extern.Stream} stream + * @private + */ + static createTrexBox_(isoBoxer, moov, stream) { + const trex = isoBoxer.createFullBox('trex', moov); + trex.track_ID = (stream.id + 1); + trex.default_sample_description_index = 1; + trex.default_sample_duration = 0; + trex.default_sample_size = 0; + trex.default_sample_flags = 0; + } + + /** + * Create PSSH box. + * + * @param {ISOBoxer} isoBoxer + * @param {ISOBoxer} moov + * @param {shaka.extern.Stream} stream + * @private + */ + static createProtectionSystemSpecificHeaderBox_(isoBoxer, moov, stream) { + const BufferUtils = shaka.util.BufferUtils; + for (const drmInfo of stream.drmInfos) { + if (!drmInfo.initData) { + continue; + } + for (const initData of drmInfo.initData) { + const initDataBuffer = BufferUtils.toArrayBuffer(initData.initData); + const parsedBuffer = isoBoxer.parseBuffer(initDataBuffer); + const pssh = parsedBuffer.fetch('pssh'); + if (pssh) { + isoBoxer.Utils.appendBox(moov, pssh); + } + } + } + } + + /** + * Convert a hex string to buffer. + * + * @param {string} str + * @return {Uint8Array} + * @private + */ + static hexStringtoBuffer_(str) { + const buf = new Uint8Array(str.length / 2); + for (let i = 0; i < str.length / 2; i += 1) { + buf[i] = parseInt(String(str[i * 2] + str[i * 2 + 1]), 16); + } + return buf; + } + + /** + * Convert a string to char code. + * + * @param {string} str + * @return {number} + * @private + */ + static stringToCharCode_(str) { + let code = 0; + for (let i = 0; i < str.length; i += 1) { + code |= str.charCodeAt(i) << ((str.length - i - 1) * 8); + } + return code; + } +}; + diff --git a/lib/transmuxer/mss_transmuxer.js b/lib/transmuxer/mss_transmuxer.js new file mode 100644 index 00000000000..7c2fee5ff69 --- /dev/null +++ b/lib/transmuxer/mss_transmuxer.js @@ -0,0 +1,328 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.transmuxer.MssTransmuxer'); + +goog.require('shaka.media.Capabilities'); +goog.require('shaka.transmuxer.TransmuxerEngine'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.Error'); +goog.require('shaka.util.ManifestParserUtils'); +goog.require('shaka.dependencies'); + +goog.requireType('shaka.media.SegmentReference'); + + +/** + * @implements {shaka.extern.Transmuxer} + * @export + */ +shaka.transmuxer.MssTransmuxer = class { + /** + * @param {string} mimeType + */ + constructor(mimeType) { + /** @private {string} */ + this.originalMimeType_ = mimeType; + + /** @private {?ISOBoxer} */ + this.isoBoxer_ = shaka.dependencies.isoBoxer(); + + this.addSpecificBoxProcessor_(); + } + + /** + * Add specific box processor for codem-isoboxer + * + * @private + */ + addSpecificBoxProcessor_() { + // eslint-disable-next-line no-restricted-syntax + this.isoBoxer_.addBoxProcessor('saio', function() { + // eslint-disable-next-line no-invalid-this + const box = /** @type {!ISOBox} */(this); + box._procFullBox(); + if (box.flags & 1) { + box._procField('aux_info_type', 'uint', 32); + box._procField('aux_info_type_parameter', 'uint', 32); + } + box._procField('entry_count', 'uint', 32); + box._procFieldArray('offset', box.entry_count, 'uint', + (box.version === 1) ? 64 : 32); + }); + // eslint-disable-next-line no-restricted-syntax + this.isoBoxer_.addBoxProcessor('saiz', function() { + // eslint-disable-next-line no-invalid-this + const box = /** @type {!ISOBox} */(this); + box._procFullBox(); + if (box.flags & 1) { + box._procField('aux_info_type', 'uint', 32); + box._procField('aux_info_type_parameter', 'uint', 32); + } + box._procField('default_sample_info_size', 'uint', 8); + box._procField('sample_count', 'uint', 32); + if (box.default_sample_info_size === 0) { + box._procFieldArray('sample_info_size', + box.sample_count, 'uint', 8); + } + }); + // eslint-disable-next-line no-restricted-syntax + this.isoBoxer_.addBoxProcessor('senc', function() { + // eslint-disable-next-line no-invalid-this + const box = /** @type {!ISOBox} */(this); + box._procFullBox(); + box._procField('sample_count', 'uint', 32); + if (box.flags & 1) { + box._procField('IV_size', 'uint', 8); + } + // eslint-disable-next-line no-restricted-syntax + box._procEntries('entry', box.sample_count, function(entry) { + // eslint-disable-next-line no-invalid-this + const boxEntry = /** @type {!ISOBox} */(this); + boxEntry._procEntryField(entry, 'InitializationVector', 'data', 8); + if (boxEntry.flags & 2) { + boxEntry._procEntryField(entry, 'NumberOfEntries', 'uint', 16); + boxEntry._procSubEntries(entry, 'clearAndCryptedData', + // eslint-disable-next-line no-restricted-syntax + entry.NumberOfEntries, function(clearAndCryptedData) { + // eslint-disable-next-line no-invalid-this + const subBoxEntry = /** @type {!ISOBox} */(this); + subBoxEntry._procEntryField(clearAndCryptedData, + 'BytesOfClearData', 'uint', 16); + subBoxEntry._procEntryField(clearAndCryptedData, + 'BytesOfEncryptedData', 'uint', 32); + }); + } + }); + }); + } + + + /** + * @override + * @export + */ + destroy() { + // Nothing + } + + + /** + * Check if the mime type and the content type is supported. + * @param {string} mimeType + * @param {string=} contentType + * @return {boolean} + * @override + * @export + */ + isSupported(mimeType, contentType) { + const Capabilities = shaka.media.Capabilities; + + const isMss = mimeType.startsWith('mss/'); + + if (!this.isoBoxer_ || !isMss) { + return false; + } + + if (contentType) { + return Capabilities.isTypeSupported( + this.convertCodecs(contentType, mimeType)); + } + + const ContentType = shaka.util.ManifestParserUtils.ContentType; + + const audioMime = this.convertCodecs(ContentType.AUDIO, mimeType); + const videoMime = this.convertCodecs(ContentType.VIDEO, mimeType); + return Capabilities.isTypeSupported(audioMime) || + Capabilities.isTypeSupported(videoMime); + } + + + /** + * @override + * @export + */ + convertCodecs(contentType, mimeType) { + return mimeType.replace('mss/', ''); + } + + + /** + * @override + * @export + */ + getOrginalMimeType() { + return this.originalMimeType_; + } + + + /** + * @override + * @export + */ + transmux(data, stream, reference) { + if (!reference) { + // Init segment doesn't need transmux + return Promise.resolve(shaka.util.BufferUtils.toUint8(data)); + } + if (!stream.mssPrivateData) { + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.MISSING_DATA_FOR_TRANSMUXING)); + } + try { + const transmuxedData = this.processMediaSegment_( + data, stream, reference); + return Promise.resolve(transmuxedData); + } catch (exception) { + if (exception instanceof shaka.util.Error) { + return Promise.reject(exception); + } + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.MSS_TRANSMUXING_FAILED)); + } + } + + /** + * Process a media segment from a data and stream. + * @param {BufferSource} data + * @param {shaka.extern.Stream} stream + * @param {shaka.media.SegmentReference} reference + * @return {!Uint8Array} + * @private + */ + processMediaSegment_(data, stream, reference) { + let i; + const isoFile = this.isoBoxer_.parseBuffer(data); + // Update track_Id in tfhd box + const tfhd = isoFile.fetch('tfhd'); + tfhd.track_ID = stream.id + 1; + // Add tfdt box + let tfdt = isoFile.fetch('tfdt'); + const traf = isoFile.fetch('traf'); + if (tfdt === null) { + tfdt = this.isoBoxer_.createFullBox('tfdt', traf, tfhd); + tfdt.version = 1; + tfdt.flags = 0; + const timescale = stream.mssPrivateData.timescale; + const startTime = reference.startTime; + tfdt.baseMediaDecodeTime = Math.floor(startTime * timescale); + } + const trun = isoFile.fetch('trun'); + // Process tfxd boxes + // This box provide absolute timestamp but we take the segment start + // time for tfdt + let tfxd = isoFile.fetch('tfxd'); + if (tfxd) { + tfxd._parent.boxes.splice(tfxd._parent.boxes.indexOf(tfxd), 1); + tfxd = null; + } + let tfrf = isoFile.fetch('tfrf'); + // processTfrf(e.request, tfrf, tfdt, streamProcessor); + if (tfrf) { + tfrf._parent.boxes.splice(tfrf._parent.boxes.indexOf(tfrf), 1); + tfrf = null; + } + + // If protected content in PIFF1.1 format + // (sepiff box = Sample Encryption PIFF) + // => convert sepiff box it into a senc box + // => create saio and saiz boxes (if not already present) + const sepiff = isoFile.fetch('sepiff'); + if (sepiff !== null) { + sepiff.type = 'senc'; + sepiff.usertype = undefined; + + let saio = isoFile.fetch('saio'); + if (saio === null) { + // Create Sample Auxiliary Information Offsets Box box (saio) + saio = this.isoBoxer_.createFullBox('saio', traf); + saio.version = 0; + saio.flags = 0; + saio.entry_count = 1; + saio.offset = [0]; + const saiz = this.isoBoxer_.createFullBox('saiz', traf); + saiz.version = 0; + saiz.flags = 0; + saiz.sample_count = sepiff.sample_count; + saiz.default_sample_info_size = 0; + saiz.sample_info_size = []; + if (sepiff.flags & 0x02) { + // Sub-sample encryption => set sample_info_size for each sample + for (i = 0; i < sepiff.sample_count; i += 1) { + // 10 = 8 (InitializationVector field size) + 2 + // (subsample_count field size) + // 6 = 2 (BytesOfClearData field size) + 4 + // (BytesOfEncryptedData field size) + saiz.sample_info_size[i] = + 10 + (6 * sepiff.entry[i].NumberOfEntries); + } + } else { + // No sub-sample encryption => set default + // sample_info_size = InitializationVector field size (8) + saiz.default_sample_info_size = 8; + } + } + } + + // set tfhd.base-data-offset-present to false + tfhd.flags &= 0xFFFFFE; + // set tfhd.default-base-is-moof to true + tfhd.flags |= 0x020000; + // set trun.data-offset-present to true + trun.flags |= 0x000001; + + // Update trun.data_offset field that corresponds to first data byte + // (inside mdat box) + const moof = isoFile.fetch('moof'); + const length = moof.getLength(); + trun.data_offset = length + 8; + + // Update saio box offset field according to new senc box offset + const saio = isoFile.fetch('saio'); + if (saio !== null) { + const trafPosInMoof = this.getBoxOffset_(moof, 'traf'); + const sencPosInTraf = this.getBoxOffset_(traf, 'senc'); + // Set offset from begin fragment to the first IV field in senc box + // 16 = box header (12) + sample_count field size (4) + saio.offset[0] = trafPosInMoof + sencPosInTraf + 16; + } + + return shaka.util.BufferUtils.toUint8(isoFile.write()); + } + + /** + * This function returns the offset of the 1st byte of a child box within + * a container box. + * + * @param {ISOBox} parent + * @param {string} type + * @return {number} + * @private + */ + getBoxOffset_(parent, type) { + let offset = 8; + for (let i = 0; i < parent.boxes.length; i++) { + if (parent.boxes[i].type === type) { + return offset; + } + offset += parent.boxes[i].size; + } + return offset; + } +}; + +shaka.transmuxer.TransmuxerEngine.registerTransmuxer( + 'mss/audio/mp4', + () => new shaka.transmuxer.MssTransmuxer('mss/audio/mp4'), + shaka.transmuxer.TransmuxerEngine.PluginPriority.FALLBACK); +shaka.transmuxer.TransmuxerEngine.registerTransmuxer( + 'mss/video/mp4', + () => new shaka.transmuxer.MssTransmuxer('mss/video/mp4'), + shaka.transmuxer.TransmuxerEngine.PluginPriority.FALLBACK); diff --git a/lib/util/error.js b/lib/util/error.js index a544689b59e..798953ca61c 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -497,6 +497,22 @@ shaka.util.Error.Code = { */ 'CONTENT_TRANSFORMATION_FAILED': 3019, + /** + * Important data is missing to be able to do the transmuxing of MSS. + */ + 'MISSING_DATA_FOR_TRANSMUXING': 3020, + + /** + * MSS transmuing failed for unknown codec. + *
error.data[0] is a unknown codec. + */ + 'MSS_TRANSMUXING_CODEC_UNKNOWN': 3021, + + /** + * MSS transmuing failed for unknown reason. + */ + 'MSS_TRANSMUXING_FAILED': 3022, + /** * The Player was unable to guess the manifest type based on file extension @@ -725,6 +741,17 @@ shaka.util.Error.Code = { */ 'CANNOT_ADD_EXTERNAL_THUMBNAILS_TO_LIVE_STREAM': 4045, + /** + * The MSS Manifest contained invalid XML markup. + *
error.data[0] is the URI associated with the XML. + */ + 'MSS_INVALID_XML': 4046, + + /** + * MSS parser encountered a live playlist. + */ + 'MSS_LIVE_CONTENT_NOT_SUPPORTED': 4047, + // RETIRED: 'INCONSISTENT_BUFFER_STATE': 5000, // RETIRED: 'INVALID_SEGMENT_INDEX': 5001, diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index 0249c135867..85b7450708e 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -151,6 +151,20 @@ shaka.util.PlayerConfiguration = class { liveSegmentsDelay: 3, sequenceMode: supportsSequenceMode, }, + mss: { + manifestPreprocessor: (element) => { + return shaka.util.ConfigUtils.referenceParametersAndReturn( + [element], + element); + }, + sequenceMode: false, + keySystemsBySystemId: { + '9a04f079-9840-4286-ab92-e65be0885f95': + 'com.microsoft.playready', + '79f0049a-4098-8642-ab92-e65be0885f95': + 'com.microsoft.playready', + }, + }, }; const streaming = { diff --git a/lib/util/xml_utils.js b/lib/util/xml_utils.js index 82ea12d27b1..84567cd2606 100644 --- a/lib/util/xml_utils.js +++ b/lib/util/xml_utils.js @@ -321,6 +321,19 @@ shaka.util.XmlUtils = class { } + /** + * Parses a boolean. + * @param {string} booleanString The boolean string. + * @return {boolean} The boolean + */ + static parseBoolean(booleanString) { + if (!booleanString) { + return false; + } + return booleanString.toLowerCase() === 'true'; + } + + /** * Evaluate a division expressed as a string. * @param {string} exprString @@ -412,7 +425,7 @@ shaka.util.XmlUtils = class { */ static parseXml(data, expectedRootElemName) { try { - const string = shaka.util.StringUtils.fromUTF8(data); + const string = shaka.util.StringUtils.fromBytesAutoDetect(data); return shaka.util.XmlUtils.parseXmlString(string, expectedRootElemName); } catch (exception) { shaka.log.error('parseXmlString threw!', exception); diff --git a/package-lock.json b/package-lock.json index bd88c986c67..36c760bd2aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "babel-plugin-istanbul": "^6.1.1", "cajon": "^0.4.4", "code-prettify": "^0.1.0", + "codem-isoboxer": "^0.3.7", "color-themes-for-google-code-prettify": "^2.0.4", "core-js": "^3.21.1", "dialog-polyfill": "^0.5.6", @@ -3106,6 +3107,12 @@ "integrity": "sha1-RocMyMGlDQm61TmzOpg9vUqjSx4=", "dev": true }, + "node_modules/codem-isoboxer": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/codem-isoboxer/-/codem-isoboxer-0.3.7.tgz", + "integrity": "sha512-aJh5CAuJX0TUUu1aLCd2DKmYxlebJfr1f4PJc9BCfXFbFclHsKvqrnqTrRV5hWVWtisllm+Q03tCEeirow8XAg==", + "dev": true + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -10886,6 +10893,12 @@ "integrity": "sha1-RocMyMGlDQm61TmzOpg9vUqjSx4=", "dev": true }, + "codem-isoboxer": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/codem-isoboxer/-/codem-isoboxer-0.3.7.tgz", + "integrity": "sha512-aJh5CAuJX0TUUu1aLCd2DKmYxlebJfr1f4PJc9BCfXFbFclHsKvqrnqTrRV5hWVWtisllm+Q03tCEeirow8XAg==", + "dev": true + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", diff --git a/package.json b/package.json index 19a51fc6b68..323e812cdd2 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "babel-plugin-istanbul": "^6.1.1", "cajon": "^0.4.4", "code-prettify": "^0.1.0", + "codem-isoboxer": "^0.3.7", "color-themes-for-google-code-prettify": "^2.0.4", "core-js": "^3.21.1", "dialog-polyfill": "^0.5.6", diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index 2cbd8398b52..78c00fea643 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -17,6 +17,7 @@ goog.require('shaka.cast.CastProxy'); goog.require('shaka.cast.CastReceiver'); goog.require('shaka.dash.DashParser'); goog.require('shaka.hls.HlsParser'); +goog.require('shaka.mss.MssParser'); goog.require('shaka.log'); goog.require('shaka.media.AdaptationSetCriteria'); goog.require('shaka.media.InitSegmentReference'); @@ -62,6 +63,7 @@ goog.require('shaka.text.TtmlTextParser'); goog.require('shaka.text.VttTextParser'); goog.require('shaka.text.WebVttGenerator'); goog.require('shaka.transmuxer.TransmuxerEngine'); +goog.require('shaka.transmuxer.MssTransmuxer'); goog.require('shaka.transmuxer.MuxjsTransmuxer'); goog.require('shaka.ui.Controls'); goog.require('shaka.ui.PlayButton'); diff --git a/test/cast/cast_utils_unit.js b/test/cast/cast_utils_unit.js index ff954fc45b3..33440cb6029 100644 --- a/test/cast/cast_utils_unit.js +++ b/test/cast/cast_utils_unit.js @@ -8,6 +8,9 @@ describe('CastUtils', () => { const CastUtils = shaka.cast.CastUtils; const FakeEvent = shaka.util.FakeEvent; + /** @type {shaka.extern.Stream} */ + const fakeStream = shaka.test.StreamingEngineUtil.createMockVideoStream(1); + it('includes every Player member', () => { const ignoredMembers = [ 'constructor', // JavaScript added field @@ -218,11 +221,11 @@ describe('CastUtils', () => { await mediaSourceEngine.init(initObject, false); const data = await shaka.test.Util.fetch(initSegmentUrl); await mediaSourceEngine.appendBuffer( - ContentType.VIDEO, data, null, null, + ContentType.VIDEO, data, null, fakeStream, /* hasClosedCaptions= */ false); const data2 = await shaka.test.Util.fetch(videoSegmentUrl); await mediaSourceEngine.appendBuffer( - ContentType.VIDEO, data2, null, null, + ContentType.VIDEO, data2, null, fakeStream, /* hasClosedCaptions= */ false); }); diff --git a/test/demo/demo_unit.js b/test/demo/demo_unit.js index 6662816eee7..3626b1f7f29 100644 --- a/test/demo/demo_unit.js +++ b/test/demo/demo_unit.js @@ -95,6 +95,7 @@ describe('Demo', () => { .add('playRangeEnd') .add('manifest.dash.keySystemsByURI') .add('manifest.hls.mediaPlaylistFullMimeType') + .add('manifest.mss.keySystemsBySystemId') .add('drm.keySystemsMapping') .add('streaming.parsePrftBox'); diff --git a/test/media/drm_engine_integration.js b/test/media/drm_engine_integration.js index 51caa61975b..63851944d6b 100644 --- a/test/media/drm_engine_integration.js +++ b/test/media/drm_engine_integration.js @@ -49,6 +49,9 @@ describe('DrmEngine', () => { /** @type {!ArrayBuffer} */ let audioSegment; + /** @type {shaka.extern.Stream} */ + const fakeStream = shaka.test.StreamingEngineUtil.createMockVideoStream(1); + beforeAll(async () => { video = shaka.test.UiUtils.createVideoElement(); document.body.appendChild(video); @@ -211,10 +214,10 @@ describe('DrmEngine', () => { await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); await drmEngine.attach(video); await mediaSourceEngine.appendBuffer( - ContentType.VIDEO, videoInitSegment, null, + ContentType.VIDEO, videoInitSegment, null, fakeStream, /* hasClosedCaptions= */ false); await mediaSourceEngine.appendBuffer( - ContentType.AUDIO, audioInitSegment, null, + ContentType.AUDIO, audioInitSegment, null, fakeStream, /* hasClosedCaptions= */ false); await encryptedEventSeen; // With PlayReady, a persistent license policy can cause a different @@ -250,10 +253,10 @@ describe('DrmEngine', () => { const reference = dummyReference(0, 10); await mediaSourceEngine.appendBuffer( - ContentType.VIDEO, videoSegment, reference, + ContentType.VIDEO, videoSegment, reference, fakeStream, /* hasClosedCaptions= */ false); await mediaSourceEngine.appendBuffer( - ContentType.AUDIO, audioSegment, reference, + ContentType.AUDIO, audioSegment, reference, fakeStream, /* hasClosedCaptions= */ false); expect(video.buffered.end(0)).toBeGreaterThan(0); @@ -309,10 +312,10 @@ describe('DrmEngine', () => { await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); await drmEngine.attach(video); await mediaSourceEngine.appendBuffer( - ContentType.VIDEO, videoInitSegment, null, + ContentType.VIDEO, videoInitSegment, null, fakeStream, /* hasClosedCaptions= */ false); await mediaSourceEngine.appendBuffer( - ContentType.AUDIO, audioInitSegment, null, + ContentType.AUDIO, audioInitSegment, null, fakeStream, /* hasClosedCaptions= */ false); await encryptedEventSeen; @@ -333,10 +336,10 @@ describe('DrmEngine', () => { const reference = dummyReference(0, 10); await mediaSourceEngine.appendBuffer( - ContentType.VIDEO, videoSegment, reference, + ContentType.VIDEO, videoSegment, reference, fakeStream, /* hasClosedCaptions= */ false); await mediaSourceEngine.appendBuffer( - ContentType.AUDIO, audioSegment, reference, + ContentType.AUDIO, audioSegment, reference, fakeStream, /* hasClosedCaptions= */ false); expect(video.buffered.end(0)).toBeGreaterThan(0); diff --git a/test/media/media_source_engine_integration.js b/test/media/media_source_engine_integration.js index 663646dafb6..c6788eb568e 100644 --- a/test/media/media_source_engine_integration.js +++ b/test/media/media_source_engine_integration.js @@ -18,6 +18,9 @@ describe('MediaSourceEngine', () => { let mediaSourceEngine; let generators; let metadata; + + /** @type {shaka.extern.Stream} */ + const fakeStream = shaka.test.StreamingEngineUtil.createMockVideoStream(1); // TODO: add text streams to MSE integration tests const mp4CeaCue0 = jasmine.objectContaining({ @@ -186,7 +189,7 @@ describe('MediaSourceEngine', () => { const segment = generators[type].getInitSegment(Date.now() / 1000); const reference = null; return mediaSourceEngine.appendBuffer( - type, segment, reference, /* hasClosedCaptions= */ false); + type, segment, reference, fakeStream, /* hasClosedCaptions= */ false); } function append(type, segmentNumber) { @@ -194,7 +197,7 @@ describe('MediaSourceEngine', () => { .getSegment(segmentNumber, Date.now() / 1000); const reference = dummyReference(type, segmentNumber); return mediaSourceEngine.appendBuffer( - type, segment, reference, /* hasClosedCaptions= */ false); + type, segment, reference, fakeStream, /* hasClosedCaptions= */ false); } function appendWithSeekAndClosedCaptions(type, segmentNumber) { @@ -205,6 +208,7 @@ describe('MediaSourceEngine', () => { type, segment, reference, + fakeStream, /* hasClosedCaptions= */ true, /* seeked= */ true); } @@ -213,7 +217,7 @@ describe('MediaSourceEngine', () => { const segment = generators[type].getInitSegment(Date.now() / 1000); const reference = null; return mediaSourceEngine.appendBuffer( - type, segment, reference, /* hasClosedCaptions= */ true); + type, segment, reference, fakeStream, /* hasClosedCaptions= */ true); } function appendWithClosedCaptions(type, segmentNumber) { @@ -221,7 +225,7 @@ describe('MediaSourceEngine', () => { .getSegment(segmentNumber, Date.now() / 1000); const reference = dummyReference(type, segmentNumber); return mediaSourceEngine.appendBuffer( - type, segment, reference, /* hasClosedCaptions= */ true); + type, segment, reference, fakeStream, /* hasClosedCaptions= */ true); } function buffered(type, time) { @@ -578,15 +582,16 @@ describe('MediaSourceEngine', () => { segment, /* offset= */ 0, /* length= */ partialSegmentLength); let reference = dummyReference(videoType, 0); await mediaSourceEngine.appendBuffer( - videoType, partialSegment, reference, /* hasClosedCaptions= */ false); + videoType, partialSegment, reference, fakeStream, + /* hasClosedCaptions= */ false); partialSegment = shaka.util.BufferUtils.toUint8( segment, /* offset= */ partialSegmentLength); reference = dummyReference(videoType, 1); await mediaSourceEngine.appendBuffer( - videoType, partialSegment, reference, /* hasClosedCaptions= */ false, - /* seeked= */ true); + videoType, partialSegment, reference, fakeStream, + /* hasClosedCaptions= */ false, /* seeked= */ true); }); it('extracts CEA-708 captions from dash', async () => { diff --git a/test/media/media_source_engine_unit.js b/test/media/media_source_engine_unit.js index 48069772121..cc99561dff9 100644 --- a/test/media/media_source_engine_unit.js +++ b/test/media/media_source_engine_unit.js @@ -61,6 +61,9 @@ describe('MediaSourceEngine', () => { const fakeTextStream = {mimeType: 'text/foo', drmInfos: []}; const fakeTransportStream = {mimeType: 'tsMimetype', drmInfos: []}; + /** @type {shaka.extern.Stream} */ + const fakeStream = shaka.test.StreamingEngineUtil.createMockVideoStream(1); + let audioSourceBuffer; let videoSourceBuffer; let mockVideo; @@ -365,7 +368,7 @@ describe('MediaSourceEngine', () => { it('appends the given data', async () => { const p = mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); audioSourceBuffer.updateend(); @@ -383,7 +386,7 @@ describe('MediaSourceEngine', () => { {code: 5, message: 'something failed'})); await expectAsync( mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false)) .toBeRejectedWith(expected); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); @@ -402,7 +405,7 @@ describe('MediaSourceEngine', () => { ContentType.AUDIO)); await expectAsync( mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false)) .toBeRejectedWith(expected); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); @@ -423,10 +426,10 @@ describe('MediaSourceEngine', () => { ContentType.AUDIO)); const p1 = mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false); const p2 = mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false); audioSourceBuffer.updateend(); await expectAsync(p1).toBeResolved(); @@ -436,7 +439,7 @@ describe('MediaSourceEngine', () => { it('rejects the promise if this operation fails async', async () => { mockVideo.error = {code: 5}; const p = mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false); audioSourceBuffer.error(); audioSourceBuffer.updateend(); @@ -453,11 +456,11 @@ describe('MediaSourceEngine', () => { it('queues operations on a single SourceBuffer', async () => { /** @type {!shaka.test.StatusPromise} */ const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer2, null, + ContentType.AUDIO, buffer2, null, fakeStream, /* hasClosedCaptions= */ false)); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); @@ -476,15 +479,15 @@ describe('MediaSourceEngine', () => { it('queues operations independently for different types', async () => { /** @type {!shaka.test.StatusPromise} */ const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer2, null, + ContentType.AUDIO, buffer2, null, fakeStream, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p3 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( - ContentType.VIDEO, buffer3, null, + ContentType.VIDEO, buffer3, null, fakeStream, /* hasClosedCaptions= */ false)); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); @@ -519,13 +522,13 @@ describe('MediaSourceEngine', () => { }); const p1 = mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false); const p2 = mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer2, null, + ContentType.AUDIO, buffer2, null, fakeStream, /* hasClosedCaptions= */ false); const p3 = mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer3, null, + ContentType.AUDIO, buffer3, null, fakeStream, /* hasClosedCaptions= */ false); await expectAsync(p1).toBeResolved(); @@ -541,7 +544,8 @@ describe('MediaSourceEngine', () => { expect(mockTextEngine.appendBuffer).not.toHaveBeenCalled(); const reference = dummyReference(0, 10); await mediaSourceEngine.appendBuffer( - ContentType.TEXT, data, reference, /* hasClosedCaptions= */ false); + ContentType.TEXT, data, reference, fakeStream, + /* hasClosedCaptions= */ false); expect(mockTextEngine.appendBuffer).toHaveBeenCalledWith( data, 0, 10); }); @@ -559,7 +563,7 @@ describe('MediaSourceEngine', () => { const init = async () => { await mediaSourceEngine.init(initObject, false); await mediaSourceEngine.appendBuffer( - ContentType.VIDEO, buffer, null, + ContentType.VIDEO, buffer, null, fakeStream, /* hasClosedCaptions= */ false); expect(videoSourceBuffer.appendBuffer).toHaveBeenCalled(); }; @@ -587,7 +591,7 @@ describe('MediaSourceEngine', () => { // Initialize the closed caption parser. const appendInit = mediaSourceEngine.appendBuffer( - ContentType.VIDEO, buffer, null, + ContentType.VIDEO, buffer, null, fakeStream, /* hasClosedCaptions= */ true); // In MediaSourceEngine, appendBuffer() is async and Promise-based, but // at the browser level, it's event-based. @@ -603,7 +607,8 @@ describe('MediaSourceEngine', () => { // Parse and append the closed captions embedded in video stream. const reference = dummyReference(0, 1000); const appendVideo = mediaSourceEngine.appendBuffer( - ContentType.VIDEO, buffer, reference, true); + ContentType.VIDEO, buffer, reference, fakeStream, + /* hasClosedCaptions= */ true); videoSourceBuffer.updateend(); await appendVideo; @@ -624,7 +629,8 @@ describe('MediaSourceEngine', () => { const reference = dummyReference(0, 1000); reference.startTime = 0.50; const appendVideo = mediaSourceEngine.appendBuffer( - ContentType.VIDEO, buffer, reference, /* hasClosedCaptions= */ false, + ContentType.VIDEO, buffer, reference, fakeStream, + /* hasClosedCaptions= */ false, /* seeked= */ false, /* adaptation= */ true); videoSourceBuffer.updateend(); await appendVideo; @@ -646,7 +652,8 @@ describe('MediaSourceEngine', () => { // text segments. In this case, SourceBuffer mode is still 'segments'. let reference = dummyReference(0, 1000); let appendVideo = mediaSourceEngine.appendBuffer( - ContentType.VIDEO, buffer, reference, /* hasClosedCaptions= */ false); + ContentType.VIDEO, buffer, reference, fakeStream, + /* hasClosedCaptions= */ false); // Wait for the first appendBuffer(), in segments mode. await simulateUpdate(); // Next, wait for abort(), used to reset the parser state for a safe @@ -668,8 +675,8 @@ describe('MediaSourceEngine', () => { // unbuffered seek or adaptation. SourceBuffer mode is 'sequence' now. reference = dummyReference(0, 1000); appendVideo = mediaSourceEngine.appendBuffer( - ContentType.VIDEO, buffer, reference, /* hasClosedCaptions= */ false, - /* seeked= */ true); + ContentType.VIDEO, buffer, reference, fakeStream, + /* hasClosedCaptions= */ false, /* seeked= */ true); // First, wait for abort(), used to reset the parser state for a safe // setting of timestampOffset. await Util.shortDelay(); @@ -903,11 +910,11 @@ describe('MediaSourceEngine', () => { it('waits for all previous operations to complete', async () => { /** @type {!shaka.test.StatusPromise} */ const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( - ContentType.VIDEO, buffer, null, + ContentType.VIDEO, buffer, null, fakeStream, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p3 = new shaka.test.StatusPromise(mediaSourceEngine.endOfStream()); @@ -931,11 +938,11 @@ describe('MediaSourceEngine', () => { /** @type {!Promise} */ const p1 = mediaSourceEngine.endOfStream(); mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer2, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); // endOfStream hasn't been called yet because blocking multiple queues // takes an extra tick, even when they are empty. @@ -963,7 +970,7 @@ describe('MediaSourceEngine', () => { /** @type {!Promise} */ const p1 = mediaSourceEngine.endOfStream(); mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled(); @@ -994,11 +1001,11 @@ describe('MediaSourceEngine', () => { it('waits for all previous operations to complete', async () => { /** @type {!shaka.test.StatusPromise} */ const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( - ContentType.VIDEO, buffer, null, + ContentType.VIDEO, buffer, null, fakeStream, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p3 = @@ -1023,11 +1030,11 @@ describe('MediaSourceEngine', () => { /** @type {!Promise} */ const p1 = mediaSourceEngine.setDuration(100); mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer2, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); // The setter hasn't been called yet because blocking multiple queues // takes an extra tick, even when they are empty. @@ -1056,7 +1063,7 @@ describe('MediaSourceEngine', () => { /** @type {!Promise} */ const p1 = mediaSourceEngine.setDuration(100); mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled(); @@ -1095,9 +1102,9 @@ describe('MediaSourceEngine', () => { // This is tested because shrinking duration generates 'updateend' // events, and we want to show that the queue still operates correctly. const a1 = mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); const a2 = mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); await p1; await a1; @@ -1117,9 +1124,9 @@ describe('MediaSourceEngine', () => { it('waits for all operations to complete', async () => { mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null, - /* hasClosedCaptions= */ false); + fakeStream, /* hasClosedCaptions= */ false); /** @type {!shaka.test.StatusPromise} */ const d = new shaka.test.StatusPromise(mediaSourceEngine.destroy()); @@ -1136,7 +1143,7 @@ describe('MediaSourceEngine', () => { it('resolves even when a pending operation fails', async () => { const p = mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false); const d = mediaSourceEngine.destroy(); @@ -1161,10 +1168,10 @@ describe('MediaSourceEngine', () => { it('cancels operations that have not yet started', async () => { mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false); const rejected = mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer2, null, + ContentType.AUDIO, buffer2, null, fakeStream, /* hasClosedCaptions= */ false); // Create the expectation first so we don't get unhandled rejection errors const expected = expectAsync(rejected).toBeRejected(); @@ -1188,7 +1195,7 @@ describe('MediaSourceEngine', () => { it('cancels blocking operations that have not yet started', async () => { const p1 = mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false); const p2 = mediaSourceEngine.endOfStream(); const d = mediaSourceEngine.destroy(); @@ -1203,7 +1210,7 @@ describe('MediaSourceEngine', () => { const d = mediaSourceEngine.destroy(); await expectAsync( mediaSourceEngine.appendBuffer( - ContentType.AUDIO, buffer, null, + ContentType.AUDIO, buffer, null, fakeStream, /* hasClosedCaptions= */ false)) .toBeRejected(); await d; diff --git a/test/mss/mss_parser_unit.js b/test/mss/mss_parser_unit.js new file mode 100644 index 00000000000..13342d25842 --- /dev/null +++ b/test/mss/mss_parser_unit.js @@ -0,0 +1,293 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Test basic manifest parsing functionality. +describe('MssParser Manifest', () => { + // const ManifestParser = shaka.test.ManifestParser; + const Mss = shaka.test.Mss; + + /** @type {!shaka.test.FakeNetworkingEngine} */ + let fakeNetEngine; + /** @type {!shaka.mss.MssParser} */ + let parser; + /** @type {!jasmine.Spy} */ + let onEventSpy; + /** @type {shaka.extern.ManifestParser.PlayerInterface} */ + let playerInterface; + + const h264CodecPrivateData = '000000016764001FAC2CA5014016EFFC100010014808' + + '080A000007D200017700C100005A648000B4C9FE31C6080002D3240005A64FF18E1DA' + + '12251600000000168E9093525'; + + const aacCodecPrivateData = '1210'; + + /** @param {!shaka.extern.Manifest} manifest */ + async function loadAllStreamsFor(manifest) { + const promises = []; + for (const variant of manifest.variants) { + for (const stream of [variant.video, variant.audio]) { + if (stream) { + promises.push(stream.createSegmentIndex()); + } + } + } + for (const text of manifest.textStreams) { + promises.push(text.createSegmentIndex()); + } + await Promise.all(promises); + } + + beforeEach(() => { + fakeNetEngine = new shaka.test.FakeNetworkingEngine(); + parser = Mss.makeMssParser(); + onEventSpy = jasmine.createSpy('onEvent'); + playerInterface = { + networkingEngine: fakeNetEngine, + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, + filter: (manifest) => Promise.resolve(), + makeTextStreamsForClosedCaptions: (manifest) => {}, + onTimelineRegionAdded: fail, // Should not have any EventStream elements. + onEvent: shaka.test.Util.spyFunc(onEventSpy), + onError: fail, + isLowLatencyMode: () => false, + isAutoLowLatencyMode: () => false, + enableLowLatencyMode: () => {}, + updateDuration: () => {}, + newDrmInfo: (stream) => {}, + }; + }); + + describe('fails for', () => { + it('invalid XML', async () => { + const source = ' { + const source = [ + '', + ' ', + ' ', + ' ', + ' ', + ].join('\n'); + const error = new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.MSS_INVALID_XML, + 'dummy://foo'); + await Mss.testFails(source, error); + }); + + it('failed network requests', async () => { + const expectedError = new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.NETWORK, + shaka.util.Error.Code.BAD_HTTP_STATUS); + + fakeNetEngine.request.and.returnValue( + shaka.util.AbortableOperation.failed(expectedError)); + await expectAsync(parser.start('', playerInterface)) + .toBeRejectedWith(shaka.test.Util.jasmineError(expectedError)); + }); + + it('missing SmoothStreamingMedia element', async () => { + const source = ''; + const error = new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.MSS_INVALID_XML, + 'dummy://foo'); + await Mss.testFails(source, error); + }); + }); + + it('Disable audio does not create audio streams', async () => { + const manifestText = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + + fakeNetEngine.setResponseText('dummy://foo', manifestText); + const config = shaka.util.PlayerConfiguration.createDefault().manifest; + config.disableAudio = true; + parser.configure(config); + + /** @type {shaka.extern.Manifest} */ + const manifest = await parser.start('dummy://foo', playerInterface); + const variant = manifest.variants[0]; + expect(variant.audio).toBe(null); + expect(variant.video).toBeTruthy(); + }); + + it('Disable video does not create video streams', async () => { + const manifestText = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + + fakeNetEngine.setResponseText('dummy://foo', manifestText); + const config = shaka.util.PlayerConfiguration.createDefault().manifest; + config.disableVideo = true; + parser.configure(config); + + /** @type {shaka.extern.Manifest} */ + const manifest = await parser.start('dummy://foo', playerInterface); + const variant = manifest.variants[0]; + expect(variant.audio).toBeTruthy(); + expect(variant.video).toBe(null); + }); + + it('Disable text does not create text streams', async () => { + const manifestText = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + + fakeNetEngine.setResponseText('dummy://foo', manifestText); + const config = shaka.util.PlayerConfiguration.createDefault().manifest; + config.disableText = true; + parser.configure(config); + + /** @type {shaka.extern.Manifest} */ + const manifest = await parser.start('dummy://foo', playerInterface); + const stream = manifest.textStreams[0]; + expect(stream).toBeUndefined(); + }); + + it('Invokes manifestPreprocessor in config', async () => { + const manifestText = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + + fakeNetEngine.setResponseText('dummy://foo', manifestText); + const config = shaka.util.PlayerConfiguration.createDefault().manifest; + config.mss.manifestPreprocessor = (mss) => { + const selector = 'StreamIndex[Name="text"'; + const vttElements = mss.querySelectorAll(selector); + for (const element of vttElements) { + element.parentNode.removeChild(element); + } + }; + parser.configure(config); + + /** @type {shaka.extern.Manifest} */ + const manifest = await parser.start('dummy://foo', playerInterface); + const stream = manifest.textStreams[0]; + expect(stream).toBeUndefined(); + }); + + it('generate a fake init segment', async () => { + const manifestText = [ + '', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + + fakeNetEngine.setResponseText('dummy://foo', manifestText); + + const manifest = await parser.start('dummy://foo', playerInterface); + const segmentReference = + await Mss.getFirstAudioSegmentReference(manifest); + const initSegmentReference = segmentReference.initSegmentReference; + expect(initSegmentReference.getUris()).toEqual([]); + expect(initSegmentReference.getStartByte()).toBe(0); + expect(initSegmentReference.getEndByte()).toBe(null); + expect(initSegmentReference.getSegmentData()).toBeDefined(); + }); + + // it('check the correct segmentIndex', async () => { + // const manifestText = [ + // '', + // ' ', + // ' ', + // ' ', + // ' ', + // '', + // ].join('\n'); + + // const baseUri = 'dummy://foo'; + + // const references = [ + // ManifestParser.makeReference('128000/0', 0, 3, baseUri), + // ManifestParser.makeReference('128000/30000000', 3, 6, baseUri), + // ]; + // await Mss.testSegmentIndex(manifestText, references); + // }); +}); diff --git a/test/test/util/mss_parser_util.js b/test/test/util/mss_parser_util.js new file mode 100644 index 00000000000..ca5fd14e383 --- /dev/null +++ b/test/test/util/mss_parser_util.js @@ -0,0 +1,115 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @summary Utilities for working with the MSS parser. */ +shaka.test.Mss = class { + /** + * Constructs and configures a very simple MSS parser. + * @return {!shaka.mss.MssParser} + */ + static makeMssParser() { + const parser = new shaka.mss.MssParser(); + const config = shaka.util.PlayerConfiguration.createDefault().manifest; + parser.configure(config); + return parser; + } + + /** + * Tests the segment index produced by the MSS manifest parser. + * + * @param {string} manifestText + * @param {!Array.} references + * @return {!Promise} + */ + static async testSegmentIndex(manifestText, references) { + const buffer = shaka.util.StringUtils.toUTF8(manifestText); + const mssParser = shaka.test.Mss.makeMssParser(); + + const networkingEngine = new shaka.test.FakeNetworkingEngine() + .setResponseValue('dummy://foo', buffer); + + const playerInterface = { + networkingEngine: networkingEngine, + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, + filter: () => {}, + makeTextStreamsForClosedCaptions: (manifest) => {}, + onTimelineRegionAdded: fail, + onEvent: fail, + onError: fail, + isLowLatencyMode: () => false, + isAutoLowLatencyMode: () => false, + enableLowLatencyMode: () => {}, + updateDuration: () => {}, + newDrmInfo: (stream) => {}, + }; + const manifest = await mssParser.start('dummy://foo', playerInterface); + const stream = manifest.variants[0].audio; + await stream.createSegmentIndex(); + + shaka.test.ManifestParser.verifySegmentIndex(stream, references); + } + + /** + * Tests that the MSS manifest parser fails to parse the given manifest. + * + * @param {string} manifestText + * @param {!shaka.util.Error} expectedError + * @return {!Promise} + */ + static async testFails(manifestText, expectedError) { + const manifestData = shaka.util.StringUtils.toUTF8(manifestText); + const mssParser = shaka.test.Mss.makeMssParser(); + + const networkingEngine = new shaka.test.FakeNetworkingEngine() + .setResponseValue('dummy://foo', manifestData); + + const playerInterface = { + networkingEngine: networkingEngine, + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, + filter: () => {}, + makeTextStreamsForClosedCaptions: (manifest) => {}, + onTimelineRegionAdded: fail, // Should not have any EventStream elements. + onEvent: fail, + onError: fail, + isLowLatencyMode: () => false, + isAutoLowLatencyMode: () => false, + enableLowLatencyMode: () => {}, + updateDuration: () => {}, + newDrmInfo: (stream) => {}, + }; + const p = mssParser.start('dummy://foo', playerInterface); + await expectAsync(p).toBeRejectedWith( + shaka.test.Util.jasmineError(expectedError)); + } + + /** + * @param {shaka.extern.Manifest} manifest + * @return {!Promise.} + */ + static async getFirstAudioSegmentReference(manifest) { + const variant = manifest.variants[0]; + expect(variant).not.toBe(null); + if (!variant) { + return null; + } + + const audio = variant.audio; + expect(audio).not.toBe(null); + if (!audio) { + return null; + } + + await audio.createSegmentIndex(); + const position = audio.segmentIndex.find(0); + goog.asserts.assert(position != null, 'Position should not be null!'); + + const reference = audio.segmentIndex.get(position); + goog.asserts.assert(reference != null, 'Reference should not be null!'); + return reference; + } +};