/*! @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.MetaSegmentIndex');
goog.require('shaka.media.SegmentIndex');
goog.require('shaka.util.DrmUtils');
goog.require('shaka.util.Error');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.LanguageUtils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.MimeUtils');
/**
* A utility to combine streams across periods.
*
* @implements {shaka.util.IReleasable}
* @final
* @export
*/
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_ = [];
/** @private {boolean} */
this.multiTypeVariantsAllowed_ = false;
/** @private {boolean} */
this.useStreamOnce_ = false;
/**
* 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_ = [];
this.multiTypeVariantsAllowed_ = false;
this.useStreamOnce_ = false;
this.usedPeriodIds_.clear();
}
/**
* @return {!Array.<shaka.extern.Variant>}
*
* @export
*/
getVariants() {
return this.variants_;
}
/**
* @return {!Array.<shaka.extern.Stream>}
*
* @export
*/
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>}
*
* @export
*/
getImageStreams() {
return this.imageStreams_;
}
/**
* Deletes a stream from matchedStreams because it is no longer needed
*
* @param {?shaka.extern.Stream} stream
* @param {string} periodId
*
* @export
*/
deleteStream(stream, periodId) {
if (!stream) {
return;
}
const ContentType = shaka.util.ManifestParserUtils.ContentType;
if (stream.type == ContentType.AUDIO) {
for (const audioStream of this.audioStreams_) {
audioStream.matchedStreams = audioStream.matchedStreams.filter((s) => {
return s !== stream;
});
}
} else if (stream.type == ContentType.VIDEO) {
for (const videoStream of this.videoStreams_) {
videoStream.matchedStreams = videoStream.matchedStreams.filter((s) => {
return s !== stream;
});
if (videoStream.trickModeVideo) {
videoStream.trickModeVideo.matchedStreams =
videoStream.trickModeVideo.matchedStreams.filter((s) => {
return s !== stream;
});
}
}
} else if (stream.type == ContentType.TEXT) {
for (const textStream of this.textStreams_) {
textStream.matchedStreams = textStream.matchedStreams.filter((s) => {
return s !== stream;
});
}
} else if (stream.type == ContentType.IMAGE) {
for (const imageStream of this.imageStreams_) {
imageStream.matchedStreams = imageStream.matchedStreams.filter((s) => {
return s !== stream;
});
}
}
if (stream.segmentIndex) {
stream.closeSegmentIndex();
}
this.usedPeriodIds_.delete(periodId);
}
/**
* Returns an object that contains arrays of streams by type
* @param {!Array<shaka.extern.Period>} periods
* @param {boolean} addDummy
* @return {{
* audioStreamsPerPeriod: !Array<!Map<string, shaka.extern.Stream>>,
* videoStreamsPerPeriod: !Array<!Map<string, shaka.extern.Stream>>,
* textStreamsPerPeriod: !Array<!Map<string, shaka.extern.Stream>>,
* imageStreamsPerPeriod: !Array<!Map<string, shaka.extern.Stream>>
* }}
* @private
*/
getStreamsPerPeriod_(periods, addDummy) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
const PeriodCombiner = shaka.util.PeriodCombiner;
const audioStreamsPerPeriod = [];
const videoStreamsPerPeriod = [];
const textStreamsPerPeriod = [];
const imageStreamsPerPeriod = [];
for (const period of periods) {
const audioMap = new Map(period.audioStreams.map((s) =>
[PeriodCombiner.generateAudioKey_(s), s]));
const videoMap = new Map(period.videoStreams.map((s) =>
[PeriodCombiner.generateVideoKey_(s), s]));
const textMap = new Map(period.textStreams.map((s) =>
[PeriodCombiner.generateTextKey_(s), s]));
const imageMap = new Map(period.imageStreams.map((s) =>
[PeriodCombiner.generateImageKey_(s), s]));
// 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.
if (addDummy) {
const dummyText = PeriodCombiner.dummyStream_(ContentType.TEXT);
textMap.set(PeriodCombiner.generateTextKey_(dummyText), dummyText);
const dummyImage = PeriodCombiner.dummyStream_(ContentType.IMAGE);
imageMap.set(PeriodCombiner.generateImageKey_(dummyImage), dummyImage);
}
audioStreamsPerPeriod.push(audioMap);
videoStreamsPerPeriod.push(videoMap);
textStreamsPerPeriod.push(textMap);
imageStreamsPerPeriod.push(imageMap);
}
return {
audioStreamsPerPeriod,
videoStreamsPerPeriod,
textStreamsPerPeriod,
imageStreamsPerPeriod,
};
}
/**
* @param {!Array.<shaka.extern.Period>} periods
* @param {boolean} isDynamic
* @param {boolean=} isPatchUpdate
* @return {!Promise}
*
* @export
*/
async combinePeriods(periods, isDynamic, isPatchUpdate = false) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
// 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) {
// We need to filter out duplicates, so call getStreamsPerPeriod()
// so it will do that by usage of Map.
const {
audioStreamsPerPeriod,
videoStreamsPerPeriod,
textStreamsPerPeriod,
imageStreamsPerPeriod,
} = this.getStreamsPerPeriod_(periods, /* addDummy= */ false);
this.audioStreams_ = Array.from(audioStreamsPerPeriod[0].values());
this.videoStreams_ = Array.from(videoStreamsPerPeriod[0].values());
this.textStreams_ = Array.from(textStreamsPerPeriod[0].values());
this.imageStreams_ = Array.from(imageStreamsPerPeriod[0].values());
} else {
// How many periods we've seen before which are not included in this call.
const periodsMissing = isPatchUpdate ? this.usedPeriodIds_.size : 0;
// 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,
videoStreamsPerPeriod,
textStreamsPerPeriod,
imageStreamsPerPeriod,
} = this.getStreamsPerPeriod_(periods, /* addDummy= */ true);
await Promise.all([
this.combine_(
this.audioStreams_,
audioStreamsPerPeriod,
firstNewPeriodIndex,
shaka.util.PeriodCombiner.cloneStream_,
shaka.util.PeriodCombiner.concatenateStreams_,
periodsMissing),
this.combine_(
this.videoStreams_,
videoStreamsPerPeriod,
firstNewPeriodIndex,
shaka.util.PeriodCombiner.cloneStream_,
shaka.util.PeriodCombiner.concatenateStreams_,
periodsMissing),
this.combine_(
this.textStreams_,
textStreamsPerPeriod,
firstNewPeriodIndex,
shaka.util.PeriodCombiner.cloneStream_,
shaka.util.PeriodCombiner.concatenateStreams_,
periodsMissing),
this.combine_(
this.imageStreams_,
imageStreamsPerPeriod,
firstNewPeriodIndex,
shaka.util.PeriodCombiner.cloneStream_,
shaka.util.PeriodCombiner.concatenateStreams_,
periodsMissing),
]);
}
// 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_.length ? this.videoStreams_ :
this.audioStreams_;
for (const stream of streams) {
const id = nextVariantId++;
variants.push({
id,
language: stream.language,
disabledUntilTime: 0,
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.util.DrmUtils.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,
disabledUntilTime: 0,
primary: audio.primary,
audio,
video,
bandwidth: (audio.bandwidth || 0) + (video.bandwidth || 0),
drmInfos: commonDrmInfos,
allowedByApplication: true,
allowedByKeySystem: true,
decodingInfos: [],
});
}
}
}
this.variants_ = variants;
}
/**
* 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;
const PeriodCombiner = shaka.util.PeriodCombiner;
// 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) => new Map(streams
.filter((s) => s.type === ContentType.AUDIO)
.map((s) => [PeriodCombiner.generateAudioKey_(s), s])));
const videoStreamDbsPerPeriod = streamDbsPerPeriod.map(
(streams) => new Map(streams
.filter((s) => s.type === ContentType.VIDEO)
.map((s) => [PeriodCombiner.generateVideoKey_(s), s])));
const textStreamDbsPerPeriod = streamDbsPerPeriod.map(
(streams) => new Map(streams
.filter((s) => s.type === ContentType.TEXT)
.map((s) => [PeriodCombiner.generateTextKey_(s), s])));
const imageStreamDbsPerPeriod = streamDbsPerPeriod.map(
(streams) => new Map(streams
.filter((s) => s.type === ContentType.IMAGE)
.map((s) => [PeriodCombiner.generateImageKey_(s), s])));
// 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) {
const dummy = PeriodCombiner.dummyStreamDB_(ContentType.TEXT);
textStreams.set(PeriodCombiner.generateTextKey_(dummy), dummy);
}
for (const imageStreams of imageStreamDbsPerPeriod) {
const dummy = PeriodCombiner.dummyStreamDB_(ContentType.IMAGE);
imageStreams.set(PeriodCombiner.generateImageKey_(dummy), dummy);
}
const periodCombiner = new shaka.util.PeriodCombiner();
const combinedAudioStreamDbs = await periodCombiner.combine_(
/* outputStreams= */ [],
audioStreamDbsPerPeriod,
/* firstNewPeriodIndex= */ 0,
shaka.util.PeriodCombiner.cloneStreamDB_,
shaka.util.PeriodCombiner.concatenateStreamDBs_,
/* periodsMissing= */ 0);
const combinedVideoStreamDbs = await periodCombiner.combine_(
/* outputStreams= */ [],
videoStreamDbsPerPeriod,
/* firstNewPeriodIndex= */ 0,
shaka.util.PeriodCombiner.cloneStreamDB_,
shaka.util.PeriodCombiner.concatenateStreamDBs_,
/* periodsMissing= */ 0);
const combinedTextStreamDbs = await periodCombiner.combine_(
/* outputStreams= */ [],
textStreamDbsPerPeriod,
/* firstNewPeriodIndex= */ 0,
shaka.util.PeriodCombiner.cloneStreamDB_,
shaka.util.PeriodCombiner.concatenateStreamDBs_,
/* periodsMissing= */ 0);
const combinedImageStreamDbs = await periodCombiner.combine_(
/* outputStreams= */ [],
imageStreamDbsPerPeriod,
/* firstNewPeriodIndex= */ 0,
shaka.util.PeriodCombiner.cloneStreamDB_,
shaka.util.PeriodCombiner.concatenateStreamDBs_,
/* periodsMissing= */ 0);
// 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<!Map<string, T>>} streamsPerPeriod A list of maps 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.
* @param {number} periodsMissing The number of periods missing
*
* @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
*/
async combine_(
outputStreams, streamsPerPeriod, firstNewPeriodIndex, clone, concat,
periodsMissing) {
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].values()));
} 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 this.extendExistingOutputStream_(
outputStream, streamsPerPeriod, firstNewPeriodIndex, concat,
unusedStreamsPerPeriod, periodsMissing);
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 = this.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) {
if (shaka.util.PeriodCombiner.isDummy_(stream)) {
// 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.
const hasCodec = outputStreams.some((s) => {
return this.areAVStreamsCompatible_(stream, s);
});
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<!Map<string, T>>} streamsPerPeriod A list of maps 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.
* @param {number} periodsMissing How many periods are missing in this update.
*
* @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
*/
async extendExistingOutputStream_(
outputStream, streamsPerPeriod, firstNewPeriodIndex, concat,
unusedStreamsPerPeriod, periodsMissing) {
this.findMatchesInAllPeriods_(streamsPerPeriod,
outputStream, periodsMissing > 0);
// 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!');
if (!outputStream.matchedStreams) {
// We were unable to extend this output stream.
shaka.log.error('No matches extending output stream!',
outputStream, streamsPerPeriod);
return false;
}
// We need to create all the per-period segment indexes and append them to
// the output's MetaSegmentIndex.
if (outputStream.segmentIndex) {
await shaka.util.PeriodCombiner.extendOutputSegmentIndex_(outputStream,
firstNewPeriodIndex + periodsMissing);
}
shaka.util.PeriodCombiner.extendOutputStream_(outputStream,
firstNewPeriodIndex, concat, unusedStreamsPerPeriod, periodsMissing);
return true;
}
/**
* Creates the segment indexes for an array of input streams, and append them
* to the output stream's segment index.
*
* @param {shaka.extern.Stream} outputStream
* @param {number} firstNewPeriodIndex An index into streamsPerPeriod which
* represents the first new period that hasn't been processed yet.
* @private
*/
static async extendOutputSegmentIndex_(outputStream, firstNewPeriodIndex) {
const operations = [];
const streams = outputStream.matchedStreams;
goog.asserts.assert(streams, 'matched streams should be valid');
for (let i = firstNewPeriodIndex; i < streams.length; i++) {
const stream = streams[i];
operations.push(stream.createSegmentIndex());
if (stream.trickModeVideo && !stream.trickModeVideo.segmentIndex) {
operations.push(stream.trickModeVideo.createSegmentIndex());
}
}
await Promise.all(operations);
// Concatenate the new matches onto the stream, starting at the first new
// period.
// Satisfy the compiler about the type.
// Also checks if the segmentIndex is still valid after the async
// operations, to make sure we stop if the active stream has changed.
if (outputStream.segmentIndex instanceof shaka.media.MetaSegmentIndex) {
for (let i = firstNewPeriodIndex; i < streams.length; i++) {
const match = streams[i];
goog.asserts.assert(match.segmentIndex,
'stream should have a segmentIndex.');
if (match.segmentIndex) {
outputStream.segmentIndex.appendSegmentIndex(match.segmentIndex);
}
}
}
}
/**
* 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<!Map<string, T>>} streamsPerPeriod A list of maps 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 {?T} A newly-created output Stream, or null if matches
* could not be found.`
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
createNewOutputStream_(
stream, streamsPerPeriod, clone, concat, unusedStreamsPerPeriod) {
// Check do we want to create output stream from dummy stream
// and if so, return quickly.
if (shaka.util.PeriodCombiner.isDummy_(stream)) {
return null;
}
// Start by cloning the stream without segments, key IDs, etc.
const outputStream = clone(stream);
// Find best-matching streams in all periods.
this.findMatchesInAllPeriods_(streamsPerPeriod, outputStream);
// This only exists where T == Stream.
if (outputStream.createSegmentIndex) {
// Override the createSegmentIndex function of the outputStream.
outputStream.createSegmentIndex = async () => {
if (!outputStream.segmentIndex) {
outputStream.segmentIndex = new shaka.media.MetaSegmentIndex();
await shaka.util.PeriodCombiner.extendOutputSegmentIndex_(
outputStream, /* firstNewPeriodIndex= */ 0);
}
};
// For T == Stream, we need to create all the per-period segment indexes
// in advance. concat() will add them to the output's MetaSegmentIndex.
}
if (!outputStream.matchedStreams || !outputStream.matchedStreams.length) {
// 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;
}
shaka.util.PeriodCombiner.extendOutputStream_(outputStream,
/* firstNewPeriodIndex= */ 0, concat, unusedStreamsPerPeriod,
/* periodsMissing= */ 0);
return outputStream;
}
/**
* @param {T} outputStream An existing output stream which needs to be
* extended into new periods.
* @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.
* @param {number} periodsMissing How many periods are missing in this update
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static extendOutputStream_(
outputStream, firstNewPeriodIndex, concat, unusedStreamsPerPeriod,
periodsMissing) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
const LanguageUtils = shaka.util.LanguageUtils;
const matches = outputStream.matchedStreams;
// Assure the compiler that matches didn't become null during the async
// operation before.
goog.asserts.assert(outputStream.matchedStreams,
'matchedStreams should be non-null');
// Concatenate the new matches onto the stream, starting at the first new
// period.
const start = firstNewPeriodIndex + periodsMissing;
for (let i = start; i < matches.length; i++) {
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 - periodsMissing].delete(match);
// Add the full mimetypes to the stream.
if (match.fullMimeTypes) {
for (const fullMimeType of match.fullMimeTypes.values()) {
outputStream.fullMimeTypes.add(fullMimeType);
}
}
}
}
}
/**
* 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.closeSegmentIndex = () => {
if (clone.segmentIndex) {
clone.segmentIndex.release();
clone.segmentIndex = null;
}
// Close the segment index of the matched streams.
if (clone.matchedStreams) {
for (const match of clone.matchedStreams) {
if (match.segmentIndex) {
match.segmentIndex.release();
match.segmentIndex = null;
}
}
}
};
// Clone roles array so this output stream can own it.
clone.roles = clone.roles.slice();
clone.segmentIndex = null;
clone.emsgSchemeIdUris = [];
clone.keyIds = new Set();
clone.closedCaptions = stream.closedCaptions ?
new Map(stream.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));
// Clone roles array so this output stream can own it.
clone.roles = clone.roles.slice();
// 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 = streamDb.closedCaptions ?
new Map(streamDb.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 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 = (output, input) => {
if (!output) {
output = [];
}
for (const item of input) {
if (!output.includes(item)) {
output.push(item);
}
}
return output;
};
output.roles = combineArrays(output.roles, input.roles);
if (input.emsgSchemeIdUris) {
output.emsgSchemeIdUris = combineArrays(
output.emsgSchemeIdUris, input.emsgSchemeIdUris);
}
for (const keyId of input.keyIds) {
output.keyIds.add(keyId);
}
if (output.originalId == null) {
output.originalId = input.originalId;
} else {
const newOriginalId = (input.originalId || '');
if (newOriginalId && !output.originalId.endsWith(newOriginalId)) {
output.originalId += ',' + newOriginalId;
}
}
const commonDrmInfos = shaka.util.DrmUtils.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);
}
}
// Prioritize the highest bandwidth
if (output.bandwidth && input.bandwidth) {
output.bandwidth = Math.max(output.bandwidth, input.bandwidth);
}
// 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);
output.trickModeVideo.matchedStreams = [];
output.trickModeVideo.createSegmentIndex = () => {
if (output.trickModeVideo.segmentIndex) {
return Promise.resolve();
}
const segmentIndex = new shaka.media.MetaSegmentIndex();
goog.asserts.assert(output.trickModeVideo.matchedStreams,
'trickmode matched streams should exist');
for (const stream of output.trickModeVideo.matchedStreams) {
goog.asserts.assert(stream.segmentIndex,
'trickmode segment index should exist');
segmentIndex.appendSegmentIndex(stream.segmentIndex);
}
output.trickModeVideo.segmentIndex = segmentIndex;
return Promise.resolve();
};
}
// Concatenate the trick mode input onto the trick mode output.
output.trickModeVideo.matchedStreams.push(input.trickModeVideo);
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.
output.trickModeVideo.matchedStreams.push(input);
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 = (output, input) => {
if (!output) {
output = [];
}
for (const item of input) {
if (!output.includes(item)) {
output.push(item);
}
}
return output;
};
output.roles = combineArrays(output.roles, input.roles);
for (const keyId of input.keyIds) {
output.keyIds.add(keyId);
}
// 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<!Map<string, T>>} streamsPerPeriod
* @param {T} outputStream
* @param {boolean=} shouldAppend
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
findMatchesInAllPeriods_(streamsPerPeriod, outputStream,
shouldAppend = false) {
const matches = shouldAppend ? outputStream.matchedStreams : [];
for (const streams of streamsPerPeriod) {
const match = this.findBestMatchInPeriod_(streams, outputStream);
if (!match) {
return;
}
matches.push(match);
}
outputStream.matchedStreams = matches;
}
/**
* Find the best match for the output stream.
*
* @param {!Map<string, 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
*/
findBestMatchInPeriod_(streams, outputStream) {
const getKey = {
'audio': shaka.util.PeriodCombiner.generateAudioKey_,
'video': shaka.util.PeriodCombiner.generateVideoKey_,
'text': shaka.util.PeriodCombiner.generateTextKey_,
'image': shaka.util.PeriodCombiner.generateImageKey_,
}[outputStream.type];
let best = null;
const key = getKey(outputStream);
if (streams.has(key)) {
// We've found exact match by hashing.
best = streams.get(key);
} else {
// We haven't found exact match, try to find the best one via
// linear search.
const areCompatible = {
'audio': (os, s) => this.areAVStreamsCompatible_(os, s),
'video': (os, s) => this.areAVStreamsCompatible_(os, s),
'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];
for (const stream of streams.values()) {
if (!areCompatible(outputStream, stream)) {
continue;
}
if (outputStream.fastSwitching != stream.fastSwitching) {
continue;
}
if (!best || isBetterMatch(outputStream, best, stream)) {
best = stream;
}
}
}
// Remove just found stream if configured to, so possible future linear
// searches can be faster.
if (this.useStreamOnce_ && !shaka.util.PeriodCombiner.isDummy_(best)) {
streams.delete(getKey(best));
}
return best;
}
/**
* @param {T} a
* @param {T} b
* @return {boolean}
*
* @template T
* Accepts either a StreamDB or Stream type.
*
* @private
*/
static areAVStreamsExactMatch_(a, b) {
if (a.mimeType != b.mimeType) {
return false;
}
return shaka.util.PeriodCombiner.getCodec_(a.codecs) ===
shaka.util.PeriodCombiner.getCodec_(b.codecs);
}
/**
* @param {boolean} allowed If set to true, multi-mimeType or multi-codec
* variants will be allowed.
* @export
*/
setAllowMultiTypeVariants(allowed) {
this.multiTypeVariantsAllowed_ = allowed;
}
/**
* @param {boolean} useOnce if true, stream will be used only once in period
* flattening algoritnm.
* @export
*/
setUseStreamOnce(useOnce) {
this.useStreamOnce_ = useOnce;
}
/**
* @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
*/
areAVStreamsCompatible_(outputStream, candidate) {
// Check for an exact match.
if (!shaka.util.PeriodCombiner.areAVStreamsExactMatch_(
outputStream, candidate)) {
// It's not an exact match. See if we can do multi-codec or multi-mimeType
// stream instead, using SourceBuffer.changeType.
if (!this.multiTypeVariantsAllowed_) {
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.util.DrmUtils.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.
// If the candidate is a dummy, then it is compatible, and we could use it
// if nothing else matches.
if (!candidate.language) {
return true;
}
// Forced subtitles should be treated as unique streams
if (outputStream.forced !== candidate.forced) {
return false;
}
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.
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;
// An exact match is better than a non-exact match.
const bestIsExact = shaka.util.PeriodCombiner.areAVStreamsExactMatch_(
outputStream, best);
const candidateIsExact = shaka.util.PeriodCombiner.areAVStreamsExactMatch_(
outputStream, candidate);
if (bestIsExact && !candidateIsExact) {
return false;
}
if (!bestIsExact && candidateIsExact) {
return true;
}
// 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 language-based differences haven't decided this, look at labels.
// If available options differ, look does any matches with output stream.
if (best.label !== candidate.label) {
if (outputStream.label === best.label) {
return false;
}
if (outputStream.label === candidate.label) {
return true;
}
}
// If label-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 if (candidate.roles.length !== best.roles.length) {
// 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 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 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;
// An exact match is better than a non-exact match.
const bestIsExact = shaka.util.PeriodCombiner.areAVStreamsExactMatch_(
outputStream, best);
const candidateIsExact = shaka.util.PeriodCombiner.areAVStreamsExactMatch_(
outputStream, candidate);
if (bestIsExact && !candidateIsExact) {
return false;
}
if (!bestIsExact && candidateIsExact) {
return true;
}
// 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;
// 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 labels.
// If available options differ, look does any matches with output stream.
if (best.label !== candidate.label) {
if (outputStream.label === best.label) {
return false;
}
if (outputStream.label === candidate.label) {
return true;
}
}
// 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;
// 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: '',
groupId: null,
primary: false,
type,
mimeType: '',
codecs: '',
language: '',
originalLanguage: null,
label: null,
width: null,
height: null,
encrypted: false,
keyIds: new Set(),
segments: [],
variantIds: [],
roles: [],
forced: false,
channelsCount: null,
audioSamplingRate: null,
spatialAudio: false,
closedCaptions: null,
external: false,
fastSwitching: false,
isAudioMuxedInVideo: false,
};
}
/**
* 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: '',
groupId: null,
createSegmentIndex: () => Promise.resolve(),
segmentIndex: new shaka.media.SegmentIndex([]),
mimeType: '',
codecs: '',
encrypted: false,
drmInfos: [],
keyIds: new Set(),
language: '',
originalLanguage: null,
label: null,
type,
primary: false,
trickModeVideo: null,
emsgSchemeIdUris: null,
roles: [],
forced: false,
channelsCount: null,
audioSamplingRate: null,
spatialAudio: false,
closedCaptions: null,
accessibilityPurpose: null,
external: false,
fastSwitching: false,
fullMimeTypes: new Set(),
isAudioMuxedInVideo: false,
};
}
/**
* 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;
}
/**
* @param {T} stream
* @return {boolean}
* @template T
* Accepts either a StreamDB or Stream type.
* @private
*/
static isDummy_(stream) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
switch (stream.type) {
case ContentType.TEXT:
return !stream.language;
case ContentType.IMAGE:
return !stream.tilesLayout;
default:
return false;
}
}
/**
* @param {T} v
* @return {string}
* @template T
* Accepts either a StreamDB or Stream type.
* @private
*/
static generateVideoKey_(v) {
return shaka.util.PeriodCombiner.generateKey_([
v.fastSwitching,
v.width,
v.frameRate,
shaka.util.PeriodCombiner.getCodec_(v.codecs),
v.mimeType,
v.label,
v.roles,
v.closedCaptions ? Array.from(v.closedCaptions.entries()) : null,
v.bandwidth,
]);
}
/**
* @param {T} a
* @return {string}
* @template T
* Accepts either a StreamDB or Stream type.
* @private
*/
static generateAudioKey_(a) {
return shaka.util.PeriodCombiner.generateKey_([
a.fastSwitching,
a.channelsCount,
a.language,
a.bandwidth,
a.label,
shaka.util.PeriodCombiner.getCodec_(a.codecs),
a.mimeType,
a.roles,
a.audioSamplingRate,
a.primary,
]);
}
/**
* @param {T} t
* @return {string}
* @template T
* Accepts either a StreamDB or Stream type.
* @private
*/
static generateTextKey_(t) {
return shaka.util.PeriodCombiner.generateKey_([
t.language,
t.label,
t.codecs,
t.mimeType,
t.bandwidth,
t.roles,
]);
}
/**
* @param {T} i
* @return {string}
* @template T
* Accepts either a StreamDB or Stream type.
* @private
*/
static generateImageKey_(i) {
return shaka.util.PeriodCombiner.generateKey_([
i.width,
i.codecs,
i.mimeType,
]);
}
/**
* @param {!Array<*>} values
* @return {string}
* @private
*/
static generateKey_(values) {
return JSON.stringify(values);
}
/**
* @param {string} codecs
* @return {string}
* @private
*/
static getCodec_(codecs) {
if (!shaka.util.PeriodCombiner.memoizedCodecs.has(codecs)) {
const normalizedCodec = shaka.util.MimeUtils.getNormalizedCodec(codecs);
shaka.util.PeriodCombiner.memoizedCodecs.set(codecs, normalizedCodec);
}
return shaka.util.PeriodCombiner.memoizedCodecs.get(codecs);
}
};
/**
* @enum {number}
*/
shaka.util.PeriodCombiner.BetterOrWorse = {
BETTER: 1,
EQUAL: 0,
WORSE: -1,
};
/**
* @private {Map<string, string>}
*/
shaka.util.PeriodCombiner.memoizedCodecs = new Map();