diff --git a/build/types/core b/build/types/core index 11a4331663..b3e9ae7546 100644 --- a/build/types/core +++ b/build/types/core @@ -76,6 +76,7 @@ +../../lib/util/functional.js +../../lib/util/i_destroyable.js +../../lib/util/i_releasable.js ++../../lib/util/id3_utils.js +../../lib/util/iterables.js +../../lib/util/language_utils.js +../../lib/util/lazy.js @@ -101,6 +102,7 @@ +../../lib/util/switch_history.js +../../lib/util/text_parser.js +../../lib/util/timer.js ++../../lib/util/ts_parser.js +../../lib/util/uint8array_utils.js +../../lib/util/xml_utils.js diff --git a/externs/mux.js b/externs/mux.js index a285a99487..49eecd8f81 100644 --- a/externs/mux.js +++ b/externs/mux.js @@ -81,15 +81,13 @@ muxjs.mp4.Transmuxer = class { * @typedef {{ * initSegment: !Uint8Array, * data: !Uint8Array, - * captions: !Array, - * metadata: !Array + * captions: !Array * }} * * @description Transmuxed data from mux.js. * @property {!Uint8Array} initSegment * @property {!Uint8Array} data * @property {!Array} captions - * @property {!Array} metadata * @exportDoc */ muxjs.mp4.Transmuxer.Segment; @@ -170,42 +168,3 @@ muxjs.mp4.ParsedClosedCaptions; */ muxjs.mp4.ClosedCaption; - -/** - * @typedef {{ - * cueTime: number, - * data: !Uint8Array, - * dispatchType: string, - * dts: number, - * frames: !Array., - * pts: number - * }} - * - * @description metadata parsed from mux.js. - * @property {number} cueTime - * @property {number} data - * @property {number} dispatchType - * @property {number} dts - * @property {string} frames - * @property {string} pts - */ -muxjs.mp4.Metadata; - - -/** - * @typedef {{ - * data: string, - * description: string, - * id: string, - * key: string, - * value: string - * }} - * - * @description metadata parsed from mux.js. - * @property {number} data - * @property {number} description - * @property {number} id - * @property {number} key - * @property {string} value - */ -muxjs.mp4.MetadataFrame; diff --git a/externs/shaka/ads.js b/externs/shaka/ads.js index af83b7c046..ef9803c703 100644 --- a/externs/shaka/ads.js +++ b/externs/shaka/ads.js @@ -120,7 +120,7 @@ shaka.extern.IAdManager = class extends EventTarget { onHlsTimedMetadata(metadata, timestampOffset) {} /** - * @param {shaka.extern.ID3Metadata} value + * @param {shaka.extern.MetadataFrame} value */ onCueMetadataChange(value) {} }; diff --git a/externs/shaka/player.js b/externs/shaka/player.js index 3414a40a8e..e657f13c21 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -437,20 +437,63 @@ shaka.extern.DrmSupportType; */ shaka.extern.SupportType; - /** - * @typedef {!Object.} + * @typedef {{ + * cueTime: ?number, + * data: !Uint8Array, + * frames: !Array., + * dts: ?number, + * pts: ?number + * }} * * @description * ID3 metadata in format defined by * https://id3.org/id3v2.3.0#Declared_ID3v2_frames * The content of the field. * + * @property {?number} cueTime + * @property {!Uint8Array} data + * @property {!Array.} frames + * @property {?number} dts + * @property {?number} pts + * * @exportDoc */ shaka.extern.ID3Metadata; +/** + * @typedef {{ + * type: string, + * size: number, + * data: Uint8Array + * }} + * + * @description metadata raw frame. + * @property {string} type + * @property {number} size + * @property {Uint8Array} data + * @exportDoc + */ +shaka.extern.MetadataRawFrame; + + +/** + * @typedef {{ + * key: string, + * data: (ArrayBuffer|string), + * description: string + * }} + * + * @description metadata frame parsed. + * @property {string} key + * @property {ArrayBuffer|string} data + * @property {string} description + * @exportDoc + */ +shaka.extern.MetadataFrame; + + /** * @typedef {{ * schemeIdUri: string, diff --git a/lib/ads/server_side_ad_manager.js b/lib/ads/server_side_ad_manager.js index de8bbd16d8..1cc2ce6c43 100644 --- a/lib/ads/server_side_ad_manager.js +++ b/lib/ads/server_side_ad_manager.js @@ -237,7 +237,7 @@ shaka.ads.ServerSideAdManager = class { } /** - * @param {shaka.extern.ID3Metadata} value + * @param {shaka.extern.MetadataFrame} value */ onCueMetadataChange(value) { // Native HLS over Safari/iOS/iPadOS @@ -246,9 +246,9 @@ shaka.ads.ServerSideAdManager = class { // done through timed metadata. Timed metadata is carried as part of the // DAI stream content and carries ad break timing information used by the // SDK to track ad breaks. - if (value['key'] && value['data']) { + if (value.key && value.data) { const metadata = {}; - metadata[value['key']] = value['data']; + metadata[value.key] = value.data; this.streamManager_.onTimedMetadata(metadata); } } diff --git a/lib/media/media_source_engine.js b/lib/media/media_source_engine.js index f61e7b3edc..e7f2544b7b 100644 --- a/lib/media/media_source_engine.js +++ b/lib/media/media_source_engine.js @@ -14,15 +14,18 @@ goog.require('shaka.media.SegmentReference'); goog.require('shaka.media.TimeRangesUtils'); goog.require('shaka.media.Transmuxer'); goog.require('shaka.text.TextEngine'); +goog.require('shaka.util.BufferUtils'); goog.require('shaka.util.Destroyer'); goog.require('shaka.util.Error'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.Functional'); goog.require('shaka.util.IDestroyable'); +goog.require('shaka.util.Id3Utils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Platform'); goog.require('shaka.util.PublicPromise'); +goog.require('shaka.util.TsParser'); goog.require('shaka.lcevc.Dil'); @@ -568,6 +571,35 @@ shaka.media.MediaSourceEngine = class { return; } + const uint8ArrayData = shaka.util.BufferUtils.toUint8(data); + if (shaka.util.TsParser.probe(uint8ArrayData)) { + const metadata = new shaka.util.TsParser().parse(uint8ArrayData) + .getMetadata(); + if (metadata.length) { + const timestampOffset = + this.sourceBuffers_[contentType].timestampOffset; + this.onMetadata_(metadata, timestampOffset, + reference ? reference.endTime : null); + } + } else { + const containerType = shaka.util.MimeUtils.getContainerType( + this.sourceBufferTypes_[contentType]); + if (containerType === 'aac') { + const frames = shaka.util.Id3Utils.getID3Frames(uint8ArrayData); + if (frames.length && reference) { + /** @private {shaka.extern.ID3Metadata} */ + const metadata = { + cueTime: reference.startTime, + data: uint8ArrayData, + frames: frames, + dts: reference.startTime, + pts: reference.startTime, + }; + this.onMetadata_([metadata], /* offset= */ 0, reference.endTime); + } + } + } + if (this.transmuxers_[contentType]) { const transmuxedData = await this.transmuxers_[contentType].transmux(data); @@ -576,15 +608,6 @@ shaka.media.MediaSourceEngine = class { if (!this.textEngine_) { this.reinitText('text/vtt', this.sequenceMode_); } - - if (transmuxedData.metadata) { - const timestampOffset = - this.sourceBuffers_[contentType].timestampOffset; - this.onMetadata_( - transmuxedData.metadata, - timestampOffset, - reference ? reference.endTime : null); - } // This doesn't work for native TS support (ex. Edge/Chromecast), // since no transmuxing is needed for native TS. if (transmuxedData.captions && transmuxedData.captions.length) { diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index bbea75c032..c74e2920c3 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -24,6 +24,7 @@ goog.require('shaka.util.Destroyer'); goog.require('shaka.util.Error'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.IDestroyable'); +goog.require('shaka.util.Id3Utils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4Parser'); @@ -1814,6 +1815,21 @@ shaka.media.StreamingEngine = class { // A special scheme in DASH used to signal manifest updates. if (schemeId == 'urn:mpeg:dash:event:2012') { this.playerInterface_.onManifestUpdate(); + } else if (schemeId == 'https://aomedia.org/emsg/ID3') { + // See https://aomediacodec.github.io/id3-emsg/ + const frames = shaka.util.Id3Utils.getID3Frames(messageData); + if (frames.length && reference) { + /** @private {shaka.extern.ID3Metadata} */ + const metadata = { + cueTime: reference.startTime, + data: messageData, + frames: frames, + dts: reference.startTime, + pts: reference.startTime, + }; + this.playerInterface_.onMetadata( + [metadata], /* offset= */ 0, reference.endTime); + } } else { /** @type {shaka.extern.EmsgInfo} */ const emsg = { @@ -2191,7 +2207,8 @@ shaka.media.StreamingEngine = class { * !shaka.util.ManifestParserUtils.ContentType), * onInitSegmentAppended: function(!number,!shaka.media.InitSegmentReference), * beforeAppendSegment: function( - * shaka.util.ManifestParserUtils.ContentType,!BufferSource):Promise + * shaka.util.ManifestParserUtils.ContentType,!BufferSource):Promise, + * onMetadata: !function(!Array., number, ?number) * }} * * @property {function():number} getPresentationTime @@ -2224,6 +2241,9 @@ shaka.media.StreamingEngine = class { * @property {!function(shaka.util.ManifestParserUtils.ContentType, * !BufferSource):Promise} beforeAppendSegment * A function called just before appending to the source buffer. + * @property + * {!function(!Array., number, ?number)} onMetadata + * Called when an ID3 is found in a EMSG. */ shaka.media.StreamingEngine.PlayerInterface; diff --git a/lib/media/transmuxer.js b/lib/media/transmuxer.js index 5119c2e255..b1f144112c 100644 --- a/lib/media/transmuxer.js +++ b/lib/media/transmuxer.js @@ -42,9 +42,6 @@ shaka.media.Transmuxer = class { /** @private {!Array.} */ this.captions_ = []; - /** @private {!Array.} */ - this.metadata_ = []; - /** @private {boolean} */ this.isTransmuxing_ = false; @@ -152,8 +149,7 @@ shaka.media.Transmuxer = class { * Transmux from Transport stream to MP4, using the mux.js library. * @param {BufferSource} data * @return {!Promise.<{data: !Uint8Array, - * captions: !Array., - * metadata: !Array.}>} + * captions: !Array.}>} */ transmux(data) { goog.asserts.assert(!this.isTransmuxing_, @@ -162,7 +158,6 @@ shaka.media.Transmuxer = class { this.transmuxPromise_ = new shaka.util.PublicPromise(); this.transmuxedData_ = []; this.captions_ = []; - this.metadata_ = []; const dataArray = shaka.util.BufferUtils.toUint8(data); this.muxTransmuxer_.push(dataArray); @@ -194,7 +189,6 @@ shaka.media.Transmuxer = class { */ onTransmuxed_(segment) { this.captions_ = segment.captions; - this.metadata_ = segment.metadata; this.transmuxedData_.push( shaka.util.Uint8ArrayUtils.concat(segment.initSegment, segment.data)); } @@ -209,7 +203,6 @@ shaka.media.Transmuxer = class { const output = { data: shaka.util.Uint8ArrayUtils.concat(...this.transmuxedData_), captions: this.captions_, - metadata: this.metadata_, }; this.transmuxPromise_.resolve(output); diff --git a/lib/player.js b/lib/player.js index 8ee87352fe..2d79ad13a0 100644 --- a/lib/player.js +++ b/lib/player.js @@ -328,7 +328,7 @@ goog.requireType('shaka.routing.Payload'); * the cue applies. * @property {string} metadataType * Type of metadata. Eg: org.id3 or org.mp4ra - * @property {shaka.extern.ID3Metadata} payload + * @property {shaka.extern.MetadataFrame} payload * The metadata itself * @exportDoc */ @@ -2706,11 +2706,11 @@ shaka.Player = class extends shaka.util.FakeEventTarget { */ processTimedMetadataMediaSrc_(metadata, offset, segmentEndTime) { for (const sample of metadata) { - if (sample['data'] && sample['cueTime'] && sample['frames']) { - const start = sample['cueTime'] + offset; + if (sample.data && sample.cueTime && sample.frames) { + const start = sample.cueTime + offset; const end = segmentEndTime; - const metadataType = 'ID3'; - for (const frame of sample['frames']) { + const metadataType = 'org.id3'; + for (const frame of sample.frames) { const payload = frame; this.dispatchMetadataEvent_(start, end, metadataType, payload); } @@ -2729,7 +2729,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { * @param {number} startTime * @param {?number} endTime * @param {string} metadataType - * @param {shaka.extern.ID3Metadata} payload + * @param {shaka.extern.MetadataFrame} payload * @private */ dispatchMetadataEvent_(startTime, endTime, metadataType, payload) { @@ -3101,6 +3101,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget { beforeAppendSegment: (contentType, segment) => { return this.drmEngine_.parseInbandPssh(contentType, segment); }, + onMetadata: (metadata, offset, endTime) => { + this.processTimedMetadataMediaSrc_(metadata, offset, endTime); + }, }; return new shaka.media.StreamingEngine(this.manifest_, playerInterface); diff --git a/lib/util/id3_utils.js b/lib/util/id3_utils.js new file mode 100644 index 0000000000..86824c2f58 --- /dev/null +++ b/lib/util/id3_utils.js @@ -0,0 +1,300 @@ +/*! @license + * Shaka Player + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.util.Id3Utils'); + +goog.require('shaka.log'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.StringUtils'); + + +/** + * @summary A set of Id3Utils utility functions. + * @export + */ +shaka.util.Id3Utils = class { + /** + * @param {Uint8Array} data + * @param {number} offset + * @return {boolean} + * @private + */ + static isHeader_(data, offset) { + /* + * http://id3.org/id3v2.3.0 + * [0] = 'I' + * [1] = 'D' + * [2] = '3' + * [3,4] = {Version} + * [5] = {Flags} + * [6-9] = {ID3 Size} + * + * An ID3v2 tag can be detected with the following pattern: + * $49 44 33 yy yy xx zz zz zz zz + * Where yy is less than $FF, xx is the 'flags' byte and zz is less than $80 + */ + if (offset + 10 <= data.length) { + // look for 'ID3' identifier + if (data[offset] === 0x49 && + data[offset + 1] === 0x44 && + data[offset + 2] === 0x33) { + // check version is within range + if (data[offset + 3] < 0xff && data[offset + 4] < 0xff) { + // check size is within range + if (data[offset + 6] < 0x80 && + data[offset + 7] < 0x80 && + data[offset + 8] < 0x80 && + data[offset + 9] < 0x80) { + return true; + } + } + } + } + + return false; + } + + /** + * @param {Uint8Array} data + * @param {number} offset + * @return {boolean} + * @private + */ + static isFooter_(data, offset) { + /* + * The footer is a copy of the header, but with a different identifier + */ + if (offset + 10 <= data.length) { + // look for '3DI' identifier + if (data[offset] === 0x33 && + data[offset + 1] === 0x44 && + data[offset + 2] === 0x49) { + // check version is within range + if (data[offset + 3] < 0xff && data[offset + 4] < 0xff) { + // check size is within range + if (data[offset + 6] < 0x80 && + data[offset + 7] < 0x80 && + data[offset + 8] < 0x80 && + data[offset + 9] < 0x80) { + return true; + } + } + } + } + + return false; + } + + /** + * @param {Uint8Array} data + * @param {number} offset + * @return {number} + * @private + */ + static readSize_(data, offset) { + let size = 0; + size = (data[offset] & 0x7f) << 21; + size |= (data[offset + 1] & 0x7f) << 14; + size |= (data[offset + 2] & 0x7f) << 7; + size |= data[offset + 3] & 0x7f; + return size; + } + + /** + * @param {Uint8Array} data + * @return {shaka.extern.MetadataRawFrame} + * @private + */ + static getFrameData_(data) { + /* + * Frame ID $xx xx xx xx (four characters) + * Size $xx xx xx xx + * Flags $xx xx + */ + const type = String.fromCharCode(data[0], data[1], data[2], data[3]); + const size = shaka.util.Id3Utils.readSize_(data, 4); + + // skip frame id, size, and flags + const offset = 10; + + return { + type, + size, + data: data.subarray(offset, offset + size), + }; + } + + /** + * @param {shaka.extern.MetadataRawFrame} frame + * @return {?shaka.extern.MetadataFrame} + * @private + */ + static decodeFrame_(frame) { + const BufferUtils = shaka.util.BufferUtils; + const StringUtils = shaka.util.StringUtils; + + const metadataFrame = { + key: frame.type, + description: '', + data: '', + }; + + if (frame.type === 'TXXX') { + /* + * Format: + * [0] = {Text Encoding} + * [1-?] = {Description}\0{Value} + */ + if (frame.size < 2) { + return null; + } + if (frame.data[0] !== shaka.util.Id3Utils.UTF8_encoding) { + shaka.log.warning('Ignore frame with unrecognized character ' + + 'encoding'); + return null; + } + const descriptionEndIndex = frame.data.subarray(1).indexOf(0); + + if (descriptionEndIndex === -1) { + return null; + } + const description = StringUtils.fromUTF8( + BufferUtils.toUint8(frame.data, 1, descriptionEndIndex)); + const data = StringUtils.fromUTF8( + BufferUtils.toUint8(frame.data, 2 + descriptionEndIndex)) + .replace(/\0*$/, ''); + + metadataFrame.description = description; + metadataFrame.data = data; + return metadataFrame; + } else if (frame.type === 'WXXX') { + /* + * Format: + * [0] = {Text Encoding} + * [1-?] = {Description}\0{URL} + */ + if (frame.size < 2) { + return null; + } + if (frame.data[0] !== shaka.util.Id3Utils.UTF8_encoding) { + shaka.log.warning('Ignore frame with unrecognized character ' + + 'encoding'); + return null; + } + const descriptionEndIndex = frame.data.subarray(1).indexOf(0); + + if (descriptionEndIndex === -1) { + return null; + } + const description = StringUtils.fromUTF8( + BufferUtils.toUint8(frame.data, 1, descriptionEndIndex)); + const data = StringUtils.fromUTF8( + BufferUtils.toUint8(frame.data, 2 + descriptionEndIndex)) + .replace(/\0*$/, ''); + + metadataFrame.description = description; + metadataFrame.data = data; + return metadataFrame; + } else if (frame.type === 'PRIV') { + /* + * Format: \0 + */ + if (frame.size < 2) { + return null; + } + const textEndIndex = frame.data.indexOf(0); + if (textEndIndex === -1) { + return null; + } + const text = StringUtils.fromUTF8( + BufferUtils.toUint8(frame.data, 0, textEndIndex)); + const data = BufferUtils.toArrayBuffer( + frame.data.subarray(text.length + 1)); + metadataFrame.description = text; + metadataFrame.data = data; + return metadataFrame; + } else if (frame.type[0] === 'T') { + /* + * Format: + * [0] = {Text Encoding} + * [1-?] = {Value} + */ + if (frame.size < 2) { + return null; + } + if (frame.data[0] !== shaka.util.Id3Utils.UTF8_encoding) { + shaka.log.warning('Ignore frame with unrecognized character ' + + 'encoding'); + return null; + } + const text = StringUtils.fromUTF8(frame.data.subarray(1)) + .replace(/\0*$/, ''); + metadataFrame.data = text; + return metadataFrame; + } else if (frame.type[0] === 'W') { + /* + * Format: + * [0-?] = {URL} + */ + const url = StringUtils.fromUTF8(frame.data) + .replace(/\0*$/, ''); + metadataFrame.data = url; + return metadataFrame; + } else if (frame.data) { + shaka.log.warning('Unrecognized ID3 frame type:', frame.type); + metadataFrame.data = BufferUtils.toArrayBuffer(frame.data); + return metadataFrame; + } + + return null; + } + + /** + * Returns an array of ID3 frames found in all the ID3 tags in the id3Data + * @param {Uint8Array} id3Data - The ID3 data containing one or more ID3 tags + * @return {!Array.} + * @export + */ + static getID3Frames(id3Data) { + const Id3Utils = shaka.util.Id3Utils; + let offset = 0; + const frames = []; + while (Id3Utils.isHeader_(id3Data, offset)) { + const size = Id3Utils.readSize_(id3Data, offset + 6); + + if ((id3Data[offset + 5] >> 6) & 1) { + // skip extended header + offset += 10; + } + // skip past ID3 header + offset += 10; + + const end = offset + size; + // loop through frames in the ID3 tag + while (offset + 10 < end) { + const frameData = Id3Utils.getFrameData_(id3Data.subarray(offset)); + const frame = Id3Utils.decodeFrame_(frameData); + if (frame) { + frames.push(frame); + } + + // skip frame header and frame data + offset += frameData.size + 10; + } + + if (Id3Utils.isFooter_(id3Data, offset)) { + offset += 10; + } + } + return frames; + } +}; + +/** + * UTF8 encoding byte + * @const {number} + */ +shaka.util.Id3Utils.UTF8_encoding = 0x03; diff --git a/lib/util/ts_parser.js b/lib/util/ts_parser.js new file mode 100644 index 0000000000..8f77dc7ad8 --- /dev/null +++ b/lib/util/ts_parser.js @@ -0,0 +1,427 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.util.TsParser'); + +goog.require('shaka.log'); +goog.require('shaka.util.Id3Utils'); + + +/** + * @see https://en.wikipedia.org/wiki/MPEG_transport_stream + * @export + */ +shaka.util.TsParser = class { + /** */ + constructor() { + /** @private {?number} */ + this.pmtId_ = null; + + /** @private {boolean} */ + this.pmtParsed_ = false; + + /** @private {?number} */ + this.videoPid_ = null; + + /** @private {?string} */ + this.videoCodec_ = null; + + /** @private {!Array.} */ + this.videoData_ = []; + + /** @private {?number} */ + this.audioPid_ = null; + + /** @private {?string} */ + this.audioCodec_ = null; + + /** @private {!Array.} */ + this.audioData_ = []; + + /** @private {?number} */ + this.id3Pid_ = null; + + /** @private {!Array.} */ + this.id3Data_ = []; + } + + /** + * Parse the given data + * + * @param {Uint8Array} data + * @return {!shaka.util.TsParser} + */ + parse(data) { + const packetLength = shaka.util.TsParser.PacketLength_; + + // A TS fragment should contain at least 3 TS packets, a PAT, a PMT, and + // one PID. + if (data.length < 3 * packetLength) { + return this; + } + const syncOffset = Math.max(0, shaka.util.TsParser.syncOffset(data)); + + const length = data.length - (data.length + syncOffset) % packetLength; + + let unknownPIDs = false; + + // loop through TS packets + for (let start = syncOffset; start < length; start += packetLength) { + if (data[start] === 0x47) { + const payloadUnitStartIndicator = !!(data[start + 1] & 0x40); + // pid is a 13-bit field starting at the last 5 bits of TS[1] + const pid = ((data[start + 1] & 0x1f) << 8) + data[start + 2]; + const adaptationFieldControl = (data[start + 3] & 0x30) >> 4; + + // if an adaption field is present, its length is specified by the + // fifth byte of the TS packet header. + let offset; + if (adaptationFieldControl > 1) { + offset = start + 5 + data[start + 4]; + // continue if there is only adaptation field + if (offset === start + packetLength) { + continue; + } + } else { + offset = start + 4; + } + switch (pid) { + case 0: + if (payloadUnitStartIndicator) { + offset += data[offset] + 1; + } + + this.pmtId_ = this.getPmtId_(data, offset); + break; + case 17: + case 0x1fff: + break; + case this.pmtId_: { + if (payloadUnitStartIndicator) { + offset += data[offset] + 1; + } + + const parsedPIDs = this.parsePMT(data, offset); + + // only update track id if track PID found while parsing PMT + // this is to avoid resetting the PID to -1 in case + // track PID transiently disappears from the stream + // this could happen in case of transient missing audio samples + // for example + // NOTE this is only the PID of the track as found in TS, + // but we are not using this for MP4 track IDs. + if (this.videoPid_ == null) { + this.videoPid_ = parsedPIDs.video; + this.videoCodec_ = parsedPIDs.videoCodec; + } + if (this.audioPid_ == null) { + this.audioPid_ = parsedPIDs.audio; + this.audioCodec_ = parsedPIDs.audioCodec; + } + if (this.id3Pid_ == null) { + this.id3Pid_ = parsedPIDs.id3; + } + + if (unknownPIDs && !this.pmtParsed_) { + shaka.log.debug('reparse from beginning'); + unknownPIDs = false; + // we set it to -188, the += 188 in the for loop will reset + // start to 0 + start = syncOffset - packetLength; + } + this.pmtParsed_ = true; + break; + } + case this.videoPid_: + this.videoData_.push(data.subarray(offset, start + packetLength)); + break; + case this.audioPid_: + this.audioData_.push(data.subarray(offset, start + packetLength)); + break; + case this.id3Pid_: + this.id3Data_.push(data.subarray(offset, start + packetLength)); + break; + default: + unknownPIDs = true; + break; + } + } else { + shaka.log.warning('Found TS packet that do not start with 0x47'); + } + } + return this; + } + + /** + * Get the PMT ID from the PAT + * + * @param {Uint8Array} data + * @param {number} offset + * @return {number} + * @private + */ + getPmtId_(data, offset) { + // skip the PSI header and parse the first PMT entry + return ((data[offset + 10] & 0x1f) << 8) | data[offset + 11]; + } + + /** + * Parse PMT + * + * @param {Uint8Array} data + * @param {number} offset + * @return {!shaka.util.TsParser.PMT} + */ + parsePMT(data, offset) { + const result = { + audio: -1, + video: -1, + id3: -1, + audioCodec: 'aac', + videoCodec: 'avc', + }; + const sectionLength = ((data[offset + 1] & 0x0f) << 8) | data[offset + 2]; + const tableEnd = offset + 3 + sectionLength - 4; + // to determine where the table is, we have to figure out how + // long the program info descriptors are + const programInfoLength = + ((data[offset + 10] & 0x0f) << 8) | data[offset + 11]; + // advance the offset to the first entry in the mapping table + offset += 12 + programInfoLength; + while (offset < tableEnd) { + const pid = ((data[offset + 1] & 0x1f) << 8) | data[offset + 2]; + switch (data[offset]) { + // SAMPLE-AES AAC + case 0xcf: + break; + // ISO/IEC 13818-7 ADTS AAC (MPEG-2 lower bit-rate audio) + case 0x0f: + if (result.audio === -1) { + result.audio = pid; + } + break; + // Packetized metadata (ID3) + case 0x15: + if (result.id3 === -1) { + result.id3 = pid; + } + break; + // SAMPLE-AES AVC + case 0xdb: + break; + // ITU-T Rec. H.264 and ISO/IEC 14496-10 (lower bit-rate video) + case 0x1b: + if (result.video === -1) { + result.video = pid; + } + break; + // ISO/IEC 11172-3 (MPEG-1 audio) + // or ISO/IEC 13818-3 (MPEG-2 halved sample rate audio) + case 0x03: + case 0x04: + if (result.audio === -1) { + result.audio = pid; + result.audioCodec = 'mp3'; + } + break; + // HEVC + case 0x24: + if (result.video === -1) { + result.video = pid; + result.videoCodec = 'hvc'; + } + break; + default: + // shaka.log.warning('Unknown stream type:', data[offset]); + break; + } + // move to the next table entry + // skip past the elementary stream descriptors, if present + offset += (((data[offset + 3] & 0x0f) << 8) | data[offset + 4]) + 5; + } + return result; + } + + /** + * Parse PES + * + * @param {Uint8Array} data + * @return {?shaka.util.TsParser.PES} + */ + parsePES(data) { + const startPrefix = (data[0] << 16) | (data[1] << 8) | data[2]; + // In certain live streams, the start of a TS fragment has ts packets + // that are frame data that is continuing from the previous fragment. This + // is to check that the pes data is the start of a new pes data + if (startPrefix !== 1) { + return null; + } + /** @type {shaka.util.TsParser.PES} */ + const pes = { + data: new Uint8Array(0), + // get the packet length, this will be 0 for video + packetLength: 6 + ((data[4] << 8) | data[5]), + pts: null, + dts: null, + }; + + // PES packets may be annotated with a PTS value, or a PTS value + // and a DTS value. Determine what combination of values is + // available to work with. + const ptsDtsFlags = data[7]; + + // PTS and DTS are normally stored as a 33-bit number. Javascript + // performs all bitwise operations on 32-bit integers but javascript + // supports a much greater range (52-bits) of integer using standard + // mathematical operations. + // We construct a 31-bit value using bitwise operators over the 31 + // most significant bits and then multiply by 4 (equal to a left-shift + // of 2) before we add the final 2 least significant bits of the + // timestamp (equal to an OR.) + if (ptsDtsFlags & 0xC0) { + // the PTS and DTS are not written out directly. For information + // on how they are encoded, see + // http://dvd.sourceforge.net/dvdinfo/pes-hdr.html + pes.pts = + (data[9] & 0x0e) * 536870912 + // 1 << 29 + (data[10] & 0xff) * 4194304 + // 1 << 22 + (data[11] & 0xfe) * 16384 + // 1 << 14 + (data[12] & 0xff) * 128 + // 1 << 7 + (data[13] & 0xfe) / 2; + + pes.dts = pes.pts; + if (ptsDtsFlags & 0x40) { + pes.dts = + (data[14] & 0x0e) * 536870912 + // 1 << 29 + (data[15] & 0xff) * 4194304 + // 1 << 22 + (data[16] & 0xfe) * 16384 + // 1 << 14 + (data[17] & 0xff) * 128 + // 1 << 7 + (data[18] & 0xfe) / 2; + } + } + // the data section starts immediately after the PES header. + // pes_header_data_length specifies the number of header bytes + // that follow the last byte of the field. + pes.data = data.subarray(9 + data[8]); + + return pes; + } + + /** + * Return the ID3 metadata + * + * @return {!Array.} + */ + getMetadata() { + const metadata = []; + for (const data of this.id3Data_) { + const pes = this.parsePES(data); + if (pes) { + metadata.push({ + cueTime: pes.pts ? pes.pts / 90000 : null, + data: pes.data, + frames: shaka.util.Id3Utils.getID3Frames(pes.data), + dts: pes.dts, + pts: pes.pts, + }); + } + } + return metadata; + } + + /** + * Check if the passed data corresponds to an MPEG2-TS + * + * @param {Uint8Array} data + * @return {boolean} + */ + static probe(data) { + const syncOffset = shaka.util.TsParser.syncOffset(data); + if (syncOffset < 0) { + return false; + } else { + if (syncOffset > 0) { + shaka.log.warning('MPEG2-TS detected but first sync word found @ ' + + 'offset ' + syncOffset + ', junk ahead ?'); + } + return true; + } + } + + /** + * Returns the synchronization offset + * + * @param {Uint8Array} data + * @return {number} + */ + static syncOffset(data) { + const packetLength = shaka.util.TsParser.PacketLength_; + // scan 1000 first bytes + const scanwindow = Math.min(1000, data.length - 3 * packetLength); + let i = 0; + while (i < scanwindow) { + // a TS fragment should contain at least 3 TS packets, a PAT, a PMT, and + // one PID, each starting with 0x47 + if (data[i] === 0x47 && + data[i + packetLength] === 0x47 && + data[i + 2 * packetLength] === 0x47) { + return i; + } else { + i++; + } + } + return -1; + } +}; + + +/** + * @const {number} + * @private + */ +shaka.util.TsParser.PacketLength_ = 188; + + +/** + * @typedef {{ + * audio: number, + * video: number, + * id3: number, + * audioCodec: string, + * videoCodec: string + * }} + * + * @summary PMT. + * @property {number} audio + * Audio PID + * @property {number} video + * Video PID + * @property {number} id3 + * ID3 PID + * @property {string} audioCodec + * Audio codec + * @property {string} videoCodec + * Video codec + */ +shaka.util.TsParser.PMT; + + +/** + * @typedef {{ + * data: Uint8Array, + * packetLength: number, + * pts: ?number, + * dts: ?number + * }} + * + * @summary PES. + * @property {Uint8Array} data + * @property {number} packetLength + * @property {?number} pts + * @property {?number} dts + */ +shaka.util.TsParser.PES; + diff --git a/test/media/media_source_engine_integration.js b/test/media/media_source_engine_integration.js index b5c78b4020..fa893c38e0 100644 --- a/test/media/media_source_engine_integration.js +++ b/test/media/media_source_engine_integration.js @@ -26,6 +26,9 @@ describe('MediaSourceEngine', () => { */ let textDisplayer; + /** @type {!jasmine.Spy} */ + let onMetadata; + beforeAll(() => { video = shaka.test.UiUtils.createVideoElement(); document.body.appendChild(video); @@ -37,10 +40,13 @@ describe('MediaSourceEngine', () => { textDisplayer = new shaka.test.FakeTextDisplayer(); + onMetadata = jasmine.createSpy('onMetadata'); + mediaSourceEngine = new shaka.media.MediaSourceEngine( video, new shaka.media.ClosedCaptionParser(), - textDisplayer); + textDisplayer, + shaka.test.Util.spyFunc(onMetadata)); const config = shaka.util.PlayerConfiguration.createDefault().mediaSource; mediaSourceEngine.configure(config); @@ -434,4 +440,33 @@ describe('MediaSourceEngine', () => { expect(textDisplayer.appendSpy).toHaveBeenCalled(); }); + + it('extracts ID3 metadata from TS', async () => { + metadata = shaka.test.TestScheme.DATA['id3-metadata_ts']; + generators = shaka.test.TestScheme.GENERATORS['id3-metadata_ts']; + + const audioType = ContentType.AUDIO; + const initObject = new Map(); + initObject.set(audioType, getFakeStream(metadata.audio)); + await mediaSourceEngine.init(initObject, /* forceTransmuxTS= */ false); + await append(ContentType.AUDIO, 0); + + expect(onMetadata).toHaveBeenCalled(); + }); + + it('extracts ID3 metadata from AAC', async () => { + if (!MediaSource.isTypeSupported('audio/aac')) { + return; + } + metadata = shaka.test.TestScheme.DATA['id3-metadata_aac']; + generators = shaka.test.TestScheme.GENERATORS['id3-metadata_aac']; + + const audioType = ContentType.AUDIO; + const initObject = new Map(); + initObject.set(audioType, getFakeStream(metadata.audio)); + await mediaSourceEngine.init(initObject, /* forceTransmuxTS= */ false); + await append(ContentType.AUDIO, 0); + + expect(onMetadata).toHaveBeenCalled(); + }); }); diff --git a/test/media/streaming_engine_integration.js b/test/media/streaming_engine_integration.js index 5248707175..cb2ce06c8f 100644 --- a/test/media/streaming_engine_integration.js +++ b/test/media/streaming_engine_integration.js @@ -251,6 +251,7 @@ describe('StreamingEngine', () => { onSegmentAppended: () => playhead.notifyOfBufferingChange(), onInitSegmentAppended: () => {}, beforeAppendSegment: () => Promise.resolve(), + onMetadata: () => {}, }; streamingEngine = new shaka.media.StreamingEngine( /** @type {shaka.extern.Manifest} */(manifest), playerInterface); diff --git a/test/media/streaming_engine_unit.js b/test/media/streaming_engine_unit.js index 0d6b1dd694..d26a247e60 100644 --- a/test/media/streaming_engine_unit.js +++ b/test/media/streaming_engine_unit.js @@ -74,6 +74,8 @@ describe('StreamingEngine', () => { let streamingEngine; /** @type {!jasmine.Spy} */ let beforeAppendSegment; + /** @type {!jasmine.Spy} */ + let onMetadata; /** @type {function(function(), number)} */ let realSetTimeout; @@ -432,6 +434,7 @@ describe('StreamingEngine', () => { onManifestUpdate = jasmine.createSpy('onManifestUpdate'); onSegmentAppended = jasmine.createSpy('onSegmentAppended'); beforeAppendSegment = jasmine.createSpy('beforeAppendSegment'); + onMetadata = jasmine.createSpy('onMetadata'); getBandwidthEstimate = jasmine.createSpy('getBandwidthEstimate'); getBandwidthEstimate.and.returnValue(1e3); @@ -461,6 +464,7 @@ describe('StreamingEngine', () => { onSegmentAppended: Util.spyFunc(onSegmentAppended), onInitSegmentAppended: () => {}, beforeAppendSegment: Util.spyFunc(beforeAppendSegment), + onMetadata: Util.spyFunc(onMetadata), }; streamingEngine = new shaka.media.StreamingEngine( /** @type {shaka.extern.Manifest} */(manifest), playerInterface); @@ -2848,6 +2852,52 @@ describe('StreamingEngine', () => { expect(onEvent).not.toHaveBeenCalled(); expect(onManifestUpdate).toHaveBeenCalled(); }); + + it('triggers metadata event', async () => { + // This is an 'emsg' box that contains a scheme of + // https://aomedia.org/emsg/ID to indicate a ID3 metadata. + segmentData[ContentType.VIDEO].segments[0] = + Uint8ArrayUtils.fromHex(( + // 105 bytes emsg box v0, flags 0 + '00 00 00 69 65 6d 73 67 00 00 00 00' + + + // scheme id uri (13 bytes) 'https://aomedia.org/emsg/ID3' + '68 74 74 70 73 3a 2f 2f 61 6f 6d 65 64 69 61 2e' + + '6f 72 67 2f 65 6d 73 67 2f 49 44 33 00' + + + // value (1 byte) '' + '00' + + + // timescale (4 bytes) 49 + '00 00 00 31' + + + // presentation time delta (4 bytes) 8 + '00 00 00 08' + + + // event duration (4 bytes) 255 + '00 00 00 ff' + + + // id (4 bytes) 51 + '00 00 00 33' + + + // message data (47 bytes) + '49 44 33 03 00 40 00 00 00 1b 00 00 00 06 00 00' + + '00 00 00 02 54 58 58 58 00 00 00 07 e0 00 03 00' + + '53 68 61 6b 61 33 44 49 03 00 40 00 00 00 1b' + ).replaceAll(' ', '')); + + videoStream.emsgSchemeIdUris = ['https://aomedia.org/emsg/ID3']; + + // Here we go! + streamingEngine.switchVariant(variant); + streamingEngine.switchTextStream(textStream); + await streamingEngine.start(); + playing = true; + await runTest(); + + expect(onEvent).not.toHaveBeenCalled(); + expect(onMetadata).toHaveBeenCalled(); + }); }); describe('embedded emsg boxes with non zero timestamps', () => { diff --git a/test/test/assets/id3-metadata.aac b/test/test/assets/id3-metadata.aac new file mode 100644 index 0000000000..220a47823b Binary files /dev/null and b/test/test/assets/id3-metadata.aac differ diff --git a/test/test/assets/id3-metadata.ts b/test/test/assets/id3-metadata.ts new file mode 100644 index 0000000000..08ab29670f Binary files /dev/null and b/test/test/assets/id3-metadata.ts differ diff --git a/test/test/util/id3_generator.js b/test/test/util/id3_generator.js new file mode 100644 index 0000000000..2775897609 --- /dev/null +++ b/test/test/util/id3_generator.js @@ -0,0 +1,100 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @summary + * A helper class used to generate ID3 metadata. + */ +shaka.test.Id3Generator = class { + /** + * Generate an ID3 from a frames. + * + * @param {!Uint8Array} frames + * @param {boolean=} extendedHeader + * @return {!Uint8Array} + */ + static generateId3(frames, extendedHeader = false) { + const Id3Generator = shaka.test.Id3Generator; + const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; + let result = Uint8ArrayUtils.concat( + Id3Generator.stringToInts_('ID3'), + new Uint8Array([ + 0x03, 0x00, // version 3.0 of ID3v2 (aka ID3v.2.3.0) + 0x40, // flags. include an extended header + 0x00, 0x00, 0x00, 0x00, // size. set later + // extended header + 0x00, 0x00, 0x00, 0x06, // extended header size. no CRC + 0x00, 0x00, // extended flags + 0x00, 0x00, 0x00, 0x02, // size of padding + ]), + frames); + if (!extendedHeader) { + result = Uint8ArrayUtils.concat( + Id3Generator.stringToInts_('ID3'), + new Uint8Array([ + 0x03, 0x00, // version 3.0 of ID3v2 (aka ID3v.2.3.0) + 0x00, // flags + 0x00, 0x00, 0x00, 0x00, // size. set later + ]), + frames); + } + + // size is stored as a sequence of four 7-bit integers with the + // high bit of each byte set to zero + const size = result.length - 10; + + result[6] = (size >>> 21) & 0x7f; + result[7] = (size >>> 14) & 0x7f; + result[8] = (size >>> 7) & 0x7f; + result[9] = size & 0x7f; + + return result; + } + + /** + * Generate an ID3 frame from a type and value. + * + * @param {string} type + * @param {!Uint8Array} value + * @return {!Uint8Array} + */ + static generateId3Frame(type, value) { + goog.asserts.assert(type, 'type must be non-null'); + goog.asserts.assert(type.length == 4, 'type must contain 4 characters'); + const Id3Generator = shaka.test.Id3Generator; + const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; + const result = Uint8ArrayUtils.concat( + Id3Generator.stringToInts_(type), + new Uint8Array([ + 0x00, 0x00, 0x00, 0x00, // size + 0xe0, 0x00, // flags + ]), + value); + + // set the size + const size = result.length - 10; + + result[4] = (size >>> 21) & 0x7f; + result[5] = (size >>> 14) & 0x7f; + result[6] = (size >>> 7) & 0x7f; + result[7] = size & 0x7f; + + return result; + } + + /** + * @param {string} string + * @return {!Uint8Array} + * @private + */ + static stringToInts_(string) { + const result = []; + for (let i = 0; i < string.length; i++) { + result[i] = string.charCodeAt(i); + } + return new Uint8Array(result); + } +}; diff --git a/test/test/util/stream_generator.js b/test/test/util/stream_generator.js index 471a57d99f..3e9d9d786f 100644 --- a/test/test/util/stream_generator.js +++ b/test/test/util/stream_generator.js @@ -83,6 +83,44 @@ shaka.test.TSVodStreamGenerator = class { } }; +/** + * @summary + * Simulates an HLS, video-on-demand, aac stream. The StreamGenerator assumes + * the stream contains a single segment. + * + * @implements {shaka.test.IStreamGenerator} + */ +shaka.test.AACVodStreamGenerator = class { + /** @param {string} segmentUri The URI of the segment. */ + constructor(segmentUri) { + /** @private {string} */ + this.segmentUri_ = segmentUri; + + /** @private {ArrayBuffer} */ + this.segment_ = null; + } + + /** @override */ + async init() { + const segment = await shaka.test.Util.fetch(this.segmentUri_); + this.segment_ = segment; + } + + /** @override */ + getInitSegment(time) { + goog.asserts.assert(false, 'getInitSegment not implemented for HLS VOD.'); + return new ArrayBuffer(0); + } + + /** @override */ + getSegment(position, wallClockTime) { + goog.asserts.assert( + this.segment_, + 'init() must be called before getSegment().'); + return this.segment_; + } +}; + /** * @summary * Simulates a DASH, video-on-demand, MP4 stream. The StreamGenerator loops a diff --git a/test/test/util/test_scheme.js b/test/test/util/test_scheme.js index a443075900..3abeba54c8 100644 --- a/test/test/util/test_scheme.js +++ b/test/test/util/test_scheme.js @@ -175,6 +175,9 @@ shaka.test.TestScheme = class { if (metadata.segmentUri.includes('.ts')) { return new shaka.test.TSVodStreamGenerator(metadata.segmentUri); } + if (metadata.segmentUri.includes('.aac')) { + return new shaka.test.AACVodStreamGenerator(metadata.segmentUri); + } return new shaka.test.Mp4VodStreamGenerator( metadata.initSegmentUri, metadata.mdhdOffset, metadata.segmentUri, metadata.tfdtOffset, metadata.segmentDuration); @@ -652,6 +655,26 @@ shaka.test.TestScheme.DATA = { }, duration: 30, }, + + 'id3-metadata_ts': { + audio: { + segmentUri: '/base/test/test/assets/id3-metadata.ts', + mimeType: 'video/mp2t', + codecs: 'mp4a.40.5', + segmentDuration: 4.99, + }, + duration: 4.99, + }, + + 'id3-metadata_aac': { + audio: { + segmentUri: '/base/test/test/assets/id3-metadata.aac', + mimeType: 'audio/aac', + codecs: '', + segmentDuration: 9.98458, + }, + duration: 9.98458, + }, }; diff --git a/test/util/id3_utils_unit.js b/test/util/id3_utils_unit.js new file mode 100644 index 0000000000..ae4d5e114f --- /dev/null +++ b/test/util/id3_utils_unit.js @@ -0,0 +1,116 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('Id3Utils', () => { + const Id3Utils = shaka.util.Id3Utils; + const Id3Generator = shaka.test.Id3Generator; + const BufferUtils = shaka.util.BufferUtils; + + it('no valid data produces empty output', () => { + expect(Id3Utils.getID3Frames(new Uint8Array([]))).toEqual([]); + }); + + it('parse a TXXX frame', () => { + const txxxValue = new Uint8Array([3, 65, 0, 83, 104, 97, 107, 97]); + const txxxFrame = Id3Generator.generateId3Frame('TXXX', txxxValue); + const txxxID3 = Id3Generator.generateId3(txxxFrame); + const expectedID3 = [ + { + key: 'TXXX', + description: 'A', + data: 'Shaka', + }, + ]; + expect(Id3Utils.getID3Frames(txxxID3)).toEqual(expectedID3); + }); + + it('parse a TXXX frame with extended header', () => { + const txxxValue = new Uint8Array([3, 65, 0, 83, 104, 97, 107, 97]); + const txxxFrame = Id3Generator.generateId3Frame('TXXX', txxxValue); + const txxxID3 = Id3Generator.generateId3(txxxFrame, true); + const expectedID3 = [ + { + key: 'TXXX', + description: 'A', + data: 'Shaka', + }, + ]; + expect(Id3Utils.getID3Frames(txxxID3)).toEqual(expectedID3); + }); + + it('parse a TCOP frame', () => { + const tcopValue = new Uint8Array( + [3, 83, 104, 97, 107, 97, 32, 50, 48, 49, 54]); + const tcopFrame = Id3Generator.generateId3Frame('TCOP', tcopValue); + const tcopID3 = Id3Generator.generateId3(tcopFrame); + const expectedID3 = [ + { + key: 'TCOP', + description: '', + data: 'Shaka 2016', + }, + ]; + expect(Id3Utils.getID3Frames(tcopID3)).toEqual(expectedID3); + }); + + it('parse a WXXX frame', () => { + const wxxxValue = new Uint8Array( + [3, 65, 0, 103, 111, 111, 103, 108, 101, 46, 99, 111, 109]); + const wxxxFrame = Id3Generator.generateId3Frame('WXXX', wxxxValue); + const wxxxID3 = Id3Generator.generateId3(wxxxFrame); + const expectedID3 = [ + { + key: 'WXXX', + description: 'A', + data: 'google.com', + }, + ]; + expect(Id3Utils.getID3Frames(wxxxID3)).toEqual(expectedID3); + }); + + it('parse a WCOP frame', () => { + const wcopValue = new Uint8Array( + [103, 111, 111, 103, 108, 101, 46, 99, 111, 109]); + const wcopFrame = Id3Generator.generateId3Frame('WCOP', wcopValue); + const wcopID3 = Id3Generator.generateId3(wcopFrame); + const expectedID3 = [ + { + key: 'WCOP', + description: '', + data: 'google.com', + }, + ]; + expect(Id3Utils.getID3Frames(wcopID3)).toEqual(expectedID3); + }); + + it('parse a PRIV frame', () => { + const privValue = new Uint8Array([65, 0, 83, 104, 97, 107]); + const privFrame = Id3Generator.generateId3Frame('PRIV', privValue); + const privID3 = Id3Generator.generateId3(privFrame); + const expectedID3 = [ + { + key: 'PRIV', + description: 'A', + data: BufferUtils.toArrayBuffer(new Uint8Array([83, 104, 97, 107])), + }, + ]; + expect(Id3Utils.getID3Frames(privID3)).toEqual(expectedID3); + }); + + it('parse an unknown frame', () => { + const unknownValue = new Uint8Array([83, 104, 97, 107]); + const unknownFrame = Id3Generator.generateId3Frame('XXXX', unknownValue); + const unknownID3 = Id3Generator.generateId3(unknownFrame); + const expectedID3 = [ + { + key: 'XXXX', + description: '', + data: BufferUtils.toArrayBuffer(new Uint8Array([83, 104, 97, 107])), + }, + ]; + expect(Id3Utils.getID3Frames(unknownID3)).toEqual(expectedID3); + }); +}); diff --git a/test/util/ts_parser_unit.js b/test/util/ts_parser_unit.js new file mode 100644 index 0000000000..3744a6796a --- /dev/null +++ b/test/util/ts_parser_unit.js @@ -0,0 +1,51 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('TsParser', () => { + const Util = shaka.test.Util; + const BufferUtils = shaka.util.BufferUtils; + + it('probes a TS segment', async () => { + const responses = await Promise.all([ + Util.fetch('/base/test/test/assets/video.ts'), + Util.fetch('/base/test/test/assets/audio.ts'), + ]); + const videoSegment = BufferUtils.toUint8(responses[0]); + const audioSegment = BufferUtils.toUint8(responses[1]); + expect(shaka.util.TsParser.probe(videoSegment)).toBeTruthy(); + expect(shaka.util.TsParser.probe(audioSegment)).toBeTruthy(); + }); + + it('probes a non TS segment', async () => { + const responses = await Promise.all([ + Util.fetch('/base/test/test/assets/small.mp4'), + ]); + const nonTsSegment = BufferUtils.toUint8(responses[0]); + expect(shaka.util.TsParser.probe(nonTsSegment)).toBeFalsy(); + }); + + it('parses a TS segment', async () => { + const responses = await Promise.all([ + Util.fetch('/base/test/test/assets/video.ts'), + Util.fetch('/base/test/test/assets/audio.ts'), + ]); + const videoSegment = BufferUtils.toUint8(responses[0]); + const audioSegment = BufferUtils.toUint8(responses[1]); + expect(new shaka.util.TsParser().parse(videoSegment)).toBeDefined(); + expect(new shaka.util.TsParser().parse(audioSegment)).toBeDefined(); + }); + + it('parses a TS segment with metadata', async () => { + const responses = await Promise.all([ + Util.fetch('/base/test/test/assets/id3-metadata.ts'), + ]); + const tsSegment = BufferUtils.toUint8(responses[0]); + const metadata = new shaka.util.TsParser().parse(tsSegment) + .getMetadata(); + expect(metadata).toBeTruthy(); + expect(metadata.length).toBe(2); + }); +});