diff --git a/demo/common/message_ids.js b/demo/common/message_ids.js index 84a7fa5770..3946435791 100644 --- a/demo/common/message_ids.js +++ b/demo/common/message_ids.js @@ -178,6 +178,7 @@ shakaDemo.MessageIds = { IGNORE_DASH_DRM: 'DEMO_IGNORE_DASH_DRM', IGNORE_DASH_MAX_SEGMENT_DURATION: 'DEMO_IGNORE_DASH_MAX_SEGMENT_DURATION', IGNORE_DASH_SUGGESTED_PRESENTATION_DELAY: 'DEMO_IGNORE_DASH_SUGGESTED_PRESENTATION_DELAY', + IGNORE_HLS_IMAGE_FAILURES: 'DEMO_IGNORE_HLS_IMAGE_FAILURES', IGNORE_HLS_TEXT_FAILURES: 'DEMO_IGNORE_HLS_TEXT_FAILURES', USE_FULL_SEGMENTS_FOR_START_TIME: 'DEMO_USE_FULL_SEGMENTS_FOR_START_TIME', IGNORE_MIN_BUFFER_TIME: 'DEMO_IGNORE_MIN_BUFFER_TIME', diff --git a/demo/config.js b/demo/config.js index 1040f91433..1fa232a556 100644 --- a/demo/config.js +++ b/demo/config.js @@ -206,6 +206,8 @@ shakaDemo.Config = class { 'manifest.dash.ignoreMaxSegmentDuration') .addBoolInput_(MessageIds.IGNORE_HLS_TEXT_FAILURES, 'manifest.hls.ignoreTextStreamFailures') + .addBoolInput_(MessageIds.IGNORE_HLS_IMAGE_FAILURES, + 'manifest.hls.ignoreImageStreamFailures') .addBoolInput_(MessageIds.USE_FULL_SEGMENTS_FOR_START_TIME, 'manifest.hls.useFullSegmentsForStartTime') .addNumberInput_(MessageIds.AVAILABILITY_WINDOW_OVERRIDE, diff --git a/demo/locales/en.json b/demo/locales/en.json index d4606f9c85..d83f30c3c1 100644 --- a/demo/locales/en.json +++ b/demo/locales/en.json @@ -87,6 +87,7 @@ "DEMO_IGNORE_DASH_EMPTY_ADAPTATION_SET": "Ignore empty DASH AdaptationSets", "DEMO_IGNORE_DASH_MAX_SEGMENT_DURATION": "Ignore DASH maxSegmentDuration", "DEMO_IGNORE_DASH_SUGGESTED_PRESENTATION_DELAY": "Ignore DASH suggestedPresentationDelay", + "DEMO_IGNORE_HLS_IMAGE_FAILURES": "Ignore HLS Image Stream Failures", "DEMO_IGNORE_HLS_TEXT_FAILURES": "Ignore HLS Text Stream Failures", "DEMO_IMA_ASSET_KEY": "Asset key (for LIVE DAI Content)", "DEMO_IMA_CONTENT_SRC_ID": "Content source ID (for VOD DAI Content)", diff --git a/demo/locales/source.json b/demo/locales/source.json index 85065d443c..fc77a844e4 100644 --- a/demo/locales/source.json +++ b/demo/locales/source.json @@ -355,6 +355,10 @@ "description": "The name of a configuration value.", "message": "Ignore [PROPER_NAME:HLS] Text Stream Failures" }, + "DEMO_IGNORE_HLS_IMAGE_FAILURES": { + "description": "The name of a configuration value.", + "message": "Ignore [PROPER_NAME:HLS] Image Stream Failures" + }, "DEMO_IMA_ASSET_KEY": { "description": "The label on a field that allows users to provide an asset key for a custom asset.", "message": "Asset key (for LIVE DAI Content)" diff --git a/externs/shaka/manifest.js b/externs/shaka/manifest.js index 92e1e5bbe4..968e8fafb3 100644 --- a/externs/shaka/manifest.js +++ b/externs/shaka/manifest.js @@ -333,7 +333,7 @@ shaka.extern.CreateSegmentIndexFunction; * The Stream's label, unique text that should describe the audio/text track. * @property {string} type * Required.
- * Content type (e.g. 'video', 'audio' or 'text') + * Content type (e.g. 'video', 'audio' or 'text', 'image') * @property {boolean} primary * Defaults to false.
* True indicates that the player should use this Stream over others if user diff --git a/externs/shaka/offline.js b/externs/shaka/offline.js index 3ba632cd0a..ea7b6f06de 100644 --- a/externs/shaka/offline.js +++ b/externs/shaka/offline.js @@ -194,6 +194,7 @@ shaka.extern.StreamDB; * appendWindowStart: number, * appendWindowEnd: number, * timestampOffset: number, + * tilesLayout: ?string, * dataKey: number * }} * @@ -210,6 +211,10 @@ shaka.extern.StreamDB; * @property {number} timestampOffset * An offset which MediaSource will add to the segment's media timestamps * during ingestion, to align to the presentation timeline. + * @property {?string} tilesLayout + * The value is a grid-item-dimension consisting of two positive decimal + * integers in the format: column-x-row ('4x3'). It describes the + * arrangement of Images in a Grid. The minimum valid LAYOUT is '1x1'. * @property {number} dataKey * The key to the data in storage. */ diff --git a/externs/shaka/player.js b/externs/shaka/player.js index be6eaa6982..8e4b8c2a0a 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -692,12 +692,16 @@ shaka.extern.DashManifestConfiguration; /** * @typedef {{ * ignoreTextStreamFailures: boolean, + * ignoreImageStreamFailures: boolean, * useFullSegmentsForStartTime: boolean * }} * * @property {boolean} ignoreTextStreamFailures * If true, ignore any errors in a text stream and filter out * those streams. + * @property {boolean} ignoreImageStreamFailures + * If true, ignore any errors in a image stream and filter out + * those streams. * @property {boolean} useFullSegmentsForStartTime * If true, force HlsParser to use a full segment request for * determining start time in case the server does not support partial requests diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index c4b8baa776..9953513b37 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -92,7 +92,8 @@ shaka.hls.HlsParser = class { * timestamps, offsets, and to handle TS rollover. * * During parsing, used to avoid duplicates in the async methods - * createStreamInfoFromMediaTag_ and createStreamInfoFromVariantTag_. + * createStreamInfoFromMediaTag_, createStreamInfoFromImageTag_ and + * createStreamInfoFromVariantTag_. * * During parsing of updates, used by getStartTime_ to determine the start * time of the first segment from existing segment references. @@ -403,6 +404,9 @@ shaka.hls.HlsParser = class { /** @type {!Array.} */ const variantTags = Utils.filterTagsByName( playlist.tags, 'EXT-X-STREAM-INF'); + /** @type {!Array.} */ + const imageTags = Utils.filterTagsByName( + playlist.tags, 'EXT-X-IMAGE-STREAM-INF'); this.parseCodecs_(variantTags); @@ -439,6 +443,7 @@ shaka.hls.HlsParser = class { this.parseClosedCaptions_(mediaTags); const variants = await this.createVariantsForTags_(variantTags); const textStreams = await this.parseTexts_(mediaTags); + const imageStreams = await this.parseImages_(imageTags); // Make sure that the parser has not been destroyed. if (!this.playerInterface_) { @@ -533,7 +538,7 @@ shaka.hls.HlsParser = class { presentationTimeline: this.presentationTimeline_, variants, textStreams, - imageStreams: [], + imageStreams, offlineSessionIds: [], minBufferTime: 0, }; @@ -666,6 +671,34 @@ shaka.hls.HlsParser = class { return textStreams.filter((s) => s); } + /** + * @param {!Array.} imageTags from the playlist. + * @return {!Promise.>} + * @private + */ + async parseImages_(imageTags) { + // Create image stream for each image tag. + const imageStreamPromises = imageTags.map(async (tag) => { + const disableThumbnails = this.config_.disableThumbnails; + if (disableThumbnails) { + return null; + } + try { + const streamInfo = await this.createStreamInfoFromImageTag_(tag); + goog.asserts.assert( + streamInfo, 'Should always have a streamInfo for image'); + return streamInfo.stream; + } catch (e) { + if (this.config_.hls.ignoreImageStreamFailures) { + return null; + } + throw e; + } + }); + const imageStreams = await Promise.all(imageStreamPromises); + return imageStreams.filter((s) => s); + } + /** * @param {!Array.} mediaTags Media tags from the playlist. * @private @@ -1183,6 +1216,51 @@ shaka.hls.HlsParser = class { return streamInfo; } + /** + * Parse EXT-X-MEDIA media tag into a Stream object. + * + * @param {shaka.hls.Tag} tag + * @return {!Promise.} + * @private + */ + async createStreamInfoFromImageTag_(tag) { + goog.asserts.assert(tag.name == 'EXT-X-IMAGE-STREAM-INF', + 'Should only be called on image tags!'); + /** @type {string} */ + const type = shaka.util.ManifestParserUtils.ContentType.IMAGE; + + const verbatimImagePlaylistUri = this.variableSubstitution_( + tag.getRequiredAttrValue('URI'), this.globalVariables_); + const codecs = tag.getAttributeValue('CODECS', 'jpeg') || ''; + + // Check if the stream has already been created as part of another Variant + // and return it if it has. + if (this.uriToStreamInfosMap_.has(verbatimImagePlaylistUri)) { + return this.uriToStreamInfosMap_.get(verbatimImagePlaylistUri); + } + + const language = this.getLanguage_(tag); + const name = tag.getAttributeValue('NAME'); + + const characteristics = tag.getAttributeValue('CHARACTERISTICS'); + + const streamInfo = await this.createStreamInfo_( + verbatimImagePlaylistUri, codecs, type, language, /* primary= */ false, + name, /* channelsCount= */ null, /* closedCaptions= */ null, + characteristics, /* forced= */ false, /* spatialAudio= */ false); + if (streamInfo == null) { + return null; + } + + // TODO: This check is necessary because of the possibility of multiple + // calls to createStreamInfoFromImageTag_ before either has resolved. + if (this.uriToStreamInfosMap_.has(verbatimImagePlaylistUri)) { + return this.uriToStreamInfosMap_.get(verbatimImagePlaylistUri); + } + this.uriToStreamInfosMap_.set(verbatimImagePlaylistUri, streamInfo); + return streamInfo; + } + /** * Parse an EXT-X-STREAM-INF media tag into a Stream object. * @@ -1255,7 +1333,8 @@ shaka.hls.HlsParser = class { response.data, absoluteMediaPlaylistUri); if (playlist.type != shaka.hls.PlaylistType.MEDIA) { - // EXT-X-MEDIA tags should point to media playlists. + // EXT-X-MEDIA and EXT-X-IMAGE-STREAM-INF tags should point to media + // playlists. throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, @@ -1618,11 +1697,12 @@ shaka.hls.HlsParser = class { * @param {!Map.} variables * @param {string} absoluteMediaPlaylistUri * @return {!shaka.media.SegmentReference} + * @param {string} type * @private */ createSegmentReference_( initSegmentReference, previousReference, hlsSegment, startTime, - timestampOffset, variables, absoluteMediaPlaylistUri) { + timestampOffset, variables, absoluteMediaPlaylistUri, type) { const tags = hlsSegment.tags; const absoluteSegmentUri = this.variableSubstitution_( hlsSegment.absoluteUri, variables); @@ -1713,6 +1793,17 @@ shaka.hls.HlsParser = class { endByte = partialSegmentRefs[partialSegmentRefs.length - 1].endByte; } + let tilesLayout = ''; + if (type == shaka.util.ManifestParserUtils.ContentType.IMAGE) { + // By default in HLS the tilesLayout is 1x1 + tilesLayout = '1x1'; + const tilesTag = + shaka.hls.Utils.getFirstTagWithName(tags, 'EXT-X-TILES'); + if (tilesTag) { + tilesLayout = tilesTag.getRequiredAttrValue('LAYOUT'); + } + } + return new shaka.media.SegmentReference( startTime, endTime, @@ -1724,6 +1815,7 @@ shaka.hls.HlsParser = class { /* appendWindowStart= */ 0, /* appendWindowEnd= */ Infinity, partialSegmentRefs, + tilesLayout, ); } @@ -1819,12 +1911,13 @@ shaka.hls.HlsParser = class { initSegmentRef = this.getInitSegmentReference_( playlist.absoluteUri, hlsSegments[0].tags, variables); goog.asserts.assert( - type != shaka.util.ManifestParserUtils.ContentType.TEXT, + type != shaka.util.ManifestParserUtils.ContentType.TEXT && + type != shaka.util.ManifestParserUtils.ContentType.IMAGE, 'Should only get start time from audio or video streams'); this.playlistStartTime_ = await this.getStartTime_( verbatimMediaPlaylistUri, initSegmentRef, mimeType, position, /* isDiscontinuity= */ false, - hlsSegments[0], variables); + hlsSegments[0], variables, type); } firstStartTime = this.playlistStartTime_; } @@ -1877,7 +1970,7 @@ shaka.hls.HlsParser = class { // eslint-disable-next-line no-await-in-loop timestampOffset = await this.getTimestampOffset_( discontintuitySequenceNum, verbatimMediaPlaylistUri, initSegmentRef, - mimeType, position, item, variables, startTime); + mimeType, position, item, variables, startTime, type); } // If the stream is low latency and the user has not configured the @@ -1902,7 +1995,8 @@ shaka.hls.HlsParser = class { startTime, timestampOffset, variables, - playlist.absoluteUri); + playlist.absoluteUri, + type); references.push(reference); } else if (!this.lowLatencyMode_) { @@ -1932,13 +2026,14 @@ shaka.hls.HlsParser = class { * @param {!shaka.hls.Segment} segment * @param {!Map.} variables * @param {number} startTime + * @param {string} type * @return {!Promise.} * @throws {shaka.util.Error} * @private */ async getTimestampOffset_(discontintuitySequenceNum, verbatimMediaPlaylistUri, initSegmentRef, - mimeType, mediaSequenceNumber, segment, variables, startTime) { + mimeType, mediaSequenceNumber, segment, variables, startTime, type) { let timestampOffset = 0; if (this.discontinuityToTso_.has(discontintuitySequenceNum)) { timestampOffset = @@ -1947,7 +2042,7 @@ shaka.hls.HlsParser = class { const mediaStartTime = await this.getStartTime_( verbatimMediaPlaylistUri, initSegmentRef, mimeType, mediaSequenceNumber, /* isDiscontinuity= */ true, segment, - variables); + variables, type); timestampOffset = startTime - mediaStartTime; shaka.log.v1('Segment timestampOffset =', timestampOffset); this.discontinuityToTso_.set( @@ -2034,12 +2129,13 @@ shaka.hls.HlsParser = class { * @param {boolean} isDiscontinuity * @param {!shaka.hls.Segment} segment * @param {!Map.} variables + * @param {string} type * @return {!Promise.} * @private */ async getStartTime_( verbatimMediaPlaylistUri, initSegmentRef, mimeType, mediaSequenceNumber, - isDiscontinuity, segment, variables) { + isDiscontinuity, segment, variables, type) { const segmentRef = this.createSegmentReference_( initSegmentRef, /* previousReference= */ null, @@ -2047,7 +2143,8 @@ shaka.hls.HlsParser = class { /* startTime= */ 0, /* timestampOffset= */ 0, variables, - /* absoluteMediaPlaylistUri= */ ''); + /* absoluteMediaPlaylistUri= */ '', + type); // If we are updating the manifest, we can usually skip fetching the segment // by examining the references we already have. This won't be possible if // there was some kind of lag or delay updating the manifest on the server, @@ -2409,6 +2506,12 @@ shaka.hls.HlsParser = class { } } + if (contentType == ContentType.IMAGE) { + if (!codecs || codecs == 'jpeg') { + return 'image/jpeg'; + } + } + // If unable to guess mime type, request a segment and try getting it // from the response. const headRequest = shaka.net.NetworkingEngine.makeRequest( @@ -2779,6 +2882,19 @@ shaka.hls.HlsParser.TEXT_EXTENSIONS_TO_MIME_TYPES_ = { }; +/** + * @const {!Object.} + * @private + */ +shaka.hls.HlsParser.IMAGE_EXTENSIONS_TO_MIME_TYPES_ = { + 'jpg': 'image/jpeg', + 'png': 'image/png', + 'svg': 'image/svg+xml', + 'webp': 'image/webp', + 'avif': 'image/avif', +}; + + /** * @const {!Object.>} * @private @@ -2787,6 +2903,7 @@ shaka.hls.HlsParser.EXTENSION_MAP_BY_CONTENT_TYPE_ = { 'audio': shaka.hls.HlsParser.AUDIO_EXTENSIONS_TO_MIME_TYPES_, 'video': shaka.hls.HlsParser.VIDEO_EXTENSIONS_TO_MIME_TYPES_, 'text': shaka.hls.HlsParser.TEXT_EXTENSIONS_TO_MIME_TYPES_, + 'image': shaka.hls.HlsParser.IMAGE_EXTENSIONS_TO_MIME_TYPES_, }; diff --git a/lib/media/segment_index.js b/lib/media/segment_index.js index 95ffe13b57..cce00b935a 100644 --- a/lib/media/segment_index.js +++ b/lib/media/segment_index.js @@ -327,7 +327,9 @@ shaka.media.SegmentIndex = class { lastReference.initSegmentReference, lastReference.timestampOffset, lastReference.appendWindowStart, - lastReference.appendWindowEnd); + lastReference.appendWindowEnd, + lastReference.partialReferences, + lastReference.tilesLayout); } diff --git a/lib/media/segment_reference.js b/lib/media/segment_reference.js index 662a22f298..3ee1adb557 100644 --- a/lib/media/segment_reference.js +++ b/lib/media/segment_reference.js @@ -135,12 +135,16 @@ shaka.media.SegmentReference = class { * presentation. Any content from after this time will be removed by * MediaSource. * @param {!Array.=} partialReferences - A list of SegmentReferences for the partial segments. + * A list of SegmentReferences for the partial segments. + * @param {?string=} tilesLayout + * The value is a grid-item-dimension consisting of two positive decimal + * integers in the format: column-x-row ('4x3'). It describes the + * arrangement of Images in a Grid. The minimum valid LAYOUT is '1x1'. */ constructor( startTime, endTime, uris, startByte, endByte, initSegmentReference, timestampOffset, appendWindowStart, appendWindowEnd, - partialReferences = []) { + partialReferences = [], tilesLayout = '') { // A preload hinted Partial Segment has the same startTime and endTime. goog.asserts.assert(startTime <= endTime, 'startTime must be less than or equal to endTime'); @@ -176,6 +180,9 @@ shaka.media.SegmentReference = class { /** @type {!Array.} */ this.partialReferences = partialReferences; + + /** @type {?string} */ + this.tilesLayout = tilesLayout; } /** @@ -250,6 +257,16 @@ shaka.media.SegmentReference = class { hasPartialSegments() { return this.partialReferences.length > 0; } + + /** + * Returns the segment's tiles layout. Only defined in image segments. + * + * @return {?string} + * @export + */ + getTilesLayout() { + return this.tilesLayout; + } }; diff --git a/lib/offline/indexeddb/v1_storage_cell.js b/lib/offline/indexeddb/v1_storage_cell.js index 448fed6e0a..3634890f5e 100644 --- a/lib/offline/indexeddb/v1_storage_cell.js +++ b/lib/offline/indexeddb/v1_storage_cell.js @@ -204,6 +204,7 @@ shaka.offline.indexeddb.V1StorageCell = class appendWindowStart, appendWindowEnd, timestampOffset, + tilesLayout: '', }; } diff --git a/lib/offline/indexeddb/v2_storage_cell.js b/lib/offline/indexeddb/v2_storage_cell.js index 2ee4595beb..fa59211fba 100644 --- a/lib/offline/indexeddb/v2_storage_cell.js +++ b/lib/offline/indexeddb/v2_storage_cell.js @@ -148,6 +148,7 @@ shaka.offline.indexeddb.V2StorageCell = class appendWindowEnd: periodEnd, timestampOffset, dataKey: old.dataKey, + tilesLayout: '', }; } }; diff --git a/lib/offline/manifest_converter.js b/lib/offline/manifest_converter.js index f76f0d0610..98df077708 100644 --- a/lib/offline/manifest_converter.js +++ b/lib/offline/manifest_converter.js @@ -229,7 +229,9 @@ shaka.offline.ManifestConverter = class { initSegmentReference, segmentDB.timestampOffset, segmentDB.appendWindowStart, - segmentDB.appendWindowEnd); + segmentDB.appendWindowEnd, + /* partialReferences= */ [], + segmentDB.tilesLayout || ''); } /** diff --git a/lib/offline/storage.js b/lib/offline/storage.js index 8f1e8ebaf6..1c8abb860d 100644 --- a/lib/offline/storage.js +++ b/lib/offline/storage.js @@ -1201,6 +1201,7 @@ shaka.offline.Storage = class { appendWindowStart: segment.appendWindowStart, appendWindowEnd: segment.appendWindowEnd, timestampOffset: segment.timestampOffset, + tilesLayout: segment.tilesLayout, dataKey, }); }); diff --git a/lib/player.js b/lib/player.js index f0c1062032..04f6735402 100644 --- a/lib/player.js +++ b/lib/player.js @@ -3336,9 +3336,11 @@ shaka.Player = class extends shaka.util.FakeEventTarget { return null; } const reference = imageStream.segmentIndex.get(referencePosition); + const tilesLayout = + reference.getTilesLayout() || imageStream.tilesLayout; // This expression is used to detect one or more numbers (0-9) followed // by an x and after one or more numbers (0-9) - const match = /(\d+)x(\d+)/.exec(imageStream.tilesLayout); + const match = /(\d+)x(\d+)/.exec(tilesLayout); if (!match) { shaka.log.warning('Tiles layout does not contain a valid format ' + ' (columns x rows)'); diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index ef6244d26d..5a907f2328 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -112,6 +112,7 @@ shaka.util.PlayerConfiguration = class { }, hls: { ignoreTextStreamFailures: false, + ignoreImageStreamFailures: false, useFullSegmentsForStartTime: false, }, }; diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index 43c6cd46fc..1bed59417b 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -1274,6 +1274,103 @@ describe('HlsParser', () => { expect(actual.variants[0].video).toBeTruthy(); }); + it('parse image streams', async () => { + const master = [ + '#EXTM3U\n', + '#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub1",LANGUAGE="eng",', + 'URI="text"\n', + '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",LANGUAGE="eng",', + 'CHANNELS="2",URI="audio"\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,mp4a",', + 'RESOLUTION=960x540,FRAME-RATE=60,AUDIO="aud1",SUBTITLES="sub1"\n', + 'video\n', + '#EXT-X-IMAGE-STREAM-INF:RESOLUTION=240×135,CODECS="jpeg",', + 'URI="image"\n', + ].join(''); + + const video = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXTINF:5,\n', + 'main.mp4\n', + ].join(''); + + const audio = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXTINF:5,\n', + 'main.mp4\n', + ].join(''); + + const text = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXTINF:5,\n', + '#EXT-X-BYTERANGE:121090@616\n', + 'main.vtt', + ].join(''); + + const image = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXTINF:5,\n', + 'image.jpg\n', + '#EXTINF:5,\n', + '#EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006\n', + 'image.jpg\n', + '#EXTINF:5,\n', + 'image.jpg\n', + ].join(''); + + fakeNetEngine + .setResponseText('test:/master', master) + .setResponseText('test:/audio', audio) + .setResponseText('test:/video', video) + .setResponseText('test:/text', text) + .setResponseText('test:/image', image) + .setResponseText('test:/main.vtt', vttText) + .setResponseValue('test:/init.mp4', initSegmentData) + .setResponseValue('test:/main.mp4', segmentData); + + const actual = await parser.start('test:/master', playerInterface); + + expect(actual.imageStreams.length).toBe(1); + expect(actual.textStreams.length).toBe(1); + expect(actual.variants.length).toBe(1); + + const thumbnails = actual.imageStreams[0]; + + await thumbnails.createSegmentIndex(); + goog.asserts.assert(thumbnails.segmentIndex != null, 'Null segmentIndex!'); + + const firstThumbnailReference = thumbnails.segmentIndex.get(0); + const secondThumbnailReference = thumbnails.segmentIndex.get(1); + const thirdThumbnailReference = thumbnails.segmentIndex.get(2); + + expect(firstThumbnailReference).not.toBe(null); + expect(secondThumbnailReference).not.toBe(null); + expect(thirdThumbnailReference).not.toBe(null); + if (firstThumbnailReference) { + expect(firstThumbnailReference.getTilesLayout()).toBe('1x1'); + } + if (secondThumbnailReference) { + expect(secondThumbnailReference.getTilesLayout()).toBe('5x2'); + } + if (thirdThumbnailReference) { + expect(thirdThumbnailReference.getTilesLayout()).toBe('1x1'); + } + }); + it('Disable audio does not create audio streams', async () => { const master = [ '#EXTM3U\n', @@ -1284,6 +1381,8 @@ describe('HlsParser', () => { '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,mp4a",', 'RESOLUTION=960x540,FRAME-RATE=60,AUDIO="aud1",SUBTITLES="sub1"\n', 'video\n', + '#EXT-X-IMAGE-STREAM-INF:RESOLUTION=240×135,CODECS="jpeg",', + 'URI="image"\n', ].join(''); const video = [ @@ -1316,11 +1415,23 @@ describe('HlsParser', () => { 'main.vtt', ].join(''); + const image = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXTINF:5,\n', + 'image.jpg\n', + '#EXTINF:5,\n', + 'image.jpg\n', + '#EXTINF:5,\n', + 'image.jpg\n', + ].join(''); + fakeNetEngine .setResponseText('test:/master', master) .setResponseText('test:/audio', audio) .setResponseText('test:/video', video) .setResponseText('test:/text', text) + .setResponseText('test:/image', image) .setResponseText('test:/main.vtt', vttText) .setResponseValue('test:/init.mp4', initSegmentData) .setResponseValue('test:/main.mp4', segmentData); @@ -1345,6 +1456,8 @@ describe('HlsParser', () => { '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,mp4a",', 'RESOLUTION=960x540,FRAME-RATE=60,AUDIO="aud1",SUBTITLES="sub1"\n', 'video\n', + '#EXT-X-IMAGE-STREAM-INF:RESOLUTION=240×135,CODECS="jpeg",', + 'URI="image"\n', ].join(''); const video = [ @@ -1377,11 +1490,23 @@ describe('HlsParser', () => { 'main.vtt', ].join(''); + const image = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXTINF:5,\n', + 'image.jpg\n', + '#EXTINF:5,\n', + 'image.jpg\n', + '#EXTINF:5,\n', + 'image.jpg\n', + ].join(''); + fakeNetEngine .setResponseText('test:/master', master) .setResponseText('test:/audio', audio) .setResponseText('test:/video', video) .setResponseText('test:/text', text) + .setResponseText('test:/image', image) .setResponseText('test:/main.vtt', vttText) .setResponseValue('test:/init.mp4', initSegmentData) .setResponseValue('test:/main.mp4', segmentData); @@ -1406,6 +1531,8 @@ describe('HlsParser', () => { '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,mp4a",', 'RESOLUTION=960x540,FRAME-RATE=60,AUDIO="aud1",SUBTITLES="sub1"\n', 'video\n', + '#EXT-X-IMAGE-STREAM-INF:RESOLUTION=240×135,CODECS="jpeg",', + 'URI="image"\n', ].join(''); const video = [ @@ -1438,11 +1565,23 @@ describe('HlsParser', () => { 'main.vtt', ].join(''); + const image = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXTINF:5,\n', + 'image.jpg\n', + '#EXTINF:5,\n', + 'image.jpg\n', + '#EXTINF:5,\n', + 'image.jpg\n', + ].join(''); + fakeNetEngine .setResponseText('test:/master', master) .setResponseText('test:/audio', audio) .setResponseText('test:/video', video) .setResponseText('test:/text', text) + .setResponseText('test:/image', image) .setResponseText('test:/main.vtt', vttText) .setResponseValue('test:/init.mp4', initSegmentData) .setResponseValue('test:/main.mp4', segmentData); @@ -1456,6 +1595,80 @@ describe('HlsParser', () => { expect(stream).toBeUndefined(); }); + it('Disable thumbnails does not create image streams', async () => { + const master = [ + '#EXTM3U\n', + '#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub1",LANGUAGE="eng",', + 'URI="text"\n', + '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",LANGUAGE="eng",', + 'CHANNELS="2",URI="audio"\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,mp4a",', + 'RESOLUTION=960x540,FRAME-RATE=60,AUDIO="aud1",SUBTITLES="sub1"\n', + 'video\n', + '#EXT-X-IMAGE-STREAM-INF:RESOLUTION=240×135,CODECS="jpeg",', + 'URI="image"\n', + ].join(''); + + const video = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXTINF:5,\n', + 'main.mp4\n', + ].join(''); + + const audio = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXTINF:5,\n', + 'main.mp4\n', + ].join(''); + + const text = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXTINF:5,\n', + '#EXT-X-BYTERANGE:121090@616\n', + 'main.vtt', + ].join(''); + + const image = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXTINF:5,\n', + 'image.jpg\n', + '#EXTINF:5,\n', + 'image.jpg\n', + '#EXTINF:5,\n', + 'image.jpg\n', + ].join(''); + + fakeNetEngine + .setResponseText('test:/master', master) + .setResponseText('test:/audio', audio) + .setResponseText('test:/video', video) + .setResponseText('test:/text', text) + .setResponseText('test:/image', image) + .setResponseText('test:/main.vtt', vttText) + .setResponseValue('test:/init.mp4', initSegmentData) + .setResponseValue('test:/main.mp4', segmentData); + + const config = shaka.util.PlayerConfiguration.createDefault().manifest; + config.disableThumbnails = true; + parser.configure(config); + + const actual = await parser.start('test:/master', playerInterface); + const stream = actual.imageStreams[0]; + expect(stream).toBeUndefined(); + }); + it('parses manifest with MP4+TTML streams', async () => { const master = [ '#EXTM3U\n', @@ -1626,6 +1839,43 @@ describe('HlsParser', () => { expect(actual).toEqual(manifest); }); + it('drops failed image streams when configured to', async () => { + const master = [ + '#EXTM3U\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",', + 'RESOLUTION=960x540,FRAME-RATE=60\n', + 'video\n', + '#EXT-X-IMAGE-STREAM-INF:RESOLUTION=240×135,CODECS="jpeg"\n', + ].join(''); + + const media = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXTINF:5,\n', + '#EXT-X-BYTERANGE:121090@616\n', + 'main.mp4', + ].join(''); + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.VIDEO); + }); + }); + + fakeNetEngine + .setResponseText('test:/master', master) + .setResponseText('test:/audio', media) + .setResponseText('test:/video', media) + .setResponseValue('test:/init.mp4', initSegmentData) + .setResponseValue('test:/main.mp4', segmentData); + + config.hls.ignoreImageStreamFailures = true; + const actual = await parser.start('test:/master', playerInterface); + expect(actual).toEqual(manifest); + }); + it('parses video described by a media tag', async () => { const master = [ '#EXTM3U\n', diff --git a/test/offline/manifest_convert_unit.js b/test/offline/manifest_convert_unit.js index b926889b1f..67b73f740d 100644 --- a/test/offline/manifest_convert_unit.js +++ b/test/offline/manifest_convert_unit.js @@ -317,6 +317,7 @@ describe('ManifestConverter', () => { appendWindowStart: 0, appendWindowEnd: Infinity, timestampOffset: 0, + tilesLayout: '', }; return segment; diff --git a/test/test/util/manifest_parser_util.js b/test/test/util/manifest_parser_util.js index 782ebdf7ac..b25c4285cf 100644 --- a/test/test/util/manifest_parser_util.js +++ b/test/test/util/manifest_parser_util.js @@ -56,11 +56,12 @@ shaka.test.ManifestParser = class { * @param {?number=} endByte * @param {number=} timestampOffset * @param {!Array.=} partialReferences + * @param {?string=} tilesLayout * @return {!shaka.media.SegmentReference} */ static makeReference(uri, start, end, baseUri = '', startByte = 0, endByte = null, timestampOffset = 0, - partialReferences = []) { + partialReferences = [], tilesLayout = '') { const getUris = () => uri.length ? [baseUri + uri] : []; // If a test wants to verify these, they can be set explicitly after @@ -84,6 +85,7 @@ shaka.test.ManifestParser = class { appendWindowStart, appendWindowEnd, partialReferences, + tilesLayout, ); } };