Skip to content

Commit

Permalink
feat: Add support to HLS Image Media Playlists (shaka-project#3365)
Browse files Browse the repository at this point in the history
  • Loading branch information
Álvaro Velad Galván committed Jun 2, 2021
1 parent 87786f4 commit 2a4083b
Show file tree
Hide file tree
Showing 19 changed files with 433 additions and 19 deletions.
1 change: 1 addition & 0 deletions demo/common/message_ids.js
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions demo/config.js
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions demo/locales/en.json
Expand Up @@ -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)",
Expand Down
4 changes: 4 additions & 0 deletions demo/locales/source.json
Expand Up @@ -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)"
Expand Down
2 changes: 1 addition & 1 deletion externs/shaka/manifest.js
Expand Up @@ -333,7 +333,7 @@ shaka.extern.CreateSegmentIndexFunction;
* The Stream's label, unique text that should describe the audio/text track.
* @property {string} type
* <i>Required.</i> <br>
* Content type (e.g. 'video', 'audio' or 'text')
* Content type (e.g. 'video', 'audio' or 'text', 'image')
* @property {boolean} primary
* <i>Defaults to false.</i> <br>
* True indicates that the player should use this Stream over others if user
Expand Down
5 changes: 5 additions & 0 deletions externs/shaka/offline.js
Expand Up @@ -194,6 +194,7 @@ shaka.extern.StreamDB;
* appendWindowStart: number,
* appendWindowEnd: number,
* timestampOffset: number,
* tilesLayout: ?string,
* dataKey: number
* }}
*
Expand All @@ -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.
*/
Expand Down
4 changes: 4 additions & 0 deletions externs/shaka/player.js
Expand Up @@ -692,12 +692,16 @@ shaka.extern.DashManifestConfiguration;
/**
* @typedef {{
* ignoreTextStreamFailures: boolean,
* ignoreImageStreamFailures: boolean,
* useFullSegmentsForStartTime: boolean
* }}
*
* @property {boolean} ignoreTextStreamFailures
* If <code>true</code>, ignore any errors in a text stream and filter out
* those streams.
* @property {boolean} ignoreImageStreamFailures
* If <code>true</code>, ignore any errors in a image stream and filter out
* those streams.
* @property {boolean} useFullSegmentsForStartTime
* If <code>true</code>, force HlsParser to use a full segment request for
* determining start time in case the server does not support partial requests
Expand Down
141 changes: 129 additions & 12 deletions lib/hls/hls_parser.js
Expand Up @@ -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.
Expand Down Expand Up @@ -403,6 +404,9 @@ shaka.hls.HlsParser = class {
/** @type {!Array.<!shaka.hls.Tag>} */
const variantTags = Utils.filterTagsByName(
playlist.tags, 'EXT-X-STREAM-INF');
/** @type {!Array.<!shaka.hls.Tag>} */
const imageTags = Utils.filterTagsByName(
playlist.tags, 'EXT-X-IMAGE-STREAM-INF');

this.parseCodecs_(variantTags);

Expand Down Expand Up @@ -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_) {
Expand Down Expand Up @@ -533,7 +538,7 @@ shaka.hls.HlsParser = class {
presentationTimeline: this.presentationTimeline_,
variants,
textStreams,
imageStreams: [],
imageStreams,
offlineSessionIds: [],
minBufferTime: 0,
};
Expand Down Expand Up @@ -666,6 +671,34 @@ shaka.hls.HlsParser = class {
return textStreams.filter((s) => s);
}

/**
* @param {!Array.<!shaka.hls.Tag>} imageTags from the playlist.
* @return {!Promise.<!Array.<!shaka.extern.Stream>>}
* @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.<!shaka.hls.Tag>} mediaTags Media tags from the playlist.
* @private
Expand Down Expand Up @@ -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.<?shaka.hls.HlsParser.StreamInfo>}
* @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.
*
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1618,11 +1697,12 @@ shaka.hls.HlsParser = class {
* @param {!Map.<string, string>} 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);
Expand Down Expand Up @@ -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,
Expand All @@ -1724,6 +1815,7 @@ shaka.hls.HlsParser = class {
/* appendWindowStart= */ 0,
/* appendWindowEnd= */ Infinity,
partialSegmentRefs,
tilesLayout,
);
}

Expand Down Expand Up @@ -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_;
}
Expand Down Expand Up @@ -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
Expand All @@ -1902,7 +1995,8 @@ shaka.hls.HlsParser = class {
startTime,
timestampOffset,
variables,
playlist.absoluteUri);
playlist.absoluteUri,
type);

references.push(reference);
} else if (!this.lowLatencyMode_) {
Expand Down Expand Up @@ -1932,13 +2026,14 @@ shaka.hls.HlsParser = class {
* @param {!shaka.hls.Segment} segment
* @param {!Map.<string, string>} variables
* @param {number} startTime
* @param {string} type
* @return {!Promise.<number>}
* @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 =
Expand All @@ -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(
Expand Down Expand Up @@ -2034,20 +2129,22 @@ shaka.hls.HlsParser = class {
* @param {boolean} isDiscontinuity
* @param {!shaka.hls.Segment} segment
* @param {!Map.<string, string>} variables
* @param {string} type
* @return {!Promise.<number>}
* @private
*/
async getStartTime_(
verbatimMediaPlaylistUri, initSegmentRef, mimeType, mediaSequenceNumber,
isDiscontinuity, segment, variables) {
isDiscontinuity, segment, variables, type) {
const segmentRef = this.createSegmentReference_(
initSegmentRef,
/* previousReference= */ null,
segment,
/* 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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -2779,6 +2882,19 @@ shaka.hls.HlsParser.TEXT_EXTENSIONS_TO_MIME_TYPES_ = {
};


/**
* @const {!Object.<string, string>}
* @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.<string, !Object.<string, string>>}
* @private
Expand All @@ -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_,
};


Expand Down
4 changes: 3 additions & 1 deletion lib/media/segment_index.js
Expand Up @@ -327,7 +327,9 @@ shaka.media.SegmentIndex = class {
lastReference.initSegmentReference,
lastReference.timestampOffset,
lastReference.appendWindowStart,
lastReference.appendWindowEnd);
lastReference.appendWindowEnd,
lastReference.partialReferences,
lastReference.tilesLayout);
}


Expand Down

0 comments on commit 2a4083b

Please sign in to comment.