/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.util.PeriodCombiner');
goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.media.DrmEngine');
goog.require('shaka.media.MetaSegmentIndex');
goog.require('shaka.media.SegmentIndex');
goog.require('shaka.util.ArrayUtils');
goog.require('shaka.util.Error');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.LanguageUtils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.MapUtils');
goog.require('shaka.util.MimeUtils');
/**
* A utility to combine streams across periods.
*
* @implements {shaka.util.IReleasable}
* @final
*/
shaka.util.PeriodCombiner = class {
/** */
constructor() {
/** @private {!Array.<shaka.extern.Variant>} */
this.variants_ = [];
/** @private {!Array.<shaka.extern.Stream>} */
this.audioStreams_ = [];
/** @private {!Array.<shaka.extern.Stream>} */
this.videoStreams_ = [];
/** @private {!Array.<shaka.extern.Stream>} */
this.textStreams_ = [];
/** @private {!Array.<shaka.extern.Stream>} */
this.imageStreams_ = [];
/**
* The IDs of the periods we have already used to generate streams.
* This helps us identify the periods which have been added when a live
* stream is updated.
*
* @private {!Set.<string>}
*/
this.usedPeriodIds_ = new Set();
}
/** @override */
release() {
const allStreams =
this.audioStreams_.concat(this.videoStreams_, this.textStreams_,
this.imageStreams_);
for (const stream of allStreams) {
if (stream.segmentIndex) {
stream.segmentIndex.release();
}
}
this.audioStreams_ = [];
this.videoStreams_ = [];
this.textStreams_ = [];
this.imageStreams_ = [];
this.variants_ = [];
}
/** @return {!Array.<shaka.extern.Variant>} */
getVariants() {
return this.variants_;
}
/** @return {!Array.<shaka.extern.Stream>} */
getTextStreams() {
// Return a copy of the array because makeTextStreamsForClosedCaptions
// may make changes to the contents of the array. Those changes should not
// propagate back to the PeriodCombiner.
return this.textStreams_.slice();
}
/** @return {!Array.<shaka.extern.Stream>} */
getImageStreams() {
return this.imageStreams_;
}
/**
* @param {!Array.<shaka.util.PeriodCombiner.Period>} periods
* @param {boolean} isDynamic
* @return {!Promise}
*/
async combinePeriods(periods, isDynamic) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
shaka.util.PeriodCombiner.filterOutAudioStreamDuplicates_(periods);
shaka.util.PeriodCombiner.filterOutVideoStreamDuplicates_(periods);
shaka.util.PeriodCombiner.filterOutTextStreamDuplicates_(periods);
shaka.util.PeriodCombiner.filterOutImageStreamDuplicates_(periods);
// Optimization: for single-period VOD, do nothing. This makes sure
// single-period DASH content will be 100% accurately represented in the
// output.
if (!isDynamic && periods.length == 1) {
const firstPeriod = periods[0];
this.audioStreams_ = firstPeriod.audioStreams;
this.videoStreams_ = firstPeriod.videoStreams;
this.textStreams_ = firstPeriod.textStreams;
this.imageStreams_ = firstPeriod.imageStreams;
} else {
// Find the first period we haven't seen before. Tag all the periods we
// see now as "used".
let firstNewPeriodIndex = -1;
for (let i = 0; i < periods.length; i++) {
const period = periods[i];
if (this.usedPeriodIds_.has(period.id)) {
// This isn't new.
} else {
// This one _is_ new.
this.usedPeriodIds_.add(period.id);
if (firstNewPeriodIndex == -1) {
// And it's the _first_ new one.
firstNewPeriodIndex = i;
}
}
}
if (firstNewPeriodIndex == -1) {
// Nothing new? Nothing to do.
return;
}
const audioStreamsPerPeriod = periods.map(
(period) => period.audioStreams);
const videoStreamsPerPeriod = periods.map(
(period) => period.videoStreams);
const textStreamsPerPeriod = periods.map(
(period) => period.textStreams);
const imageStreamsPerPeriod = periods.map(
(period) => period.imageStreams);
// It's okay to have a period with no text or images, but our algorithm
// fails on any period without matching streams. So we add dummy streams
// to each period. Since we combine text streams by language and image
// streams by resolution, we might need a dummy even in periods with these
// streams already.
for (const textStreams of textStreamsPerPeriod) {
textStreams.push(shaka.util.PeriodCombiner.dummyStream_(
ContentType.TEXT));
}
for (const imageStreams of imageStreamsPerPeriod) {
imageStreams.push(shaka.util.PeriodCombiner.dummyStream_(
ContentType.IMAGE));
}
await shaka.util.PeriodCombiner.combine_(
this.audioStreams_,
audioStreamsPerPeriod,
firstNewPeriodIndex,
shaka.util.PeriodCombiner.cloneStream_,
shaka.util.PeriodCombiner.concatenateStreams_);
await shaka.util.PeriodCombiner.combine_(
this.videoStreams_,
videoStreamsPerPeriod,
firstNewPeriodIndex,
shaka.util.PeriodCombiner.cloneStream_,
shaka.util.PeriodCombiner.concatenateStreams_);
await shaka.util.PeriodCombiner.combine_(
this.textStreams_,
textStreamsPerPeriod,
firstNewPeriodIndex,
shaka.util.PeriodCombiner.cloneStream_,
shaka.util.PeriodCombiner.concatenateStreams_);
await shaka.util.PeriodCombiner.combine_(
this.imageStreams_,
imageStreamsPerPeriod,
firstNewPeriodIndex,
shaka.util.PeriodCombiner.cloneStream_,
shaka.util.PeriodCombiner.concatenateStreams_);
}
// Create variants for all audio/video combinations.
let nextVariantId = 0;
const variants = [];
if (!this.videoStreams_.length || !this.audioStreams_.length) {
// For audio-only or video-only content, just give each stream its own
// variant.
const streams = this.videoStreams_.concat(this.audioStreams_);
for (const stream of streams) {
const id = nextVariantId++;
variants.push({
id,
language: stream.language,
primary: stream.primary,
audio: stream.type == ContentType.AUDIO ? stream : null,
video: stream.type == ContentType.VIDEO ? stream : null,
bandwidth: stream.bandwidth || 0,
drmInfos: stream.drmInfos,
allowedByApplication: true,
allowedByKeySystem: true,
decodingInfos: [],
});
}
} else {
for (const audio of this.audioStreams_) {
for (const video of this.videoStreams_) {
const commonDrmInfos = shaka.media.DrmEngine.getCommonDrmInfos(
audio.drmInfos, video.drmInfos);
if (audio.drmInfos.length && video.drmInfos.length &&
!commonDrmInfos.length) {
shaka.log.warning(
'Incompatible DRM in audio & video, skipping variant creation.',
audio, video);
continue;
}
const id = nextVariantId++;
variants.push({
id,
language: audio.language,
primary: audio.primary,
audio,
video,
bandwidth: (audio.bandwidth || 0) + (video.bandwidth || 0),
drmInfos: commonDrmInfos,
allowedByApplication: true,
allowedByKeySystem: true,
decodingInfos: [],
});
}
}
}
this.variants_ = variants;
}
/**
* @param {!Array.<shaka.util.PeriodCombiner.Period>} periods
* @private
*/
static filterOutAudioStreamDuplicates_(periods) {
const ArrayUtils = shaka.util.ArrayUtils;
// Two audio streams are considered to be duplicates of
// one another if their ids are different, but all the other
// information is the same.
for (const period of periods) {
const filteredAudios = [];
for (const a1 of period.audioStreams) {
let duplicate = false;
for (const a2 of filteredAudios) {
if (a1.id != a2.id &&
a1.channelsCount == a2.channelsCount &&
a1.language == a2.language &&
a1.bandwidth == a2.bandwidth &&
a1.label == a2.label &&
a1.codecs == a2.codecs &&
a1.mimeType == a2.mimeType &&
ArrayUtils.hasSameElements(a1.roles, a2.roles) &&
a1.audioSamplingRate == a2.audioSamplingRate &&
a1.primary == a2.primary) {
duplicate = true;
}
}
if (!duplicate) {
filteredAudios.push(a1);
}
}
period.audioStreams = filteredAudios;
}
}
/**
* @param {!Array.<shaka.util.PeriodCombiner.Period>} periods
* @private
*/
static filterOutTextStreamDuplicates_(periods) {
const ArrayUtils = shaka.util.ArrayUtils;
// Two text streams are considered to be duplicates of
// one another if their ids are different, but all the other
// information is the same.
for (const period of periods) {
const filteredTexts = [];
for (const t1 of period.textStreams) {
let duplicate = false;
for (const t2 of filteredTexts) {
if (t1.id != t2.id &&
t1.language == t2.language &&
t1.label == t2.label &&
t1.codecs == t2.codecs &&
t1.mimeType == t2.mimeType &&
t1.bandwidth == t2.bandwidth &&
ArrayUtils.hasSameElements(t1.roles, t2.roles)) {
duplicate = true;
}
}
if (!duplicate) {
filteredTexts.push(t1);
}
}
period.textStreams = filteredTexts;
}
}
/**
* @param {!Array.<shaka.util.PeriodCombiner.Period>} periods
* @private
*/
static filterOutVideoStreamDuplicates_(periods) {
const ArrayUtils = shaka.util.ArrayUtils;
const MapUtils = shaka.util.MapUtils;
// Two video streams are considered to be duplicates of
// one another if their ids are different, but all the other
// information is the same.
for (const period of periods) {
const filteredVideos = [];
for (const v1 of period.videoStreams) {
let duplicate = false;
for (const v2 of filteredVideos) {
if (v1.id != v2.id &&
v1.width == v2.width &&
v1.frameRate == v2.frameRate &&
v1.codecs == v2.codecs &&
v1.mimeType == v2.mimeType &&
v1.label == v2.label &&
ArrayUtils.hasSameElements(v1.roles, v2.roles) &&
MapUtils.hasSameElements(v1.closedCaptions, v2.closedCaptions) &&
v1.bandwidth == v2.bandwidth) {
duplicate = true;
}
}
if (!duplicate) {
filteredVideos.push(v1);
}
}
period.videoStreams = filteredVideos;
}
}
/**
* @param {!Array.<shaka.util.PeriodCombiner.Period>} periods
* @private
*/
static filterOutImageStreamDuplicates_(periods) {
// Two image streams are considered to be duplicates of
// one another if their ids are different, but all the other
// information is the same.
for (const period of periods) {
const filteredImages = [];
for (const i1 of period.imageStreams) {
let duplicate = false;
for (const i2 of filteredImages) {
if (i1.id != i2.id &&
i1.width == i2.width &&
i1.codecs == i2.codecs &&
i1.mimeType == i2.mimeType) {
duplicate = true;
}
}
if (!duplicate) {
filteredImages.push(i1);
}
}
period.imageStreams = filteredImages;
}
}
/**
* Stitch together DB streams across periods, taking a mix of stream types.
* The offline database does not separate these by type.
*
* Unlike the DASH case, this does not need to maintain any state for manifest
* updates.
*
* @param {!Array.<!Array.<shaka.extern.StreamDB>>} streamDbsPerPeriod
* @return {!Promise.<!Array.<shaka.extern.StreamDB>>}
*/
static async combineDbStreams(streamDbsPerPeriod) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
// Optimization: for single-period content, do nothing. This makes sure
// single-period DASH or any HLS content stored offline will be 100%
// accurately represented in the output.
if (streamDbsPerPeriod.length == 1) {
return streamDbsPerPeriod[0];
}
const audioStreamDbsPerPeriod = streamDbsPerPeriod.map(
(streams) => streams.filter((s) => s.type == ContentType.AUDIO));
const videoStreamDbsPerPeriod = streamDbsPerPeriod.map(
(streams) => streams.filter((s) => s.type == ContentType.VIDEO));
const textStreamDbsPerPeriod = streamDbsPerPeriod.map(
(streams) => streams.filter((s) => s.type == ContentType.TEXT));
const imageStreamDbsPerPeriod = streamDbsPerPeriod.map(
(streams) => streams.filter((s) => s.type == ContentType.IMAGE));
// It's okay to have a period with no text or images, but our algorithm
// fails on any period without matching streams. So we add dummy streams to
// each period. Since we combine text streams by language and image streams
// by resolution, we might need a dummy even in periods with these streams
// already.
for (const textStreams of textStreamDbsPerPeriod) {
textStreams.push(shaka.util.PeriodCombiner.dummyStreamDB_(
ContentType.TEXT));
}
for (const imageStreams of imageStreamDbsPerPeriod) {
imageStreams.push(shaka.util.PeriodCombiner.dummyStreamDB_(
ContentType.IMAGE));
}
const combinedAudioStreamDbs = await shaka.util.PeriodCombiner.combine_(
/* outputStreams= */ [],
audioStreamDbsPerPeriod,
/* firstNewPeriodIndex= */ 0,
shaka.util.PeriodCombiner.cloneStreamDB_,
shaka.util.PeriodCombiner.concatenateStreamDBs_);
const combinedVideoStreamDbs = await shaka.util.PeriodCombiner.combine_(
/* outputStreams= */ [],
videoStreamDbsPerPeriod,
/* firstNewPeriodIndex= */ 0,
shaka.util.PeriodCombiner.cloneStreamDB_,
shaka.util.PeriodCombiner.concatenateStreamDBs_);
const combinedTextStreamDbs = await shaka.util.PeriodCombiner.combine_(
/* outputStreams= */ [],
textStreamDbsPerPeriod,
/* firstNewPeriodIndex= */ 0,
shaka.util.PeriodCombiner.cloneStreamDB_,
shaka.util.PeriodCombiner.concatenateStreamDBs_);
const combinedImageStreamDbs = await shaka.util.PeriodCombiner.combine_(
/* outputStreams= */ [],
imageStreamDbsPerPeriod,
/* firstNewPeriodIndex= */ 0,
shaka.util.PeriodCombiner.cloneStreamDB_,
shaka.util.PeriodCombiner.concatenateStreamDBs_);
// Recreate variantIds from scratch in the output.
// HLS content is always single-period, so the early return at the top of
// this method would catch all HLS content. DASH content stored with v3.0
// will already be flattened before storage. Therefore the only content
// that reaches this point is multi-period DASH content stored before v3.0.
// Such content always had variants generated from all combinations of audio
// and video, so we can simply do that now without loss of correctness.
let nextVariantId = 0;
if (!combinedVideoStreamDbs.length || !combinedAudioStreamDbs.length) {
// For audio-only or video-only content, just give each stream its own
// variant ID.
const combinedStreamDbs =
combinedVideoStreamDbs.concat(combinedAudioStreamDbs);
for (const stream of combinedStreamDbs) {
stream.variantIds = [nextVariantId++];
}
} else {
for (const audio of combinedAudioStreamDbs) {
for (const video of combinedVideoStreamDbs) {
const id = nextVariantId++;
video.variantIds.push(id);
audio.variantIds.push(id);
}
}
}
return combinedVideoStreamDbs
.concat(combinedAudioStreamDbs)
.concat(combinedTextStreamDbs)
.concat(combinedImageStreamDbs);
}
/**
* Combine input Streams per period into flat output Streams.
* Templatized to handle both DASH Streams and offline StreamDBs.
*
* @param {!Array.<T>} outputStreams A list of existing output streams, to
* facilitate updates for live DASH content. Will be modified and returned.
* @param {!Array.<!Array.<T>>} streamsPerPeriod A list of lists of Streams
* from each period.
* @param {number} firstNewPeriodIndex An index into streamsPerPeriod which
* represents the first new period that hasn't been processed yet.
* @param {function(T):T} clone Make a clone of an input stream.
* @param {function(T, T)} concat Concatenate the second stream onto the end
* of the first.
*
* @return {!Promise.<!Array.<T>>} The same array passed to outputStreams,
* modified to include any newly-created streams.
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static async combine_(
outputStreams, streamsPerPeriod, firstNewPeriodIndex, clone, concat) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
const unusedStreamsPerPeriod = [];
for (let i = 0; i < streamsPerPeriod.length; i++) {
if (i >= firstNewPeriodIndex) {
// This periods streams are all new.
unusedStreamsPerPeriod.push(new Set(streamsPerPeriod[i]));
} else {
// This period's streams have all been used already.
unusedStreamsPerPeriod.push(new Set());
}
}
// First, extend all existing output Streams into the new periods.
for (const outputStream of outputStreams) {
// eslint-disable-next-line no-await-in-loop
const ok = await shaka.util.PeriodCombiner.extendExistingOutputStream_(
outputStream, streamsPerPeriod, firstNewPeriodIndex, concat,
unusedStreamsPerPeriod);
if (!ok) {
// This output Stream was not properly extended to include streams from
// the new period. This is likely a bug in our algorithm, so throw an
// error.
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.PERIOD_FLATTENING_FAILED);
}
// This output stream is now complete with content from all known
// periods.
} // for (const outputStream of outputStreams)
for (const unusedStreams of unusedStreamsPerPeriod) {
for (const stream of unusedStreams) {
// Create a new output stream which includes this input stream.
const outputStream =
// eslint-disable-next-line no-await-in-loop
await shaka.util.PeriodCombiner.createNewOutputStream_(
stream, streamsPerPeriod, clone, concat,
unusedStreamsPerPeriod);
if (outputStream) {
outputStreams.push(outputStream);
} else {
// This is not a stream we can build output from, but it may become
// part of another output based on another period's stream.
}
} // for (const stream of unusedStreams)
} // for (const unusedStreams of unusedStreamsPerPeriod)
for (const unusedStreams of unusedStreamsPerPeriod) {
for (const stream of unusedStreams) {
const isDummyText = stream.type == ContentType.TEXT && !stream.language;
const isDummyImage = stream.type == ContentType.IMAGE &&
!stream.tilesLayout;
if (isDummyText || isDummyImage) {
// This is one of our dummy streams, so ignore it. We may not use
// them all, and that's fine.
continue;
}
// If this stream has a different codec/MIME than any other stream,
// then we can't play it.
// TODO(#1528): Consider changing this when we support codec switching.
const hasCodec = outputStreams.some((s) => {
return s.mimeType == stream.mimeType &&
shaka.util.MimeUtils.getCodecBase(s.codecs) ==
shaka.util.MimeUtils.getCodecBase(stream.codecs);
});
if (!hasCodec) {
continue;
}
// Any other unused stream is likely a bug in our algorithm, so throw
// an error.
shaka.log.error('Unused stream in period-flattening!',
stream, outputStreams);
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.PERIOD_FLATTENING_FAILED);
}
}
return outputStreams;
}
/**
* @param {T} outputStream An existing output stream which needs to be
* extended into new periods.
* @param {!Array.<!Array.<T>>} streamsPerPeriod A list of lists of Streams
* from each period.
* @param {number} firstNewPeriodIndex An index into streamsPerPeriod which
* represents the first new period that hasn't been processed yet.
* @param {function(T, T)} concat Concatenate the second stream onto the end
* of the first.
* @param {!Array.<!Set.<T>>} unusedStreamsPerPeriod An array of sets of
* unused streams from each period.
*
* @return {!Promise.<boolean>}
*
* @template T
* Should only be called with a Stream type in practice, but has call sites
* from other templated functions that also accept a StreamDB.
*
* @private
*/
static async extendExistingOutputStream_(
outputStream, streamsPerPeriod, firstNewPeriodIndex, concat,
unusedStreamsPerPeriod) {
const matches = shaka.util.PeriodCombiner.findMatchesInAllPeriods_(
streamsPerPeriod, outputStream);
if (!matches) {
// We were unable to extend this output stream.
shaka.log.error('No matches extending output stream!',
outputStream, streamsPerPeriod);
return false;
}
// This only exists where T == Stream, and this should only ever be called
// on Stream types. StreamDB should not have pre-existing output streams.
goog.asserts.assert(outputStream.createSegmentIndex,
'outputStream should be a Stream type!');
// We need to create all the per-period segment indexes and append them to
// the output's MetaSegmentIndex.
await shaka.util.PeriodCombiner.createSegmentIndexes_(matches);
// Assure the compiler that matches didn't become null during the async
// operation above.
goog.asserts.assert(matches, 'Matches should be non-null');
shaka.util.PeriodCombiner.extendOutputStream_(
outputStream, matches, firstNewPeriodIndex, concat,
unusedStreamsPerPeriod);
return true;
}
/**
* Creates all segment indexes for an array of streams. Returns once every
* segment index is created.
*
* @param {!Array.<!shaka.extern.Stream>} streams
* @return {!Promise}
* @private
*/
static createSegmentIndexes_(streams) {
const operations = [];
for (const stream of streams) {
operations.push(stream.createSegmentIndex());
if (stream.trickModeVideo && !stream.trickModeVideo.segmentIndex) {
operations.push(stream.trickModeVideo.createSegmentIndex());
}
}
return Promise.all(operations);
}
/**
* Create a new output Stream based on a particular input Stream. Locates
* matching Streams in all other periods and combines them into an output
* Stream.
* Templatized to handle both DASH Streams and offline StreamDBs.
*
* @param {T} stream An input stream on which to base the output stream.
* @param {!Array.<!Array.<T>>} streamsPerPeriod A list of lists of Streams
* from each period.
* @param {function(T):T} clone Make a clone of an input stream.
* @param {function(T, T)} concat Concatenate the second stream onto the end
* of the first.
* @param {!Array.<!Set.<T>>} unusedStreamsPerPeriod An array of sets of
* unused streams from each period.
*
* @return {!Promise.<?T>} A newly-created output Stream, or null if matches
* could not be found.`
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static async createNewOutputStream_(
stream, streamsPerPeriod, clone, concat, unusedStreamsPerPeriod) {
// Start by cloning the stream without segments, key IDs, etc.
const outputStream = clone(stream);
// Find best-matching streams in all periods.
const matches = shaka.util.PeriodCombiner.findMatchesInAllPeriods_(
streamsPerPeriod, outputStream);
if (!matches) {
// This is not a stream we can build output from, but it may become part
// of another output based on another period's stream.
return null;
}
// This only exists where T == Stream.
if (outputStream.createSegmentIndex) {
// For T == Stream, we need to create all the per-period segment indexes
// in advance. concat() will add them to the output's MetaSegmentIndex.
await shaka.util.PeriodCombiner.createSegmentIndexes_(matches);
}
// Assure the compiler that matches didn't become null during the async
// operation above.
goog.asserts.assert(matches, 'Matches should be non-null');
shaka.util.PeriodCombiner.extendOutputStream_(
outputStream, matches, /* firstNewPeriodIndex= */ 0, concat,
unusedStreamsPerPeriod);
return outputStream;
}
/**
* @param {T} outputStream An existing output stream which needs to be
* extended into new periods.
* @param {!Array.<T>} matches A list of matching Streams from each period.
* @param {number} firstNewPeriodIndex An index into streamsPerPeriod which
* represents the first new period that hasn't been processed yet.
* @param {function(T, T)} concat Concatenate the second stream onto the end
* of the first.
* @param {!Array.<!Set.<T>>} unusedStreamsPerPeriod An array of sets of
* unused streams from each period.
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static extendOutputStream_(
outputStream, matches, firstNewPeriodIndex, concat,
unusedStreamsPerPeriod) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
const LanguageUtils = shaka.util.LanguageUtils;
// Concatenate the new matches onto the stream, starting at the first new
// period.
for (let i = 0; i < matches.length; i++) {
if (i >= firstNewPeriodIndex) {
const match = matches[i];
concat(outputStream, match);
// We only consider an audio stream "used" if its language is related to
// the output language. There are scenarios where we want to generate
// separate tracks for each language, even when we are forced to connect
// unrelated languages across periods.
let used = true;
if (outputStream.type == ContentType.AUDIO) {
const relatedness = LanguageUtils.relatedness(
outputStream.language, match.language);
if (relatedness == 0) {
used = false;
}
}
if (used) {
unusedStreamsPerPeriod[i].delete(match);
}
}
}
}
/**
* Clone a Stream to make an output Stream for combining others across
* periods.
*
* @param {shaka.extern.Stream} stream
* @return {shaka.extern.Stream}
* @private
*/
static cloneStream_(stream) {
const clone = /** @type {shaka.extern.Stream} */(Object.assign({}, stream));
// These are wiped out now and rebuilt later from the various per-period
// streams that match this output.
clone.originalId = null;
clone.createSegmentIndex = () => Promise.resolve();
clone.segmentIndex = new shaka.media.MetaSegmentIndex();
clone.emsgSchemeIdUris = [];
clone.keyIds = new Set();
clone.closedCaptions = null;
clone.trickModeVideo = null;
return clone;
}
/**
* Clone a StreamDB to make an output stream for combining others across
* periods.
*
* @param {shaka.extern.StreamDB} streamDb
* @return {shaka.extern.StreamDB}
* @private
*/
static cloneStreamDB_(streamDb) {
const clone = /** @type {shaka.extern.StreamDB} */(Object.assign(
{}, streamDb));
// These are wiped out now and rebuilt later from the various per-period
// streams that match this output.
clone.keyIds = new Set();
clone.segments = [];
clone.variantIds = [];
clone.closedCaptions = null;
return clone;
}
/**
* Combine the various fields of the input Stream into the output.
*
* @param {shaka.extern.Stream} output
* @param {shaka.extern.Stream} input
* @private
*/
static concatenateStreams_(output, input) {
// We keep the original stream's bandwidth, resolution, frame rate,
// sample rate, and channel count to ensure that it's properly
// matched with similar content in other periods further down
// the line.
// Combine arrays, keeping only the unique elements
const combineArrays = (a, b) => Array.from(new Set(a.concat(b)));
output.roles = combineArrays(output.roles, input.roles);
if (input.emsgSchemeIdUris) {
output.emsgSchemeIdUris = combineArrays(
output.emsgSchemeIdUris, input.emsgSchemeIdUris);
}
const combineSets = (a, b) => new Set([...a, ...b]);
output.keyIds = combineSets(output.keyIds, input.keyIds);
if (output.originalId == null) {
output.originalId = input.originalId;
} else {
output.originalId += ',' + (input.originalId || '');
}
const commonDrmInfos = shaka.media.DrmEngine.getCommonDrmInfos(
output.drmInfos, input.drmInfos);
if (input.drmInfos.length && output.drmInfos.length &&
!commonDrmInfos.length) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.INCONSISTENT_DRM_ACROSS_PERIODS);
}
output.drmInfos = commonDrmInfos;
// The output is encrypted if any input was encrypted.
output.encrypted = output.encrypted || input.encrypted;
// Combine the closed captions maps.
if (input.closedCaptions) {
if (!output.closedCaptions) {
output.closedCaptions = new Map();
}
for (const [key, value] of input.closedCaptions) {
output.closedCaptions.set(key, value);
}
}
// Satisfy the compiler about the type.
goog.asserts.assert(
output.segmentIndex instanceof shaka.media.MetaSegmentIndex,
'Output streams should have a MetaSegmentIndex!');
// Satisfy the compiler that the input index has been created.
goog.asserts.assert(
input.segmentIndex,
'Input segment index should have been created by now!');
output.segmentIndex.appendSegmentIndex(input.segmentIndex);
// Combine trick-play video streams, if present.
if (input.trickModeVideo) {
if (!output.trickModeVideo) {
// Create a fresh output stream for trick-mode playback.
output.trickModeVideo = shaka.util.PeriodCombiner.cloneStream_(
input.trickModeVideo);
// Start it with whatever non-trick-mode Streams are in the output so
// far.
output.trickModeVideo.segmentIndex = output.segmentIndex.clone();
}
// Concatenate the trick mode input onto the trick mode output.
shaka.util.PeriodCombiner.concatenateStreams_(
output.trickModeVideo, input.trickModeVideo);
} else if (output.trickModeVideo) {
// We have a trick mode output, but no input from this Period. Fill it in
// from the standard input Stream.
shaka.util.PeriodCombiner.concatenateStreams_(
output.trickModeVideo, input);
}
}
/**
* Combine the various fields of the input StreamDB into the output.
*
* @param {shaka.extern.StreamDB} output
* @param {shaka.extern.StreamDB} input
* @private
*/
static concatenateStreamDBs_(output, input) {
// Combine arrays, keeping only the unique elements
const combineArrays = (a, b) => Array.from(new Set(a.concat(b)));
output.roles = combineArrays(output.roles, input.roles);
const combineSets = (a, b) => new Set([...a, ...b]);
output.keyIds = combineSets(output.keyIds, input.keyIds);
// The output is encrypted if any input was encrypted.
output.encrypted = output.encrypted && input.encrypted;
// Concatenate segments without de-duping.
output.segments.push(...input.segments);
// Combine the closed captions maps.
if (input.closedCaptions) {
if (!output.closedCaptions) {
output.closedCaptions = new Map();
}
for (const [key, value] of input.closedCaptions) {
output.closedCaptions.set(key, value);
}
}
}
/**
* Finds streams in all periods which match the output stream.
*
* @param {!Array.<!Array.<T>>} streamsPerPeriod
* @param {T} outputStream
* @return {Array.<T>}
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static findMatchesInAllPeriods_(streamsPerPeriod, outputStream) {
const matches = [];
for (const streams of streamsPerPeriod) {
const match = shaka.util.PeriodCombiner.findBestMatchInPeriod_(
streams, outputStream);
if (!match) {
return null;
}
matches.push(match);
}
return matches;
}
/**
* Find the best match for the output stream.
*
* @param {!Array.<T>} streams
* @param {T} outputStream
* @return {?T} Returns null if no match can be found.
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static findBestMatchInPeriod_(streams, outputStream) {
const areCompatible = {
'audio': shaka.util.PeriodCombiner.areAVStreamsCompatible_,
'video': shaka.util.PeriodCombiner.areAVStreamsCompatible_,
'text': shaka.util.PeriodCombiner.areTextStreamsCompatible_,
'image': shaka.util.PeriodCombiner.areImageStreamsCompatible_,
}[outputStream.type];
const isBetterMatch = {
'audio': shaka.util.PeriodCombiner.isAudioStreamBetterMatch_,
'video': shaka.util.PeriodCombiner.isVideoStreamBetterMatch_,
'text': shaka.util.PeriodCombiner.isTextStreamBetterMatch_,
'image': shaka.util.PeriodCombiner.isImageStreamBetterMatch_,
}[outputStream.type];
let best = null;
for (const stream of streams) {
if (!areCompatible(outputStream, stream)) {
continue;
}
if (!best || isBetterMatch(outputStream, best, stream)) {
best = stream;
}
}
return best;
}
/**
* @param {T} outputStream An audio or video output stream
* @param {T} candidate A candidate stream to be combined with the output
* @return {boolean} True if the candidate could be combined with the
* output stream
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static areAVStreamsCompatible_(outputStream, candidate) {
const getCodecBase = (codecs) => shaka.util.MimeUtils.getCodecBase(codecs);
// Check MIME type and codecs, which should always be the same.
if (candidate.mimeType != outputStream.mimeType ||
getCodecBase(candidate.codecs) != getCodecBase(outputStream.codecs)) {
return false;
}
// This field is only available on Stream, not StreamDB.
if (outputStream.drmInfos) {
// Check for compatible DRM systems. Note that clear streams are
// implicitly compatible with any DRM and with each other.
if (!shaka.media.DrmEngine.areDrmCompatible(outputStream.drmInfos,
candidate.drmInfos)) {
return false;
}
}
return true;
}
/**
* @param {T} outputStream A text output stream
* @param {T} candidate A candidate stream to be combined with the output
* @return {boolean} True if the candidate could be combined with the
* output
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static areTextStreamsCompatible_(outputStream, candidate) {
const LanguageUtils = shaka.util.LanguageUtils;
// For text, we don't care about MIME type or codec. We can always switch
// between text types.
// The output stream should not be a dummy stream inserted to fill a period
// gap. So reject any candidate if the output has no language. This would
// cause findMatchesInAllPeriods_ to return null and this output stream to
// be skipped (meaning no output streams based on it).
if (!outputStream.language) {
return false;
}
// If the candidate is a dummy, then it is compatible, and we could use it
// if nothing else matches.
if (!candidate.language) {
return true;
}
const languageRelatedness = LanguageUtils.relatedness(
outputStream.language, candidate.language);
// We will strictly avoid combining text across languages or "kinds"
// (caption vs subtitle).
if (languageRelatedness == 0 ||
candidate.kind != outputStream.kind) {
return false;
}
return true;
}
/**
* @param {T} outputStream A image output stream
* @param {T} candidate A candidate stream to be combined with the output
* @return {boolean} True if the candidate could be combined with the
* output
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static areImageStreamsCompatible_(outputStream, candidate) {
// For image, we don't care about MIME type. We can always switch
// between image types.
// The output stream should not be a dummy stream inserted to fill a period
// gap. So reject any candidate if the output has no tilesLayout. This
// would cause findMatchesInAllPeriods_ to return null and this output
// stream to be skipped (meaning no output streams based on it).
if (!outputStream.tilesLayout) {
return false;
}
return true;
}
/**
* @param {T} outputStream An audio output stream
* @param {T} best The best match so far for this period
* @param {T} candidate A candidate stream which might be better
* @return {boolean} True if the candidate is a better match
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static isAudioStreamBetterMatch_(outputStream, best, candidate) {
const LanguageUtils = shaka.util.LanguageUtils;
const {BETTER, EQUAL, WORSE} = shaka.util.PeriodCombiner.BetterOrWorse;
// If the output stream was based on the candidate stream, the candidate
// stream should be considered a better match. We can check this by
// comparing their ids.
if (outputStream.id == candidate.id) {
return true;
}
// Otherwise, compare the streams' characteristics to determine the best
// match.
// The most important thing is language. In some cases, we will accept a
// different language across periods when we must.
const bestRelatedness = LanguageUtils.relatedness(
outputStream.language, best.language);
const candidateRelatedness = LanguageUtils.relatedness(
outputStream.language, candidate.language);
if (candidateRelatedness > bestRelatedness) {
return true;
}
if (candidateRelatedness < bestRelatedness) {
return false;
}
// If the language doesn't match, but the candidate is the "primary"
// language, then that should be preferred as a fallback.
if (!best.primary && candidate.primary) {
return true;
}
if (best.primary && !candidate.primary) {
return false;
}
// If language-based differences haven't decided this, look at roles. If
// the candidate has more roles in common with the output, upgrade to the
// candidate.
if (outputStream.roles.length) {
const bestRoleMatches =
best.roles.filter((role) => outputStream.roles.includes(role));
const candidateRoleMatches =
candidate.roles.filter((role) => outputStream.roles.includes(role));
if (candidateRoleMatches.length > bestRoleMatches.length) {
return true;
} else if (candidateRoleMatches.length < bestRoleMatches.length) {
return false;
} else {
// Both streams have the same role overlap with the outputStream
// If this is the case, choose the stream with the fewer roles overall.
// Streams that match best together tend to be streams with the same
// roles, e g stream1 with roles [r1, r2] is likely a better match
// for stream2 with roles [r1, r2] vs stream3 with roles
// [r1, r2, r3, r4].
// If we match stream1 with stream3 due to the same role overlap,
// stream2 is likely to be left unmatched and error out later.
// See https://github.com/shaka-project/shaka-player/issues/2542 for
// more details.
return candidate.roles.length < best.roles.length;
}
} else if (!candidate.roles.length && best.roles.length) {
// If outputStream has no roles, and only one of the streams has no roles,
// choose the one with no roles.
return true;
} else if (candidate.roles.length && !best.roles.length) {
return false;
}
// If language-based and role-based features are equivalent, take the audio
// with the closes channel count to the output.
const channelsBetterOrWorse =
shaka.util.PeriodCombiner.compareClosestPreferLower(
outputStream.channelsCount,
best.channelsCount,
candidate.channelsCount);
if (channelsBetterOrWorse == BETTER) {
return true;
} else if (channelsBetterOrWorse == WORSE) {
return false;
}
// If channels are equal, take the closest sample rate to the output.
const sampleRateBetterOrWorse =
shaka.util.PeriodCombiner.compareClosestPreferLower(
outputStream.audioSamplingRate,
best.audioSamplingRate,
candidate.audioSamplingRate);
if (sampleRateBetterOrWorse == BETTER) {
return true;
} else if (sampleRateBetterOrWorse == WORSE) {
return false;
}
if (outputStream.bandwidth) {
// Take the audio with the closest bandwidth to the output.
const bandwidthBetterOrWorse =
shaka.util.PeriodCombiner.compareClosestPreferMinimalAbsDiff_(
outputStream.bandwidth,
best.bandwidth,
candidate.bandwidth);
if (bandwidthBetterOrWorse == BETTER) {
return true;
} else if (bandwidthBetterOrWorse == WORSE) {
return false;
}
}
// If the result of each comparison was inconclusive, default to false.
return false;
}
/**
* @param {T} outputStream A video output stream
* @param {T} best The best match so far for this period
* @param {T} candidate A candidate stream which might be better
* @return {boolean} True if the candidate is a better match
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static isVideoStreamBetterMatch_(outputStream, best, candidate) {
const {BETTER, EQUAL, WORSE} = shaka.util.PeriodCombiner.BetterOrWorse;
// If the output stream was based on the candidate stream, the candidate
// stream should be considered a better match. We can check this by
// comparing their ids.
if (outputStream.id == candidate.id) {
return true;
}
// Otherwise, compare the streams' characteristics to determine the best
// match.
// Take the video with the closest resolution to the output.
const resolutionBetterOrWorse =
shaka.util.PeriodCombiner.compareClosestPreferLower(
outputStream.width * outputStream.height,
best.width * best.height,
candidate.width * candidate.height);
if (resolutionBetterOrWorse == BETTER) {
return true;
} else if (resolutionBetterOrWorse == WORSE) {
return false;
}
// We may not know the frame rate for the content, in which case this gets
// skipped.
if (outputStream.frameRate) {
// Take the video with the closest frame rate to the output.
const frameRateBetterOrWorse =
shaka.util.PeriodCombiner.compareClosestPreferLower(
outputStream.frameRate,
best.frameRate,
candidate.frameRate);
if (frameRateBetterOrWorse == BETTER) {
return true;
} else if (frameRateBetterOrWorse == WORSE) {
return false;
}
}
if (outputStream.bandwidth) {
// Take the video with the closest bandwidth to the output.
const bandwidthBetterOrWorse =
shaka.util.PeriodCombiner.compareClosestPreferMinimalAbsDiff_(
outputStream.bandwidth,
best.bandwidth,
candidate.bandwidth);
if (bandwidthBetterOrWorse == BETTER) {
return true;
} else if (bandwidthBetterOrWorse == WORSE) {
return false;
}
}
// If the result of each comparison was inconclusive, default to false.
return false;
}
/**
* @param {T} outputStream A text output stream
* @param {T} best The best match so far for this period
* @param {T} candidate A candidate stream which might be better
* @return {boolean} True if the candidate is a better match
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static isTextStreamBetterMatch_(outputStream, best, candidate) {
const LanguageUtils = shaka.util.LanguageUtils;
// If the output stream was based on the candidate stream, the candidate
// stream should be considered a better match. We can check this by
// comparing their ids.
if (outputStream.id == candidate.id) {
return true;
}
// Otherwise, compare the streams' characteristics to determine the best
// match.
// The most important thing is language. In some cases, we will accept a
// different language across periods when we must.
const bestRelatedness = LanguageUtils.relatedness(
outputStream.language, best.language);
const candidateRelatedness = LanguageUtils.relatedness(
outputStream.language, candidate.language);
if (candidateRelatedness > bestRelatedness) {
return true;
}
if (candidateRelatedness < bestRelatedness) {
return false;
}
// If the language doesn't match, but the candidate is the "primary"
// language, then that should be preferred as a fallback.
if (!best.primary && candidate.primary) {
return true;
}
if (best.primary && !candidate.primary) {
return false;
}
// If the candidate has more roles in common with the output, upgrade to the
// candidate.
if (outputStream.roles.length) {
const bestRoleMatches =
best.roles.filter((role) => outputStream.roles.includes(role));
const candidateRoleMatches =
candidate.roles.filter((role) => outputStream.roles.includes(role));
if (candidateRoleMatches.length > bestRoleMatches.length) {
return true;
}
if (candidateRoleMatches.length < bestRoleMatches.length) {
return false;
}
} else if (!candidate.roles.length && best.roles.length) {
// If outputStream has no roles, and only one of the streams has no roles,
// choose the one with no roles.
return true;
} else if (candidate.roles.length && !best.roles.length) {
return false;
}
// If the candidate has the same MIME type and codec, upgrade to the
// candidate. It's not required that text streams use the same format
// across periods, but it's a helpful signal. Some content in our demo app
// contains the same languages repeated with two different text formats in
// each period. This condition ensures that all text streams are used.
// Otherwise, we wind up with some one stream of each language left unused,
// triggering a failure.
if (candidate.mimeType == outputStream.mimeType &&
candidate.codecs == outputStream.codecs &&
(best.mimeType != outputStream.mimeType ||
best.codecs != outputStream.codecs)) {
return true;
}
// If the result of each comparison was inconclusive, default to false.
return false;
}
/**
* @param {T} outputStream A image output stream
* @param {T} best The best match so far for this period
* @param {T} candidate A candidate stream which might be better
* @return {boolean} True if the candidate is a better match
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static isImageStreamBetterMatch_(outputStream, best, candidate) {
const {BETTER, EQUAL, WORSE} = shaka.util.PeriodCombiner.BetterOrWorse;
// If the output stream was based on the candidate stream, the candidate
// stream should be considered a better match. We can check this by
// comparing their ids.
if (outputStream.id == candidate.id) {
return true;
}
// Take the image with the closest resolution to the output.
const resolutionBetterOrWorse =
shaka.util.PeriodCombiner.compareClosestPreferLower(
outputStream.width * outputStream.height,
best.width * best.height,
candidate.width * candidate.height);
if (resolutionBetterOrWorse == BETTER) {
return true;
} else if (resolutionBetterOrWorse == WORSE) {
return false;
}
// If the result of each comparison was inconclusive, default to false.
return false;
}
/**
* Create a dummy StreamDB to fill in periods that are missing a certain type,
* to avoid failing the general flattening algorithm. This won't be used for
* audio or video, since those are strictly required in all periods if they
* exist in any period.
*
* @param {shaka.util.ManifestParserUtils.ContentType} type
* @return {shaka.extern.StreamDB}
* @private
*/
static dummyStreamDB_(type) {
return {
id: 0,
originalId: '',
primary: false,
type,
mimeType: '',
codecs: '',
language: '',
label: null,
width: null,
height: null,
encrypted: false,
keyIds: new Set(),
segments: [],
variantIds: [],
roles: [],
forced: false,
channelsCount: null,
audioSamplingRate: null,
spatialAudio: false,
closedCaptions: null,
};
}
/**
* Create a dummy Stream to fill in periods that are missing a certain type,
* to avoid failing the general flattening algorithm. This won't be used for
* audio or video, since those are strictly required in all periods if they
* exist in any period.
*
* @param {shaka.util.ManifestParserUtils.ContentType} type
* @return {shaka.extern.Stream}
* @private
*/
static dummyStream_(type) {
return {
id: 0,
originalId: '',
createSegmentIndex: () => Promise.resolve(),
segmentIndex: new shaka.media.SegmentIndex([]),
mimeType: '',
codecs: '',
encrypted: false,
drmInfos: [],
keyIds: new Set(),
language: '',
label: null,
type,
primary: false,
trickModeVideo: null,
emsgSchemeIdUris: null,
roles: [],
forced: false,
channelsCount: null,
audioSamplingRate: null,
spatialAudio: false,
closedCaptions: null,
};
}
/**
* Compare the best value so far with the candidate value and the output
* value. Decide if the candidate is better, equal, or worse than the best
* so far. Any value less than or equal to the output is preferred over a
* larger value, and closer to the output is better than farther.
*
* This provides us a generic way to choose things that should match as
* closely as possible, like resolution, frame rate, audio channels, or
* sample rate. If we have to go higher to make a match, we will. But if
* the user selects 480p, for example, we don't want to surprise them with
* 720p and waste bandwidth if there's another choice available to us.
*
* @param {number} outputValue
* @param {number} bestValue
* @param {number} candidateValue
* @return {shaka.util.PeriodCombiner.BetterOrWorse}
*/
static compareClosestPreferLower(outputValue, bestValue, candidateValue) {
const {BETTER, EQUAL, WORSE} = shaka.util.PeriodCombiner.BetterOrWorse;
// If one is the exact match for the output value, and the other isn't,
// prefer the one that is the exact match.
if (bestValue == outputValue && outputValue != candidateValue) {
return WORSE;
} else if (candidateValue == outputValue && outputValue != bestValue) {
return BETTER;
}
if (bestValue > outputValue) {
if (candidateValue <= outputValue) {
// Any smaller-or-equal-to-output value is preferable to a
// bigger-than-output value.
return BETTER;
}
// Both "best" and "candidate" are greater than the output. Take
// whichever is closer.
if (candidateValue - outputValue < bestValue - outputValue) {
return BETTER;
} else if (candidateValue - outputValue > bestValue - outputValue) {
return WORSE;
}
} else {
// The "best" so far is less than or equal to the output. If the
// candidate is bigger than the output, we don't want it.
if (candidateValue > outputValue) {
return WORSE;
}
// Both "best" and "candidate" are less than or equal to the output.
// Take whichever is closer.
if (outputValue - candidateValue < outputValue - bestValue) {
return BETTER;
} else if (outputValue - candidateValue > outputValue - bestValue) {
return WORSE;
}
}
return EQUAL;
}
/**
* @param {number} outputValue
* @param {number} bestValue
* @param {number} candidateValue
* @return {shaka.util.PeriodCombiner.BetterOrWorse}
* @private
*/
static compareClosestPreferMinimalAbsDiff_(
outputValue, bestValue, candidateValue) {
const {BETTER, EQUAL, WORSE} = shaka.util.PeriodCombiner.BetterOrWorse;
const absDiffBest = Math.abs(outputValue - bestValue);
const absDiffCandidate = Math.abs(outputValue - candidateValue);
if (absDiffCandidate < absDiffBest) {
return BETTER;
} else if (absDiffBest < absDiffCandidate) {
return WORSE;
}
return EQUAL;
}
};
/**
* @typedef {{
* id: string,
* audioStreams: !Array.<shaka.extern.Stream>,
* videoStreams: !Array.<shaka.extern.Stream>,
* textStreams: !Array.<shaka.extern.Stream>,
* imageStreams: !Array.<shaka.extern.Stream>
* }}
*
* @description Contains the streams from one DASH period.
*
* @property {string} id
* The Period ID.
* @property {!Array.<shaka.extern.Stream>} audioStreams
* The audio streams from one Period.
* @property {!Array.<shaka.extern.Stream>} videoStreams
* The video streams from one Period.
* @property {!Array.<shaka.extern.Stream>} textStreams
* The text streams from one Period.
* @property {!Array.<shaka.extern.Stream>} imageStreams
* The image streams from one Period.
*/
shaka.util.PeriodCombiner.Period;
/**
* @enum {number}
*/
shaka.util.PeriodCombiner.BetterOrWorse = {
BETTER: 1,
EQUAL: 0,
WORSE: -1,
};