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,
);
}
};