Skip to content

Commit

Permalink
Add support for multiple media tags with the same group-id (HLS).
Browse files Browse the repository at this point in the history
Currently HLS parser expects only one media tag to have a given
group id. According to the spec that might not be the case.
This change adds support for multiple tags with the same gruop id
and insures the parser creates variants for all of them.

Issue #279.

Change-Id: I327e52387f7513464fc56c4b6b8d07ead689d6cc
  • Loading branch information
ismena committed Mar 14, 2017
1 parent f946067 commit a3d6186
Show file tree
Hide file tree
Showing 3 changed files with 225 additions and 108 deletions.
262 changes: 162 additions & 100 deletions lib/hls/hls_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ goog.require('shaka.media.SegmentIndex');
goog.require('shaka.media.SegmentReference');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.Error');
goog.require('shaka.util.Functional');
goog.require('shaka.util.ManifestParserUtils');



Expand Down Expand Up @@ -180,27 +182,29 @@ shaka.hls.HlsParser.prototype.parseManifest_ = function(data, uri) {
*/
shaka.hls.HlsParser.prototype.createPeriod_ = function(playlist) {
var Utils = shaka.hls.Utils;
var Functional = shaka.util.Functional;
var tags = playlist.tags;

// Create Variants for every 'EXT-X-STREAM-INF' tag.
var variantTags = Utils.filterTagsByName(tags, 'EXT-X-STREAM-INF');
var variantsPromises = variantTags.map(function(tag) {
return this.createVariant_(tag, playlist);
return this.createVariantsForTag_(tag, playlist);
}.bind(this));

var mediaTags = Utils.filterTagsByName(playlist.tags, 'EXT-X-MEDIA');
var textStreamTags = mediaTags.filter(function(tag) {
var type = this.getRequiredAttributeValue_(tag, 'TYPE');
return type == 'SUBTITLES';
}.bind(this));
// TODO: CLOSED-CAPTIONS requires the parsing of CEA-608 from the video.

// TODO: CLOSED-CAPTIONS requires the parsing of CEA-608 from the video.
var textStreamPromises = textStreamTags.map(function(tag) {
return this.createTextStream_(tag, playlist);
}.bind(this));

return Promise.all(variantsPromises).then(function(variants) {
return Promise.all(variantsPromises).then(function(allVariants) {
return Promise.all(textStreamPromises).then(function(textStreams) {
var variants = allVariants.reduce(Functional.collapseArrays, []);
this.fitSegments_(variants);
return {
startTime: 0,
Expand All @@ -213,77 +217,17 @@ shaka.hls.HlsParser.prototype.createPeriod_ = function(playlist) {


/**
* Parses an EXT-X-STREAM-INF tag into a Variant.
*
* @param {!shaka.hls.Tag} tag
* @param {!shaka.hls.Playlist} playlist
* @return {!Promise.<!shakaExtern.Variant>}
* @return {!Promise.<!Array.<!shakaExtern.Variant>>}
* @private
*/
shaka.hls.HlsParser.prototype.createVariant_ = function(tag, playlist) {
shaka.hls.HlsParser.prototype.createVariantsForTag_ = function(tag, playlist) {
goog.asserts.assert(tag.name == 'EXT-X-STREAM-INF',
'Should only be called on variant tags!');

var ContentType = shaka.util.ManifestParserUtils.ContentType;
var bandwidth = Number(this.getRequiredAttributeValue_(tag, 'BANDWIDTH'));
// TODO(ismena): Implement support for protected content.
var drmInfos = [];

return this.createStreamsForVariant_(tag, playlist)
.then(function(streamsByType) {
var audio = streamsByType[ContentType.AUDIO];
var video = streamsByType[ContentType.VIDEO];
return {
id: this.globalId_++,
language: audio ? audio.language : 'und',
primary: (!!audio && audio.primary) || (!!video && video.primary),
audio: audio,
video: video,
bandwidth: bandwidth,
drmInfos: drmInfos,
allowedByApplication: true,
allowedByKeySystem: true
};
}.bind(this));
};


/**
* Parses an EXT-X-MEDIA tag with TYPE="SUBTITLES" into a text stream.
*
* @param {!shaka.hls.Tag} tag
* @param {!shaka.hls.Playlist} playlist
* @return {!Promise.<?shakaExtern.Stream>}
* @private
*/
shaka.hls.HlsParser.prototype.createTextStream_ = function(tag, playlist) {
goog.asserts.assert(tag.name == 'EXT-X-MEDIA',
'Should only be called on media tags!');

var type = this.getRequiredAttributeValue_(tag, 'TYPE');
goog.asserts.assert(type == 'SUBTITLES',
'Should only be called on tags with TYPE="SUBTITLES"!');

var timeOffset = this.getTimeOffset_(playlist);
return this.createStreamFromMediaTag_(tag, [], timeOffset);
};


/**
* Creates audio and video streams for the Variant.
*
* @param {!shaka.hls.Tag} tag
* @param {!shaka.hls.Playlist} playlist
* @return {!Promise.<!Object.<string, ?shakaExtern.Stream>>}
* @private
*/
shaka.hls.HlsParser.prototype.createStreamsForVariant_ =
function(tag, playlist) {
var Utils = shaka.hls.Utils;
var ContentType = shaka.util.ManifestParserUtils.ContentType;
var streamsByType = {};
var videoTag = null;
var audioTag = null;
var bandwidth = Number(this.getRequiredAttributeValue_(tag, 'BANDWIDTH'));

var codecs = this.getRequiredAttributeValue_(tag, 'CODECS').split(',');
var resolutionAttr = tag.getAttribute('RESOLUTION');
Expand All @@ -308,54 +252,172 @@ shaka.hls.HlsParser.prototype.createStreamsForVariant_ =
var audioAttr = tag.getAttribute('AUDIO');
var videoAttr = tag.getAttribute('VIDEO');

if (audioAttr)
audioTag = Utils.findMediaTag(mediaTags, 'AUDIO', audioAttr.value);
var audioPromises = [];
var videoPromises = [];

if (videoAttr)
videoTag = Utils.findMediaTag(mediaTags, 'VIDEO', videoAttr.value);
// TODO: support for protected content
var drmInfos = [];

return this.createStreamFromMediaTag_(audioTag, codecs, timeOffset)
.then(function(stream) {
streamsByType[ContentType.AUDIO] = stream;
return this.createStreamFromMediaTag_(videoTag, codecs, timeOffset);
}.bind(this)).then(function(stream) {
this.addVideoAttributes_(stream, width, height, frameRate);
streamsByType[ContentType.VIDEO] = stream;
var audio = streamsByType[ContentType.AUDIO];
var video = streamsByType[ContentType.VIDEO];
if (audioAttr) {
var audioTags = Utils.findMediaTags(mediaTags, 'AUDIO', audioAttr.value);
audioPromises = audioTags.map(function(tag) {
return this.createStreamFromMediaTag_(tag, codecs, timeOffset);
}.bind(this));
}

if (videoAttr) {
var videoTags = Utils.findMediaTags(mediaTags, 'VIDEO', videoAttr.value);
videoPromises = videoTags.map(function(tag) {
return this.createStreamFromMediaTag_(tag, codecs, timeOffset);
}.bind(this));
}

var audioStreams = [];
var videoStreams = [];

return Promise
.all([
Promise.all(audioPromises),
Promise.all(videoPromises)
])
.then(function(data) {
audioStreams = data[0];
videoStreams = data[1];
// TODO: find examples of audio-only variants and account
// for them.
if (video && audio) {
// Both audio and video streams have already been created.
return streamsByType;
} else if (video && !audio) {
// If video stream has been described by a media tag,
if (videoStreams.length && audioStreams.length) {
return null;
} else if (videoStreams.length && !audioStreams.length) {
// If video streams have been described by a media tag,
// assume the underlying uri describes audio.
return this.createStreamFromVariantTag_(tag, codecs,
ContentType.AUDIO,
timeOffset)
.then(function(stream) {
streamsByType[ContentType.AUDIO] = stream;
return streamsByType;
});
return this.createStreamFromVariantTag_(
tag, codecs,
ContentType.AUDIO,
timeOffset);
} else {
// In any other case (video-only variants, multiplexed
// content audio described by a media tag) assume the
// underlying uri describes video.
return this.createStreamFromVariantTag_(tag, codecs,
ContentType.VIDEO,
timeOffset)
.then(function(stream) {
streamsByType[ContentType.VIDEO] = stream;
this.addVideoAttributes_(
stream, width, height, frameRate);
return streamsByType;
}.bind(this));
return this.createStreamFromVariantTag_(
tag, codecs,
ContentType.VIDEO,
timeOffset);
}
}.bind(this))
.then(function(stream) {
if (stream) {
if (stream.type == ContentType.AUDIO)
audioStreams = [stream];
else
videoStreams = [stream];
}

return this.createVariants_(audioStreams,
videoStreams,
bandwidth,
drmInfos,
width,
height,
frameRate);
}.bind(this));
};


/**
* @param {!Array.<!shakaExtern.Stream>} audioStreams
* @param {!Array.<!shakaExtern.Stream>} videoStreams
* @param {number} bandwidth
* @param {!Array.<shakaExtern.DrmInfo>} drmInfos
* @param {!string|undefined} width
* @param {!string|undefined} height
* @param {!string|undefined} frameRate
* @return {!Array.<!shakaExtern.Variant>}
* @private
*/
shaka.hls.HlsParser.prototype.createVariants_ =
function(audioStreams, videoStreams, bandwidth, drmInfos,
width, height, frameRate) {

videoStreams.forEach(function(stream) {
this.addVideoAttributes_(stream, width, height, frameRate);
}.bind(this));

// In case of audio-only or video-only content, we create an array of
// one item containing a null. This way, the double-loop works for all
// kinds of content.
// NOTE: we currently don't have support for audio-only content.
if (!videoStreams.length)
videoStreams = [null];
if (!audioStreams.length)
audioStreams = [null];

var variants = [];
for (var i = 0; i < audioStreams.length; i++) {
for (var j = 0; j < videoStreams.length; j++) {
var audio = audioStreams[i];
var video = videoStreams[j];
variants.push(this.createVariant_(
audio, video, bandwidth, drmInfos));
}
}
return variants;
};


/**
* @param {shakaExtern.Stream} audio
* @param {shakaExtern.Stream} video
* @param {number} bandwidth
* @param {!Array.<shakaExtern.DrmInfo>} drmInfos
* @return {!shakaExtern.Variant}
* @private
*/
shaka.hls.HlsParser.prototype.createVariant_ =
function(audio, video, bandwidth, drmInfos) {
var ContentType = shaka.util.ManifestParserUtils.ContentType;

// Since both audio and video are of the same type, this assertion will catch
// certain mistakes at runtime that the compiler would miss.
goog.asserts.assert(!audio || audio.type == ContentType.AUDIO,
'Audio parameter mismatch!');
goog.asserts.assert(!video || video.type == ContentType.VIDEO,
'Video parameter mismatch!');

return {
id: this.globalId_++,
language: audio ? audio.language : 'und',
primary: (!!audio && audio.primary) || (!!video && video.primary),
audio: audio,
video: video,
bandwidth: bandwidth,
drmInfos: drmInfos,
allowedByApplication: true,
allowedByKeySystem: true
};
};


/**
* Parses an EXT-X-MEDIA tag with TYPE="SUBTITLES" into a text stream.
*
* @param {!shaka.hls.Tag} tag
* @param {!shaka.hls.Playlist} playlist
* @return {!Promise.<?shakaExtern.Stream>}
* @private
*/
shaka.hls.HlsParser.prototype.createTextStream_ = function(tag, playlist) {
goog.asserts.assert(tag.name == 'EXT-X-MEDIA',
'Should only be called on media tags!');

var type = this.getRequiredAttributeValue_(tag, 'TYPE');
goog.asserts.assert(type == 'SUBTITLES',
'Should only be called on tags with TYPE="SUBTITLES"!');

var timeOffset = this.getTimeOffset_(playlist);
return this.createStreamFromMediaTag_(tag, [], timeOffset);
};


/**
* Parse EXT-X-MEDIA media tag into a Stream object.
*
Expand Down
11 changes: 3 additions & 8 deletions lib/hls/hls_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,14 @@ shaka.hls.Utils.getFirstTagWithName = function(tags, name) {
* @param {!Array.<!shaka.hls.Tag>} tags
* @param {!string} type
* @param {!string} groupId
* @return {shaka.hls.Tag}
* @return {!Array<!shaka.hls.Tag>}
*/
shaka.hls.Utils.findMediaTag = function(tags, type, groupId) {
var filtered = tags.filter(function(tag) {
shaka.hls.Utils.findMediaTags = function(tags, type, groupId) {
return tags.filter(function(tag) {
var typeAttr = tag.getAttribute('TYPE');
var groupIdAttr = tag.getAttribute('GROUP-ID');
return typeAttr.value == type && groupIdAttr.value == groupId;
});

if (filtered.length)
return filtered[0];
else
return null;
};


Expand Down
Loading

0 comments on commit a3d6186

Please sign in to comment.