From 522cf45ee14284c9d2df638c218f97eaff7a8b2d Mon Sep 17 00:00:00 2001 From: jrivera Date: Tue, 25 Apr 2017 19:58:59 -0400 Subject: [PATCH] Fixed codecs to mimetype conversion to take into account all possible scenarios --- src/master-playlist-controller.js | 157 ++++++++++++------ test/master-playlist-controller.test.js | 212 ++++++++++++------------ 2 files changed, 209 insertions(+), 160 deletions(-) diff --git a/src/master-playlist-controller.js b/src/master-playlist-controller.js index 250714248..46b44ed93 100644 --- a/src/master-playlist-controller.js +++ b/src/master-playlist-controller.js @@ -14,6 +14,13 @@ import Decrypter from './decrypter-worker'; let Hls; +// Default codec parameters if non were provided for video and/or audio +const defaultCodecs = { + videoCodec: 'avc1', + videoObjectTypeIndicator: '.4d400d', + audioProfile: '2' +}; + // SegmentLoader stats that need to have each loader's // values summed to calculate the final value const loaderStats = [ @@ -64,10 +71,7 @@ const objectChanged = function(a, b) { */ const parseCodecs = function(codecs) { let result = { - codecCount: 0, - videoCodec: null, - videoObjectTypeIndicator: null, - audioProfile: null + codecCount: 0 }; let parsed; @@ -104,6 +108,42 @@ export const mapLegacyAvcCodecs_ = function(codecString) { }); }; +/** + * Build a media mime-type string from a set of parameters + * @param {String} type either 'audio' or 'video' + * @param {String} container either 'mp2t' or 'mp4' + * @param {Array} codecs an array of codec strings to add + * @return {String} a valid media mime-type + */ +const makeMimeTypeString = function(type, container, codecs) { + // The codecs array is filtered so that falsey values are + // dropped and don't cause Array#join to create spurious + // commas + return `${type}/${container}; codecs="${codecs.filter(c=>!!c).join(', ')}"`; +}; + +const getContainerType = function(media) { + // An initialization segment means the media playlists is an iframe + // playlist or is using the mp4 container. We don't currently + // support iframe playlists, so assume this is signalling mp4 + // fragments. + if (media.segments && media.segments.length && media.segments[0].map) { + return 'mp4'; + } + return 'mp2t'; +}; + +const getCodecs = function(media) { + // if the codecs were explicitly specified, use them instead of the + // defaults + let mediaAttributes = media.attributes || {}; + + if (mediaAttributes.CODECS) { + return parseCodecs(mediaAttributes.CODECS); + } + return defaultCodecs; +}; + /** * Calculates the MIME type strings for a working configuration of * SourceBuffers to play variant streams in a master playlist. If @@ -119,74 +159,87 @@ export const mapLegacyAvcCodecs_ = function(codecString) { * @private */ export const mimeTypesForPlaylist_ = function(master, media) { - let container = 'mp2t'; - let codecs = { - videoCodec: 'avc1', - videoObjectTypeIndicator: '.4d400d', - audioProfile: '2' - }; - let audioGroup = []; - let mediaAttributes; - let previousGroup = null; + let containerType = getContainerType(media); + let codecInfo = getCodecs(media); + let mediaAttributes = media.attributes || {}; + // Default condition for a traditional HLS (no demuxed audio/video) + let isMuxed = true; + let isMaat = false; if (!media) { - // not enough information, return an error + // Not enough information, return an error return []; } - // An initialization segment means the media playlists is an iframe - // playlist or is using the mp4 container. We don't currently - // support iframe playlists, so assume this is signalling mp4 - // fragments. - // the existence check for segments can be removed once - // https://github.com/videojs/m3u8-parser/issues/8 is closed - if (media.segments && media.segments.length && media.segments[0].map) { - container = 'mp4'; + + if (master.mediaGroups.AUDIO && mediaAttributes.AUDIO) { + let audioGroup = master.mediaGroups.AUDIO[mediaAttributes.AUDIO]; + + // Handle the case where we are in a multiple-audio track scenario + if (audioGroup) { + isMaat = true; + // Start with the everything demuxed then... + isMuxed = false; + // ...check to see if any audio group tracks are muxed (ie. lacking a uri) + for (let groupId in audioGroup) { + if (!audioGroup[groupId].uri) { + isMuxed = true; + break; + } + } + } } - // if the codecs were explicitly specified, use them instead of the - // defaults - mediaAttributes = media.attributes || {}; - if (mediaAttributes.CODECS) { - let parsedCodecs = parseCodecs(mediaAttributes.CODECS); + // HLS with multiple-audio tracks must always get an audio codec. + // Put another way, there is no way to have a video-only multiple-audio HLS! + if (isMaat && !codecInfo.audioProfile) { + codecInfo.audioProfile = defaultCodecs.audioProfile; + } - Object.keys(parsedCodecs).forEach((key) => { - codecs[key] = parsedCodecs[key] || codecs[key]; - }); + // Generate the final codec strings from the codec object generated above + let codecStrings = {}; + + if (codecInfo.videoCodec) { + codecStrings.video = `${codecInfo.videoCodec}${codecInfo.videoObjectTypeIndicator}`; } - if (master.mediaGroups.AUDIO) { - audioGroup = master.mediaGroups.AUDIO[mediaAttributes.AUDIO]; + if (codecInfo.audioProfile) { + codecStrings.audio = `mp4a.40.${codecInfo.audioProfile}`; } - // if audio could be muxed or unmuxed, use mime types appropriate - // for both scenarios - for (let groupId in audioGroup) { - if (previousGroup && (!!audioGroup[groupId].uri !== !!previousGroup.uri)) { - // one source buffer with muxed video and audio and another for - // the alternate audio + // Finally, make and return an array with proper mime-types depending on + // the configuration + let justAudio = makeMimeTypeString('audio', containerType, [codecStrings.audio]); + let justVideo = makeMimeTypeString('video', containerType, [codecStrings.video]); + let bothVideoAudio = makeMimeTypeString('video', containerType, [ + codecStrings.video, + codecStrings.audio + ]); + + if (isMaat) { + if (!isMuxed && codecStrings.video) { return [ - 'video/' + container + '; codecs="' + - codecs.videoCodec + codecs.videoObjectTypeIndicator + ', mp4a.40.' + codecs.audioProfile + '"', - 'audio/' + container + '; codecs="mp4a.40.' + codecs.audioProfile + '"' + justVideo, + justAudio ]; } - previousGroup = audioGroup[groupId]; + return [ + bothVideoAudio, + justAudio + ]; } - // if all video and audio is unmuxed, use two single-codec mime - // types - if (previousGroup && previousGroup.uri) { + + // If there is ano video codec at all, always just return a single + // audio/ mime-type + if (!codecStrings.video) { return [ - 'video/' + container + '; codecs="' + - codecs.videoCodec + codecs.videoObjectTypeIndicator + '"', - 'audio/' + container + '; codecs="mp4a.40.' + codecs.audioProfile + '"' + justAudio ]; } - // all video and audio are muxed, use a dual-codec mime type + // When not using separate audio media groups, audio and video is + // *always* muxed return [ - 'video/' + container + '; codecs="' + - codecs.videoCodec + codecs.videoObjectTypeIndicator + - ', mp4a.40.' + codecs.audioProfile + '"' + bothVideoAudio ]; }; diff --git a/test/master-playlist-controller.test.js b/test/master-playlist-controller.test.js index 3c5f13849..063271a09 100644 --- a/test/master-playlist-controller.test.js +++ b/test/master-playlist-controller.test.js @@ -19,6 +19,40 @@ import { Hls } from '../src/videojs-contrib-hls'; /* eslint-enable no-unused-vars */ import Playlist from '../src/playlist'; +const generateMedia = function(isMaat, isMuxed, hasVideoCodec, hasAudioCodec) { + const codec = (hasVideoCodec ? 'avc1.4d400d' : '') + + (hasVideoCodec && hasAudioCodec ? ',' : '') + + (hasAudioCodec ? 'mp4a.40.2' : ''); + const master = { + mediaGroups: {}, + playlists: [] + }; + const media = { + attributes: {} + }; + + if (isMaat) { + master.mediaGroups.AUDIO = { + test: { + demuxed: { + uri: 'foo.bar' + } + } + }; + + if (isMuxed) { + master.mediaGroups.AUDIO.test.muxed = {}; + } + media.attributes.AUDIO = 'test'; + } + + if (hasVideoCodec || hasAudioCodec) { + media.attributes.CODECS = codec; + } + + return [master, media]; +}; + QUnit.module('MasterPlaylistController', { beforeEach(assert) { this.env = useFakeEnvironment(assert); @@ -1590,114 +1624,76 @@ QUnit.test('sets up subtitles', function(assert) { QUnit.module('Codec to MIME Type Conversion'); QUnit.test('recognizes muxed codec configurations', function(assert) { - assert.deepEqual(mimeTypesForPlaylist_({ mediaGroups: {} }, {}), - [ 'video/mp2t; codecs="avc1.4d400d, mp4a.40.2"' ], - 'returns a default MIME type when no codecs are present'); - - assert.deepEqual(mimeTypesForPlaylist_({ - mediaGroups: {}, - playlists: [] - }, { - attributes: { - CODECS: 'mp4a.40.E,avc1.deadbeef' - } - }), [ - 'video/mp2t; codecs="avc1.deadbeef, mp4a.40.E"' - ], 'returned the parsed muxed type'); -}); - -QUnit.test('recognizes mixed codec configurations', function(assert) { - assert.deepEqual(mimeTypesForPlaylist_({ - mediaGroups: { - AUDIO: { - hi: { - en: {}, - es: { - uri: 'http://example.com/alt-audio.m3u8' - } - } - } - }, - playlists: [] - }, { - attributes: { - AUDIO: 'hi' - } - }), [ - 'video/mp2t; codecs="avc1.4d400d, mp4a.40.2"', - 'audio/mp2t; codecs="mp4a.40.2"' - ], 'returned a default muxed type with alternate audio'); - - assert.deepEqual(mimeTypesForPlaylist_({ - mediaGroups: { - AUDIO: { - hi: { - eng: {}, - es: { - uri: 'http://example.com/alt-audio.m3u8' - } - } - } - }, - playlists: [] - }, { - attributes: { - CODECS: 'mp4a.40.E,avc1.deadbeef', - AUDIO: 'hi' - } - }), [ - 'video/mp2t; codecs="avc1.deadbeef, mp4a.40.E"', - 'audio/mp2t; codecs="mp4a.40.E"' - ], 'returned a parsed muxed type with alternate audio'); -}); - -QUnit.test('recognizes unmuxed codec configurations', function(assert) { - assert.deepEqual(mimeTypesForPlaylist_({ - mediaGroups: { - AUDIO: { - hi: { - eng: { - uri: 'http://example.com/eng.m3u8' - }, - es: { - uri: 'http://example.com/eng.m3u8' - } - } - } - }, - playlists: [] - }, { - attributes: { - AUDIO: 'hi' - } - }), [ - 'video/mp2t; codecs="avc1.4d400d"', - 'audio/mp2t; codecs="mp4a.40.2"' - ], 'returned default unmuxed types'); - - assert.deepEqual(mimeTypesForPlaylist_({ - mediaGroups: { - AUDIO: { - hi: { - eng: { - uri: 'http://example.com/alt-audio.m3u8' - }, - es: { - uri: 'http://example.com/eng.m3u8' - } - } - } - }, - playlists: [] - }, { - attributes: { - CODECS: 'mp4a.40.E,avc1.deadbeef', - AUDIO: 'hi' - } - }), [ - 'video/mp2t; codecs="avc1.deadbeef"', - 'audio/mp2t; codecs="mp4a.40.E"' - ], 'returned parsed unmuxed types'); + // no MAAT + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(false, true, false, false)), + ['video/mp2t; codecs="avc1.4d400d, mp4a.40.2"'], + 'no MAAT, codecs: none'); + + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(false, true, true, false)), + ['video/mp2t; codecs="avc1.4d400d"'], + 'no MAAT, codecs: video'); + + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(false, true, false, true)), + ['audio/mp2t; codecs="mp4a.40.2"'], + 'no MAAT, codecs: audio'); + + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(false, true, true, true)), + ['video/mp2t; codecs="avc1.4d400d, mp4a.40.2"'], + 'no MAAT, codecs: video, audio'); + + // MAAT, not muxed + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(true, false, false, false)), + ['video/mp2t; codecs="avc1.4d400d"', + 'audio/mp2t; codecs="mp4a.40.2"'], + 'MAAT, demuxed, codecs: none'); + + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(true, false, true, false)), + ['video/mp2t; codecs="avc1.4d400d"', + 'audio/mp2t; codecs="mp4a.40.2"'], + 'MAAT, demuxed, codecs: video'); + + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(true, false, false, true)), + ['video/mp2t; codecs="mp4a.40.2"', + 'audio/mp2t; codecs="mp4a.40.2"'], + 'MAAT, demuxed, codecs: audio'); + + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(true, false, true, true)), + ['video/mp2t; codecs="avc1.4d400d"', + 'audio/mp2t; codecs="mp4a.40.2"'], + 'MAAT, demuxed, codecs: video, audio'); + + // MAAT, muxed + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(true, true, false, false)), + ['video/mp2t; codecs="avc1.4d400d, mp4a.40.2"', + 'audio/mp2t; codecs="mp4a.40.2"'], + 'MAAT, muxed, codecs: none'); + + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(true, true, true, false)), + ['video/mp2t; codecs="avc1.4d400d, mp4a.40.2"', + 'audio/mp2t; codecs="mp4a.40.2"'], + 'MAAT, muxed, codecs: video'); + + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(true, true, false, true)), + ['video/mp2t; codecs="mp4a.40.2"', + 'audio/mp2t; codecs="mp4a.40.2"'], + 'MAAT, muxed, codecs: audio'); + + assert.deepEqual(mimeTypesForPlaylist_.apply(null, + generateMedia(true, true, true, true)), + ['video/mp2t; codecs="avc1.4d400d, mp4a.40.2"', + 'audio/mp2t; codecs="mp4a.40.2"'], + 'MAAT, muxed, codecs: video, audio'); }); QUnit.module('Map Legacy AVC Codec');