mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-25 17:45:03 +03:00
fb0d819f1c
resolution Closes #2517 Change-Id: I7102153f2df83b8ad66411e709fdaf5a5a043b53
1223 lines
41 KiB
JavaScript
1223 lines
41 KiB
JavaScript
/** @license
|
|
* Copyright 2016 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
goog.provide('shaka.util.PeriodCombiner');
|
|
|
|
goog.require('goog.asserts');
|
|
goog.require('shaka.media.DrmEngine');
|
|
goog.require('shaka.media.MetaSegmentIndex');
|
|
goog.require('shaka.media.SegmentIndex');
|
|
goog.require('shaka.util.Error');
|
|
goog.require('shaka.util.IReleasable');
|
|
goog.require('shaka.util.Iterables');
|
|
goog.require('shaka.util.LanguageUtils');
|
|
goog.require('shaka.util.ManifestParserUtils');
|
|
|
|
/**
|
|
* 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_ = [];
|
|
|
|
/**
|
|
* 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_);
|
|
|
|
for (const stream of allStreams) {
|
|
if (stream.segmentIndex) {
|
|
stream.segmentIndex.release();
|
|
}
|
|
}
|
|
|
|
this.audioStreams_ = [];
|
|
this.videoStreams_ = [];
|
|
this.textStreams_ = [];
|
|
this.variants_ = [];
|
|
}
|
|
|
|
/** @return {!Array.<shaka.extern.Variant>} */
|
|
getVariants() {
|
|
return this.variants_;
|
|
}
|
|
|
|
/** @return {!Array.<shaka.extern.Stream>} */
|
|
getTextStreams() {
|
|
return this.textStreams_;
|
|
}
|
|
|
|
/**
|
|
* @param {!Array.<shaka.util.PeriodCombiner.Period>} periods
|
|
* @param {boolean} isDynamic
|
|
* @return {!Promise}
|
|
*/
|
|
async combinePeriods(periods, isDynamic) {
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
const Iterables = shaka.util.Iterables;
|
|
|
|
// 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;
|
|
} else {
|
|
// Find the first period we haven't seen before. Tag all the periods we
|
|
// see now as "used".
|
|
let firstNewPeriodIndex = -1;
|
|
for (const {i, item: period} of Iterables.enumerate(periods)) {
|
|
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);
|
|
|
|
// It's okay to have a period with no text, but our algorithm fails on any
|
|
// period without matching streams. So we add dummy text streams to each
|
|
// period. Since we combine text streams by language, we might need a
|
|
// dummy even in periods with text streams already.
|
|
for (const textStreams of textStreamsPerPeriod) {
|
|
textStreams.push(shaka.util.PeriodCombiner.dummyTextStream_());
|
|
}
|
|
|
|
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_);
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
}
|
|
} 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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
// 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));
|
|
|
|
// It's okay to have a period with no text, but our algorithm fails on any
|
|
// period without matching streams. So we add dummy text streams to each
|
|
// period. Since we combine text streams by language, we might need a
|
|
// dummy even in periods with text streams already.
|
|
for (const textStreams of textStreamDbsPerPeriod) {
|
|
textStreams.push(shaka.util.PeriodCombiner.dummyTextStreamDB_());
|
|
}
|
|
|
|
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_);
|
|
|
|
// 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 v2.6
|
|
// will already be flattened before storage. Therefore the only content
|
|
// that reaches this point is multi-period DASH content stored before v2.6.
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* 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 Iterables = shaka.util.Iterables;
|
|
|
|
const unusedStreamsPerPeriod = [];
|
|
for (const {i, item: streams} of Iterables.enumerate(streamsPerPeriod)) {
|
|
if (i >= firstNewPeriodIndex) {
|
|
// This periods streams are all new.
|
|
unusedStreamsPerPeriod.push(new Set(streams));
|
|
} 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) {
|
|
if (stream.type == ContentType.TEXT && !stream.language) {
|
|
// This is one of our dummy text streams, so ignore it. We may not
|
|
// use them all, and that's fine.
|
|
continue;
|
|
}
|
|
|
|
// Any other unused stream 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);
|
|
}
|
|
}
|
|
|
|
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.
|
|
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 Promise.all(matches.map((match) => match.createSegmentIndex()));
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* 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 Promise.all(matches.map((match) => match.createSegmentIndex()));
|
|
}
|
|
|
|
// 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.
|
|
const Iterables = shaka.util.Iterables;
|
|
for (const {i, item: match} of Iterables.enumerate(matches)) {
|
|
if (i >= firstNewPeriodIndex) {
|
|
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) {
|
|
// 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;
|
|
|
|
// Take the max bandwidth, resolution, frame rate, sample rate, and channel
|
|
// count.
|
|
output.bandwidth = Math.max(output.bandwidth || 0, input.bandwidth || 0);
|
|
if (output.width) {
|
|
output.width = Math.max(output.width, input.width || 0);
|
|
}
|
|
if (output.height) {
|
|
output.height = Math.max(output.height, input.height || 0);
|
|
}
|
|
if (output.frameRate) {
|
|
output.frameRate = Math.max(output.frameRate, input.frameRate || 0);
|
|
}
|
|
if (output.audioSamplingRate) {
|
|
output.audioSamplingRate = Math.max(
|
|
output.audioSamplingRate, input.audioSamplingRate || 0);
|
|
}
|
|
if (output.channelsCount) {
|
|
output.channelsCount = Math.max(
|
|
output.channelsCount, input.channelsCount || 0);
|
|
}
|
|
|
|
// 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_,
|
|
}[outputStream.type];
|
|
|
|
const isBetterMatch = {
|
|
'audio': shaka.util.PeriodCombiner.isAudioStreamBetterMatch_,
|
|
'video': shaka.util.PeriodCombiner.isVideoStreamBetterMatch_,
|
|
'text': shaka.util.PeriodCombiner.isTextStreamBetterMatch_,
|
|
}[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 = shaka.util.MimeUtils.getCodecBase;
|
|
// 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 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_;
|
|
|
|
// 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;
|
|
}
|
|
if (candidateRoleMatches.length < bestRoleMatches.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;
|
|
}
|
|
|
|
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_;
|
|
|
|
// 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.compareClosestPreferLower_(
|
|
outputStream.bandwidth,
|
|
best.bandwidth,
|
|
candidate.bandwidth);
|
|
if (bandwidthBetterOrWorse == BETTER) {
|
|
return true;
|
|
} else if (bandwidthBetterOrWorse == WORSE) {
|
|
return 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 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;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Create a dummy text StreamDB to fill in periods with no text, to avoid
|
|
* failing the general flattening algorithm.
|
|
*
|
|
* @return {shaka.extern.StreamDB}
|
|
* @private
|
|
*/
|
|
static dummyTextStreamDB_() {
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
|
|
return {
|
|
id: 0,
|
|
originalId: '',
|
|
primary: false,
|
|
type: ContentType.TEXT,
|
|
mimeType: '',
|
|
codecs: '',
|
|
language: '',
|
|
label: null,
|
|
width: null,
|
|
height: null,
|
|
encrypted: false,
|
|
keyIds: new Set(),
|
|
segments: [],
|
|
variantIds: [],
|
|
roles: [],
|
|
channelsCount: null,
|
|
audioSamplingRate: null,
|
|
closedCaptions: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a dummy text Stream to fill in periods with no text, to avoid
|
|
* failing the general flattening algorithm.
|
|
*
|
|
* @return {shaka.extern.Stream}
|
|
* @private
|
|
*/
|
|
static dummyTextStream_() {
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
|
|
return {
|
|
id: 0,
|
|
originalId: '',
|
|
createSegmentIndex: () => Promise.resolve(),
|
|
segmentIndex: new shaka.media.SegmentIndex([]),
|
|
mimeType: '',
|
|
codecs: '',
|
|
encrypted: false,
|
|
drmInfos: [],
|
|
keyIds: new Set(),
|
|
language: '',
|
|
label: null,
|
|
type: ContentType.TEXT,
|
|
primary: false,
|
|
trickModeVideo: null,
|
|
emsgSchemeIdUris: null,
|
|
roles: [],
|
|
channelsCount: null,
|
|
audioSamplingRate: null,
|
|
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_}
|
|
* @private
|
|
*/
|
|
static compareClosestPreferLower_(outputValue, bestValue, candidateValue) {
|
|
const {BETTER, EQUAL, WORSE} = shaka.util.PeriodCombiner.BetterOrWorse_;
|
|
|
|
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 {
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
return EQUAL;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @typedef {{
|
|
* id: string,
|
|
* audioStreams: !Array.<shaka.extern.Stream>,
|
|
* videoStreams: !Array.<shaka.extern.Stream>,
|
|
* textStreams: !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.
|
|
*/
|
|
shaka.util.PeriodCombiner.Period;
|
|
|
|
/**
|
|
* @enum {number}
|
|
* @private
|
|
*/
|
|
shaka.util.PeriodCombiner.BetterOrWorse_ = {
|
|
BETTER: 1,
|
|
EQUAL: 0,
|
|
WORSE: -1,
|
|
};
|