Source: lib/util/periods.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.util.PeriodCombiner');
  7. goog.require('goog.asserts');
  8. goog.require('shaka.log');
  9. goog.require('shaka.media.DrmEngine');
  10. goog.require('shaka.media.MetaSegmentIndex');
  11. goog.require('shaka.media.SegmentIndex');
  12. goog.require('shaka.util.ArrayUtils');
  13. goog.require('shaka.util.Error');
  14. goog.require('shaka.util.IReleasable');
  15. goog.require('shaka.util.LanguageUtils');
  16. goog.require('shaka.util.ManifestParserUtils');
  17. goog.require('shaka.util.MapUtils');
  18. goog.require('shaka.util.MimeUtils');
  19. /**
  20. * A utility to combine streams across periods.
  21. *
  22. * @implements {shaka.util.IReleasable}
  23. * @final
  24. * @export
  25. */
  26. shaka.util.PeriodCombiner = class {
  27. /** */
  28. constructor() {
  29. /** @private {!Array.<shaka.extern.Variant>} */
  30. this.variants_ = [];
  31. /** @private {!Array.<shaka.extern.Stream>} */
  32. this.audioStreams_ = [];
  33. /** @private {!Array.<shaka.extern.Stream>} */
  34. this.videoStreams_ = [];
  35. /** @private {!Array.<shaka.extern.Stream>} */
  36. this.textStreams_ = [];
  37. /** @private {!Array.<shaka.extern.Stream>} */
  38. this.imageStreams_ = [];
  39. /**
  40. * The IDs of the periods we have already used to generate streams.
  41. * This helps us identify the periods which have been added when a live
  42. * stream is updated.
  43. *
  44. * @private {!Set.<string>}
  45. */
  46. this.usedPeriodIds_ = new Set();
  47. }
  48. /** @override */
  49. release() {
  50. const allStreams =
  51. this.audioStreams_.concat(this.videoStreams_, this.textStreams_,
  52. this.imageStreams_);
  53. for (const stream of allStreams) {
  54. if (stream.segmentIndex) {
  55. stream.segmentIndex.release();
  56. }
  57. }
  58. this.audioStreams_ = [];
  59. this.videoStreams_ = [];
  60. this.textStreams_ = [];
  61. this.imageStreams_ = [];
  62. this.variants_ = [];
  63. }
  64. /**
  65. * @return {!Array.<shaka.extern.Variant>}
  66. *
  67. * @export
  68. */
  69. getVariants() {
  70. return this.variants_;
  71. }
  72. /**
  73. * @return {!Array.<shaka.extern.Stream>}
  74. *
  75. * @export
  76. */
  77. getTextStreams() {
  78. // Return a copy of the array because makeTextStreamsForClosedCaptions
  79. // may make changes to the contents of the array. Those changes should not
  80. // propagate back to the PeriodCombiner.
  81. return this.textStreams_.slice();
  82. }
  83. /**
  84. * @return {!Array.<shaka.extern.Stream>}
  85. *
  86. * @export
  87. */
  88. getImageStreams() {
  89. return this.imageStreams_;
  90. }
  91. /**
  92. * Returns an object that contains arrays of streams by type
  93. * @param {!Array.<shaka.extern.Period>} periods
  94. * @return {{
  95. * audioStreamsPerPeriod: !Array.<!Array.<shaka.extern.Stream>>,
  96. * videoStreamsPerPeriod: !Array.<!Array.<shaka.extern.Stream>>,
  97. * textStreamsPerPeriod: !Array.<!Array.<shaka.extern.Stream>>,
  98. * imageStreamsPerPeriod: !Array.<!Array.<shaka.extern.Stream>>
  99. * }}
  100. * @private
  101. */
  102. getStreamsPerPeriod_(periods) {
  103. const audioStreamsPerPeriod = [];
  104. const videoStreamsPerPeriod = [];
  105. const textStreamsPerPeriod = [];
  106. const imageStreamsPerPeriod = [];
  107. for (const period of periods) {
  108. audioStreamsPerPeriod.push(period.audioStreams);
  109. videoStreamsPerPeriod.push(period.videoStreams);
  110. textStreamsPerPeriod.push(period.textStreams);
  111. imageStreamsPerPeriod.push(period.imageStreams);
  112. }
  113. return {
  114. audioStreamsPerPeriod,
  115. videoStreamsPerPeriod,
  116. textStreamsPerPeriod,
  117. imageStreamsPerPeriod,
  118. };
  119. }
  120. /**
  121. * @param {!Array.<shaka.extern.Period>} periods
  122. * @param {boolean} isDynamic
  123. * @return {!Promise}
  124. *
  125. * @export
  126. */
  127. async combinePeriods(periods, isDynamic) {
  128. const ContentType = shaka.util.ManifestParserUtils.ContentType;
  129. shaka.util.PeriodCombiner.filterOutDuplicates_(periods);
  130. // Optimization: for single-period VOD, do nothing. This makes sure
  131. // single-period DASH content will be 100% accurately represented in the
  132. // output.
  133. if (!isDynamic && periods.length == 1) {
  134. const firstPeriod = periods[0];
  135. this.audioStreams_ = firstPeriod.audioStreams;
  136. this.videoStreams_ = firstPeriod.videoStreams;
  137. this.textStreams_ = firstPeriod.textStreams;
  138. this.imageStreams_ = firstPeriod.imageStreams;
  139. } else {
  140. // Find the first period we haven't seen before. Tag all the periods we
  141. // see now as "used".
  142. let firstNewPeriodIndex = -1;
  143. for (let i = 0; i < periods.length; i++) {
  144. const period = periods[i];
  145. if (this.usedPeriodIds_.has(period.id)) {
  146. // This isn't new.
  147. } else {
  148. // This one _is_ new.
  149. this.usedPeriodIds_.add(period.id);
  150. if (firstNewPeriodIndex == -1) {
  151. // And it's the _first_ new one.
  152. firstNewPeriodIndex = i;
  153. }
  154. }
  155. }
  156. if (firstNewPeriodIndex == -1) {
  157. // Nothing new? Nothing to do.
  158. return;
  159. }
  160. const {
  161. audioStreamsPerPeriod,
  162. videoStreamsPerPeriod,
  163. textStreamsPerPeriod,
  164. imageStreamsPerPeriod,
  165. } = this.getStreamsPerPeriod_(periods);
  166. // It's okay to have a period with no text or images, but our algorithm
  167. // fails on any period without matching streams. So we add dummy streams
  168. // to each period. Since we combine text streams by language and image
  169. // streams by resolution, we might need a dummy even in periods with these
  170. // streams already.
  171. for (const textStreams of textStreamsPerPeriod) {
  172. textStreams.push(shaka.util.PeriodCombiner.dummyStream_(
  173. ContentType.TEXT));
  174. }
  175. for (const imageStreams of imageStreamsPerPeriod) {
  176. imageStreams.push(shaka.util.PeriodCombiner.dummyStream_(
  177. ContentType.IMAGE));
  178. }
  179. await Promise.all([
  180. shaka.util.PeriodCombiner.combine_(
  181. this.audioStreams_,
  182. audioStreamsPerPeriod,
  183. firstNewPeriodIndex,
  184. shaka.util.PeriodCombiner.cloneStream_,
  185. shaka.util.PeriodCombiner.concatenateStreams_),
  186. shaka.util.PeriodCombiner.combine_(
  187. this.videoStreams_,
  188. videoStreamsPerPeriod,
  189. firstNewPeriodIndex,
  190. shaka.util.PeriodCombiner.cloneStream_,
  191. shaka.util.PeriodCombiner.concatenateStreams_),
  192. shaka.util.PeriodCombiner.combine_(
  193. this.textStreams_,
  194. textStreamsPerPeriod,
  195. firstNewPeriodIndex,
  196. shaka.util.PeriodCombiner.cloneStream_,
  197. shaka.util.PeriodCombiner.concatenateStreams_),
  198. shaka.util.PeriodCombiner.combine_(
  199. this.imageStreams_,
  200. imageStreamsPerPeriod,
  201. firstNewPeriodIndex,
  202. shaka.util.PeriodCombiner.cloneStream_,
  203. shaka.util.PeriodCombiner.concatenateStreams_),
  204. ]);
  205. }
  206. // Create variants for all audio/video combinations.
  207. let nextVariantId = 0;
  208. const variants = [];
  209. if (!this.videoStreams_.length || !this.audioStreams_.length) {
  210. // For audio-only or video-only content, just give each stream its own
  211. // variant.
  212. const streams = this.videoStreams_.length ? this.videoStreams_ :
  213. this.audioStreams_;
  214. for (const stream of streams) {
  215. const id = nextVariantId++;
  216. variants.push({
  217. id,
  218. language: stream.language,
  219. disabledUntilTime: 0,
  220. primary: stream.primary,
  221. audio: stream.type == ContentType.AUDIO ? stream : null,
  222. video: stream.type == ContentType.VIDEO ? stream : null,
  223. bandwidth: stream.bandwidth || 0,
  224. drmInfos: stream.drmInfos,
  225. allowedByApplication: true,
  226. allowedByKeySystem: true,
  227. decodingInfos: [],
  228. });
  229. }
  230. } else {
  231. for (const audio of this.audioStreams_) {
  232. for (const video of this.videoStreams_) {
  233. const commonDrmInfos = shaka.media.DrmEngine.getCommonDrmInfos(
  234. audio.drmInfos, video.drmInfos);
  235. if (audio.drmInfos.length && video.drmInfos.length &&
  236. !commonDrmInfos.length) {
  237. shaka.log.warning(
  238. 'Incompatible DRM in audio & video, skipping variant creation.',
  239. audio, video);
  240. continue;
  241. }
  242. const id = nextVariantId++;
  243. variants.push({
  244. id,
  245. language: audio.language,
  246. disabledUntilTime: 0,
  247. primary: audio.primary,
  248. audio,
  249. video,
  250. bandwidth: (audio.bandwidth || 0) + (video.bandwidth || 0),
  251. drmInfos: commonDrmInfos,
  252. allowedByApplication: true,
  253. allowedByKeySystem: true,
  254. decodingInfos: [],
  255. });
  256. }
  257. }
  258. }
  259. this.variants_ = variants;
  260. }
  261. /**
  262. * @param {!Array<shaka.extern.Period>} periods
  263. * @private
  264. */
  265. static filterOutDuplicates_(periods) {
  266. const PeriodCombiner = shaka.util.PeriodCombiner;
  267. const ArrayUtils = shaka.util.ArrayUtils;
  268. const MapUtils = shaka.util.MapUtils;
  269. for (const period of periods) {
  270. // Two video streams are considered to be duplicates of
  271. // one another if their ids are different, but all the other
  272. // information is the same.
  273. period.videoStreams = PeriodCombiner.filterOutStreamDuplicates_(
  274. period.videoStreams, (v1, v2) => {
  275. return v1.id !== v2.id &&
  276. v1.fastSwitching == v2.fastSwitching &&
  277. v1.width === v2.width &&
  278. v1.frameRate === v2.frameRate &&
  279. v1.codecs === v2.codecs &&
  280. v1.mimeType === v2.mimeType &&
  281. v1.label === v2.label &&
  282. ArrayUtils.hasSameElements(v1.roles, v2.roles) &&
  283. MapUtils.hasSameElements(v1.closedCaptions, v2.closedCaptions) &&
  284. v1.bandwidth === v2.bandwidth;
  285. });
  286. // Two audio streams are considered to be duplicates of
  287. // one another if their ids are different, but all the other
  288. // information is the same.
  289. period.audioStreams = PeriodCombiner.filterOutStreamDuplicates_(
  290. period.audioStreams, (a1, a2) => {
  291. return a1.id !== a2.id &&
  292. a1.fastSwitching == a2.fastSwitching &&
  293. a1.channelsCount === a2.channelsCount &&
  294. a1.language === a2.language &&
  295. a1.bandwidth === a2.bandwidth &&
  296. a1.label === a2.label &&
  297. a1.codecs === a2.codecs &&
  298. a1.mimeType === a2.mimeType &&
  299. ArrayUtils.hasSameElements(a1.roles, a2.roles) &&
  300. a1.audioSamplingRate === a2.audioSamplingRate &&
  301. a1.primary === a2.primary;
  302. });
  303. // Two text streams are considered to be duplicates of
  304. // one another if their ids are different, but all the other
  305. // information is the same.
  306. period.textStreams = PeriodCombiner.filterOutStreamDuplicates_(
  307. period.textStreams, (t1, t2) => {
  308. return t1.id !== t2.id &&
  309. t1.language === t2.language &&
  310. t1.label === t2.label &&
  311. t1.codecs === t2.codecs &&
  312. t1.mimeType === t2.mimeType &&
  313. t1.bandwidth === t2.bandwidth &&
  314. ArrayUtils.hasSameElements(t1.roles, t2.roles);
  315. });
  316. // Two image streams are considered to be duplicates of
  317. // one another if their ids are different, but all the other
  318. // information is the same.
  319. period.imageStreams = PeriodCombiner.filterOutStreamDuplicates_(
  320. period.imageStreams, (i1, i2) => {
  321. return i1.id !== i2.id &&
  322. i1.width === i2.width &&
  323. i1.codecs === i2.codecs &&
  324. i1.mimeType === i2.mimeType;
  325. });
  326. }
  327. }
  328. /**
  329. * @param {!Array<shaka.extern.Stream>} streams
  330. * @param {function(
  331. * shaka.extern.Stream, shaka.extern.Stream): boolean} isDuplicate
  332. * @return {!Array<shaka.extern.Stream>}
  333. * @private
  334. */
  335. static filterOutStreamDuplicates_(streams, isDuplicate) {
  336. const filteredStreams = [];
  337. for (const s1 of streams) {
  338. const duplicate = filteredStreams.some((s2) => isDuplicate(s1, s2));
  339. if (!duplicate) {
  340. filteredStreams.push(s1);
  341. }
  342. }
  343. return filteredStreams;
  344. }
  345. /**
  346. * Stitch together DB streams across periods, taking a mix of stream types.
  347. * The offline database does not separate these by type.
  348. *
  349. * Unlike the DASH case, this does not need to maintain any state for manifest
  350. * updates.
  351. *
  352. * @param {!Array.<!Array.<shaka.extern.StreamDB>>} streamDbsPerPeriod
  353. * @return {!Promise.<!Array.<shaka.extern.StreamDB>>}
  354. */
  355. static async combineDbStreams(streamDbsPerPeriod) {
  356. const ContentType = shaka.util.ManifestParserUtils.ContentType;
  357. // Optimization: for single-period content, do nothing. This makes sure
  358. // single-period DASH or any HLS content stored offline will be 100%
  359. // accurately represented in the output.
  360. if (streamDbsPerPeriod.length == 1) {
  361. return streamDbsPerPeriod[0];
  362. }
  363. const audioStreamDbsPerPeriod = streamDbsPerPeriod.map(
  364. (streams) => streams.filter((s) => s.type == ContentType.AUDIO));
  365. const videoStreamDbsPerPeriod = streamDbsPerPeriod.map(
  366. (streams) => streams.filter((s) => s.type == ContentType.VIDEO));
  367. const textStreamDbsPerPeriod = streamDbsPerPeriod.map(
  368. (streams) => streams.filter((s) => s.type == ContentType.TEXT));
  369. const imageStreamDbsPerPeriod = streamDbsPerPeriod.map(
  370. (streams) => streams.filter((s) => s.type == ContentType.IMAGE));
  371. // It's okay to have a period with no text or images, but our algorithm
  372. // fails on any period without matching streams. So we add dummy streams to
  373. // each period. Since we combine text streams by language and image streams
  374. // by resolution, we might need a dummy even in periods with these streams
  375. // already.
  376. for (const textStreams of textStreamDbsPerPeriod) {
  377. textStreams.push(shaka.util.PeriodCombiner.dummyStreamDB_(
  378. ContentType.TEXT));
  379. }
  380. for (const imageStreams of imageStreamDbsPerPeriod) {
  381. imageStreams.push(shaka.util.PeriodCombiner.dummyStreamDB_(
  382. ContentType.IMAGE));
  383. }
  384. const combinedAudioStreamDbs = await shaka.util.PeriodCombiner.combine_(
  385. /* outputStreams= */ [],
  386. audioStreamDbsPerPeriod,
  387. /* firstNewPeriodIndex= */ 0,
  388. shaka.util.PeriodCombiner.cloneStreamDB_,
  389. shaka.util.PeriodCombiner.concatenateStreamDBs_);
  390. const combinedVideoStreamDbs = await shaka.util.PeriodCombiner.combine_(
  391. /* outputStreams= */ [],
  392. videoStreamDbsPerPeriod,
  393. /* firstNewPeriodIndex= */ 0,
  394. shaka.util.PeriodCombiner.cloneStreamDB_,
  395. shaka.util.PeriodCombiner.concatenateStreamDBs_);
  396. const combinedTextStreamDbs = await shaka.util.PeriodCombiner.combine_(
  397. /* outputStreams= */ [],
  398. textStreamDbsPerPeriod,
  399. /* firstNewPeriodIndex= */ 0,
  400. shaka.util.PeriodCombiner.cloneStreamDB_,
  401. shaka.util.PeriodCombiner.concatenateStreamDBs_);
  402. const combinedImageStreamDbs = await shaka.util.PeriodCombiner.combine_(
  403. /* outputStreams= */ [],
  404. imageStreamDbsPerPeriod,
  405. /* firstNewPeriodIndex= */ 0,
  406. shaka.util.PeriodCombiner.cloneStreamDB_,
  407. shaka.util.PeriodCombiner.concatenateStreamDBs_);
  408. // Recreate variantIds from scratch in the output.
  409. // HLS content is always single-period, so the early return at the top of
  410. // this method would catch all HLS content. DASH content stored with v3.0
  411. // will already be flattened before storage. Therefore the only content
  412. // that reaches this point is multi-period DASH content stored before v3.0.
  413. // Such content always had variants generated from all combinations of audio
  414. // and video, so we can simply do that now without loss of correctness.
  415. let nextVariantId = 0;
  416. if (!combinedVideoStreamDbs.length || !combinedAudioStreamDbs.length) {
  417. // For audio-only or video-only content, just give each stream its own
  418. // variant ID.
  419. const combinedStreamDbs =
  420. combinedVideoStreamDbs.concat(combinedAudioStreamDbs);
  421. for (const stream of combinedStreamDbs) {
  422. stream.variantIds = [nextVariantId++];
  423. }
  424. } else {
  425. for (const audio of combinedAudioStreamDbs) {
  426. for (const video of combinedVideoStreamDbs) {
  427. const id = nextVariantId++;
  428. video.variantIds.push(id);
  429. audio.variantIds.push(id);
  430. }
  431. }
  432. }
  433. return combinedVideoStreamDbs
  434. .concat(combinedAudioStreamDbs)
  435. .concat(combinedTextStreamDbs)
  436. .concat(combinedImageStreamDbs);
  437. }
  438. /**
  439. * Combine input Streams per period into flat output Streams.
  440. * Templatized to handle both DASH Streams and offline StreamDBs.
  441. *
  442. * @param {!Array.<T>} outputStreams A list of existing output streams, to
  443. * facilitate updates for live DASH content. Will be modified and returned.
  444. * @param {!Array.<!Array.<T>>} streamsPerPeriod A list of lists of Streams
  445. * from each period.
  446. * @param {number} firstNewPeriodIndex An index into streamsPerPeriod which
  447. * represents the first new period that hasn't been processed yet.
  448. * @param {function(T):T} clone Make a clone of an input stream.
  449. * @param {function(T, T)} concat Concatenate the second stream onto the end
  450. * of the first.
  451. *
  452. * @return {!Promise.<!Array.<T>>} The same array passed to outputStreams,
  453. * modified to include any newly-created streams.
  454. *
  455. * @template T
  456. * Accepts either a StreamDB or Stream type.
  457. *
  458. * @private
  459. */
  460. static async combine_(
  461. outputStreams, streamsPerPeriod, firstNewPeriodIndex, clone, concat) {
  462. const ContentType = shaka.util.ManifestParserUtils.ContentType;
  463. const unusedStreamsPerPeriod = [];
  464. for (let i = 0; i < streamsPerPeriod.length; i++) {
  465. if (i >= firstNewPeriodIndex) {
  466. // This periods streams are all new.
  467. unusedStreamsPerPeriod.push(new Set(streamsPerPeriod[i]));
  468. } else {
  469. // This period's streams have all been used already.
  470. unusedStreamsPerPeriod.push(new Set());
  471. }
  472. }
  473. // First, extend all existing output Streams into the new periods.
  474. for (const outputStream of outputStreams) {
  475. // eslint-disable-next-line no-await-in-loop
  476. const ok = await shaka.util.PeriodCombiner.extendExistingOutputStream_(
  477. outputStream, streamsPerPeriod, firstNewPeriodIndex, concat,
  478. unusedStreamsPerPeriod);
  479. if (!ok) {
  480. // This output Stream was not properly extended to include streams from
  481. // the new period. This is likely a bug in our algorithm, so throw an
  482. // error.
  483. throw new shaka.util.Error(
  484. shaka.util.Error.Severity.CRITICAL,
  485. shaka.util.Error.Category.MANIFEST,
  486. shaka.util.Error.Code.PERIOD_FLATTENING_FAILED);
  487. }
  488. // This output stream is now complete with content from all known
  489. // periods.
  490. } // for (const outputStream of outputStreams)
  491. for (const unusedStreams of unusedStreamsPerPeriod) {
  492. for (const stream of unusedStreams) {
  493. // Create a new output stream which includes this input stream.
  494. const outputStream =
  495. shaka.util.PeriodCombiner.createNewOutputStream_(
  496. stream, streamsPerPeriod, clone, concat,
  497. unusedStreamsPerPeriod);
  498. if (outputStream) {
  499. outputStreams.push(outputStream);
  500. } else {
  501. // This is not a stream we can build output from, but it may become
  502. // part of another output based on another period's stream.
  503. }
  504. } // for (const stream of unusedStreams)
  505. } // for (const unusedStreams of unusedStreamsPerPeriod)
  506. for (const unusedStreams of unusedStreamsPerPeriod) {
  507. for (const stream of unusedStreams) {
  508. const isDummyText = stream.type == ContentType.TEXT && !stream.language;
  509. const isDummyImage = stream.type == ContentType.IMAGE &&
  510. !stream.tilesLayout;
  511. if (isDummyText || isDummyImage) {
  512. // This is one of our dummy streams, so ignore it. We may not use
  513. // them all, and that's fine.
  514. continue;
  515. }
  516. // If this stream has a different codec/MIME than any other stream,
  517. // then we can't play it.
  518. // TODO(#1528): Consider changing this when we support codec switching.
  519. const hasCodec = outputStreams.some((s) => {
  520. return s.mimeType == stream.mimeType &&
  521. shaka.util.MimeUtils.getNormalizedCodec(s.codecs) ==
  522. shaka.util.MimeUtils.getNormalizedCodec(stream.codecs);
  523. });
  524. if (!hasCodec) {
  525. continue;
  526. }
  527. // Any other unused stream is likely a bug in our algorithm, so throw
  528. // an error.
  529. shaka.log.error('Unused stream in period-flattening!',
  530. stream, outputStreams);
  531. throw new shaka.util.Error(
  532. shaka.util.Error.Severity.CRITICAL,
  533. shaka.util.Error.Category.MANIFEST,
  534. shaka.util.Error.Code.PERIOD_FLATTENING_FAILED);
  535. }
  536. }
  537. return outputStreams;
  538. }
  539. /**
  540. * @param {T} outputStream An existing output stream which needs to be
  541. * extended into new periods.
  542. * @param {!Array.<!Array.<T>>} streamsPerPeriod A list of lists of Streams
  543. * from each period.
  544. * @param {number} firstNewPeriodIndex An index into streamsPerPeriod which
  545. * represents the first new period that hasn't been processed yet.
  546. * @param {function(T, T)} concat Concatenate the second stream onto the end
  547. * of the first.
  548. * @param {!Array.<!Set.<T>>} unusedStreamsPerPeriod An array of sets of
  549. * unused streams from each period.
  550. *
  551. * @return {!Promise.<boolean>}
  552. *
  553. * @template T
  554. * Should only be called with a Stream type in practice, but has call sites
  555. * from other templated functions that also accept a StreamDB.
  556. *
  557. * @private
  558. */
  559. static async extendExistingOutputStream_(
  560. outputStream, streamsPerPeriod, firstNewPeriodIndex, concat,
  561. unusedStreamsPerPeriod) {
  562. shaka.util.PeriodCombiner.findMatchesInAllPeriods_(streamsPerPeriod,
  563. outputStream);
  564. // This only exists where T == Stream, and this should only ever be called
  565. // on Stream types. StreamDB should not have pre-existing output streams.
  566. goog.asserts.assert(outputStream.createSegmentIndex,
  567. 'outputStream should be a Stream type!');
  568. if (!outputStream.matchedStreams) {
  569. // We were unable to extend this output stream.
  570. shaka.log.error('No matches extending output stream!',
  571. outputStream, streamsPerPeriod);
  572. return false;
  573. }
  574. // We need to create all the per-period segment indexes and append them to
  575. // the output's MetaSegmentIndex.
  576. if (outputStream.segmentIndex) {
  577. await shaka.util.PeriodCombiner.extendOutputSegmentIndex_(outputStream,
  578. firstNewPeriodIndex);
  579. }
  580. shaka.util.PeriodCombiner.extendOutputStream_(outputStream,
  581. firstNewPeriodIndex, concat, unusedStreamsPerPeriod);
  582. return true;
  583. }
  584. /**
  585. * Creates the segment indexes for an array of input streams, and append them
  586. * to the output stream's segment index.
  587. *
  588. * @param {shaka.extern.Stream} outputStream
  589. * @param {number} firstNewPeriodIndex An index into streamsPerPeriod which
  590. * represents the first new period that hasn't been processed yet.
  591. * @private
  592. */
  593. static async extendOutputSegmentIndex_(outputStream, firstNewPeriodIndex) {
  594. const operations = [];
  595. const streams = outputStream.matchedStreams;
  596. goog.asserts.assert(streams, 'matched streams should be valid');
  597. for (const stream of streams) {
  598. operations.push(stream.createSegmentIndex());
  599. if (stream.trickModeVideo && !stream.trickModeVideo.segmentIndex) {
  600. operations.push(stream.trickModeVideo.createSegmentIndex());
  601. }
  602. }
  603. await Promise.all(operations);
  604. // Concatenate the new matches onto the stream, starting at the first new
  605. // period.
  606. // Satisfy the compiler about the type.
  607. // Also checks if the segmentIndex is still valid after the async
  608. // operations, to make sure we stop if the active stream has changed.
  609. if (outputStream.segmentIndex instanceof shaka.media.MetaSegmentIndex) {
  610. for (let i = firstNewPeriodIndex; i < streams.length; i++) {
  611. const match = streams[i];
  612. goog.asserts.assert(match.segmentIndex,
  613. 'stream should have a segmentIndex.');
  614. if (match.segmentIndex) {
  615. outputStream.segmentIndex.appendSegmentIndex(match.segmentIndex);
  616. }
  617. }
  618. }
  619. }
  620. /**
  621. * Create a new output Stream based on a particular input Stream. Locates
  622. * matching Streams in all other periods and combines them into an output
  623. * Stream.
  624. * Templatized to handle both DASH Streams and offline StreamDBs.
  625. *
  626. * @param {T} stream An input stream on which to base the output stream.
  627. * @param {!Array.<!Array.<T>>} streamsPerPeriod A list of lists of Streams
  628. * from each period.
  629. * @param {function(T):T} clone Make a clone of an input stream.
  630. * @param {function(T, T)} concat Concatenate the second stream onto the end
  631. * of the first.
  632. * @param {!Array.<!Set.<T>>} unusedStreamsPerPeriod An array of sets of
  633. * unused streams from each period.
  634. *
  635. * @return {?T} A newly-created output Stream, or null if matches
  636. * could not be found.`
  637. *
  638. * @template T
  639. * Accepts either a StreamDB or Stream type.
  640. *
  641. * @private
  642. */
  643. static createNewOutputStream_(
  644. stream, streamsPerPeriod, clone, concat, unusedStreamsPerPeriod) {
  645. // Start by cloning the stream without segments, key IDs, etc.
  646. const outputStream = clone(stream);
  647. // Find best-matching streams in all periods.
  648. shaka.util.PeriodCombiner.findMatchesInAllPeriods_(streamsPerPeriod,
  649. outputStream);
  650. // This only exists where T == Stream.
  651. if (outputStream.createSegmentIndex) {
  652. // Override the createSegmentIndex function of the outputStream.
  653. outputStream.createSegmentIndex = async () => {
  654. if (!outputStream.segmentIndex) {
  655. outputStream.segmentIndex = new shaka.media.MetaSegmentIndex();
  656. await shaka.util.PeriodCombiner.extendOutputSegmentIndex_(
  657. outputStream, /* firstNewPeriodIndex= */ 0);
  658. }
  659. };
  660. // For T == Stream, we need to create all the per-period segment indexes
  661. // in advance. concat() will add them to the output's MetaSegmentIndex.
  662. }
  663. if (!outputStream.matchedStreams || !outputStream.matchedStreams.length) {
  664. // This is not a stream we can build output from, but it may become part
  665. // of another output based on another period's stream.
  666. return null;
  667. }
  668. shaka.util.PeriodCombiner.extendOutputStream_(outputStream,
  669. /* firstNewPeriodIndex= */ 0, concat, unusedStreamsPerPeriod);
  670. return outputStream;
  671. }
  672. /**
  673. * @param {T} outputStream An existing output stream which needs to be
  674. * extended into new periods.
  675. * @param {number} firstNewPeriodIndex An index into streamsPerPeriod which
  676. * represents the first new period that hasn't been processed yet.
  677. * @param {function(T, T)} concat Concatenate the second stream onto the end
  678. * of the first.
  679. * @param {!Array.<!Set.<T>>} unusedStreamsPerPeriod An array of sets of
  680. * unused streams from each period.
  681. *
  682. * @template T
  683. * Accepts either a StreamDB or Stream type.
  684. *
  685. * @private
  686. */
  687. static extendOutputStream_(
  688. outputStream, firstNewPeriodIndex, concat, unusedStreamsPerPeriod) {
  689. const ContentType = shaka.util.ManifestParserUtils.ContentType;
  690. const LanguageUtils = shaka.util.LanguageUtils;
  691. const matches = outputStream.matchedStreams;
  692. // Assure the compiler that matches didn't become null during the async
  693. // operation before.
  694. goog.asserts.assert(outputStream.matchedStreams,
  695. 'matchedStreams should be non-null');
  696. // Concatenate the new matches onto the stream, starting at the first new
  697. // period.
  698. for (let i = firstNewPeriodIndex; i < matches.length; i++) {
  699. const match = matches[i];
  700. concat(outputStream, match);
  701. // We only consider an audio stream "used" if its language is related to
  702. // the output language. There are scenarios where we want to generate
  703. // separate tracks for each language, even when we are forced to connect
  704. // unrelated languages across periods.
  705. let used = true;
  706. if (outputStream.type == ContentType.AUDIO) {
  707. const relatedness = LanguageUtils.relatedness(
  708. outputStream.language, match.language);
  709. if (relatedness == 0) {
  710. used = false;
  711. }
  712. }
  713. if (used) {
  714. unusedStreamsPerPeriod[i].delete(match);
  715. }
  716. }
  717. }
  718. /**
  719. * Clone a Stream to make an output Stream for combining others across
  720. * periods.
  721. *
  722. * @param {shaka.extern.Stream} stream
  723. * @return {shaka.extern.Stream}
  724. * @private
  725. */
  726. static cloneStream_(stream) {
  727. const clone = /** @type {shaka.extern.Stream} */(Object.assign({}, stream));
  728. // These are wiped out now and rebuilt later from the various per-period
  729. // streams that match this output.
  730. clone.originalId = null;
  731. clone.createSegmentIndex = () => Promise.resolve();
  732. clone.closeSegmentIndex = () => {
  733. if (clone.segmentIndex) {
  734. clone.segmentIndex.release();
  735. clone.segmentIndex = null;
  736. }
  737. // Close the segment index of the matched streams.
  738. if (clone.matchedStreams) {
  739. for (const match of clone.matchedStreams) {
  740. if (match.segmentIndex) {
  741. match.segmentIndex.release();
  742. match.segmentIndex = null;
  743. }
  744. }
  745. }
  746. };
  747. clone.segmentIndex = null;
  748. clone.emsgSchemeIdUris = [];
  749. clone.keyIds = new Set();
  750. clone.closedCaptions = null;
  751. clone.trickModeVideo = null;
  752. return clone;
  753. }
  754. /**
  755. * Clone a StreamDB to make an output stream for combining others across
  756. * periods.
  757. *
  758. * @param {shaka.extern.StreamDB} streamDb
  759. * @return {shaka.extern.StreamDB}
  760. * @private
  761. */
  762. static cloneStreamDB_(streamDb) {
  763. const clone = /** @type {shaka.extern.StreamDB} */(Object.assign(
  764. {}, streamDb));
  765. // These are wiped out now and rebuilt later from the various per-period
  766. // streams that match this output.
  767. clone.keyIds = new Set();
  768. clone.segments = [];
  769. clone.variantIds = [];
  770. clone.closedCaptions = null;
  771. return clone;
  772. }
  773. /**
  774. * Combine the various fields of the input Stream into the output.
  775. *
  776. * @param {shaka.extern.Stream} output
  777. * @param {shaka.extern.Stream} input
  778. * @private
  779. */
  780. static concatenateStreams_(output, input) {
  781. // We keep the original stream's bandwidth, resolution, frame rate,
  782. // sample rate, and channel count to ensure that it's properly
  783. // matched with similar content in other periods further down
  784. // the line.
  785. // Combine arrays, keeping only the unique elements
  786. const combineArrays = (a, b) => Array.from(new Set(a.concat(b)));
  787. output.roles = combineArrays(output.roles, input.roles);
  788. if (input.emsgSchemeIdUris) {
  789. output.emsgSchemeIdUris = combineArrays(
  790. output.emsgSchemeIdUris, input.emsgSchemeIdUris);
  791. }
  792. const combineSets = (a, b) => new Set([...a, ...b]);
  793. output.keyIds = combineSets(output.keyIds, input.keyIds);
  794. if (output.originalId == null) {
  795. output.originalId = input.originalId;
  796. } else {
  797. output.originalId += ',' + (input.originalId || '');
  798. }
  799. const commonDrmInfos = shaka.media.DrmEngine.getCommonDrmInfos(
  800. output.drmInfos, input.drmInfos);
  801. if (input.drmInfos.length && output.drmInfos.length &&
  802. !commonDrmInfos.length) {
  803. throw new shaka.util.Error(
  804. shaka.util.Error.Severity.CRITICAL,
  805. shaka.util.Error.Category.MANIFEST,
  806. shaka.util.Error.Code.INCONSISTENT_DRM_ACROSS_PERIODS);
  807. }
  808. output.drmInfos = commonDrmInfos;
  809. // The output is encrypted if any input was encrypted.
  810. output.encrypted = output.encrypted || input.encrypted;
  811. // Combine the closed captions maps.
  812. if (input.closedCaptions) {
  813. if (!output.closedCaptions) {
  814. output.closedCaptions = new Map();
  815. }
  816. for (const [key, value] of input.closedCaptions) {
  817. output.closedCaptions.set(key, value);
  818. }
  819. }
  820. // Combine trick-play video streams, if present.
  821. if (input.trickModeVideo) {
  822. if (!output.trickModeVideo) {
  823. // Create a fresh output stream for trick-mode playback.
  824. output.trickModeVideo = shaka.util.PeriodCombiner.cloneStream_(
  825. input.trickModeVideo);
  826. // TODO: fix the createSegmentIndex function for trickModeVideo.
  827. // The trick-mode tracks in multi-period content should have trick-mode
  828. // segment indexes whenever available, rather than only regular-mode
  829. // segment indexes.
  830. output.trickModeVideo.createSegmentIndex = () => {
  831. // Satisfy the compiler about the type.
  832. goog.asserts.assert(
  833. output.segmentIndex instanceof shaka.media.MetaSegmentIndex,
  834. 'The stream should have a MetaSegmentIndex.');
  835. output.trickModeVideo.segmentIndex = output.segmentIndex.clone();
  836. return Promise.resolve();
  837. };
  838. }
  839. // Concatenate the trick mode input onto the trick mode output.
  840. shaka.util.PeriodCombiner.concatenateStreams_(
  841. output.trickModeVideo, input.trickModeVideo);
  842. } else if (output.trickModeVideo) {
  843. // We have a trick mode output, but no input from this Period. Fill it in
  844. // from the standard input Stream.
  845. shaka.util.PeriodCombiner.concatenateStreams_(
  846. output.trickModeVideo, input);
  847. }
  848. }
  849. /**
  850. * Combine the various fields of the input StreamDB into the output.
  851. *
  852. * @param {shaka.extern.StreamDB} output
  853. * @param {shaka.extern.StreamDB} input
  854. * @private
  855. */
  856. static concatenateStreamDBs_(output, input) {
  857. // Combine arrays, keeping only the unique elements
  858. const combineArrays = (a, b) => Array.from(new Set(a.concat(b)));
  859. output.roles = combineArrays(output.roles, input.roles);
  860. const combineSets = (a, b) => new Set([...a, ...b]);
  861. output.keyIds = combineSets(output.keyIds, input.keyIds);
  862. // The output is encrypted if any input was encrypted.
  863. output.encrypted = output.encrypted && input.encrypted;
  864. // Concatenate segments without de-duping.
  865. output.segments.push(...input.segments);
  866. // Combine the closed captions maps.
  867. if (input.closedCaptions) {
  868. if (!output.closedCaptions) {
  869. output.closedCaptions = new Map();
  870. }
  871. for (const [key, value] of input.closedCaptions) {
  872. output.closedCaptions.set(key, value);
  873. }
  874. }
  875. }
  876. /**
  877. * Finds streams in all periods which match the output stream.
  878. *
  879. * @param {!Array.<!Array.<T>>} streamsPerPeriod
  880. * @param {T} outputStream
  881. *
  882. * @template T
  883. * Accepts either a StreamDB or Stream type.
  884. *
  885. * @private
  886. */
  887. static findMatchesInAllPeriods_(streamsPerPeriod, outputStream) {
  888. const matches = [];
  889. for (const streams of streamsPerPeriod) {
  890. const match = shaka.util.PeriodCombiner.findBestMatchInPeriod_(
  891. streams, outputStream);
  892. if (!match) {
  893. return;
  894. }
  895. matches.push(match);
  896. }
  897. outputStream.matchedStreams = matches;
  898. }
  899. /**
  900. * Find the best match for the output stream.
  901. *
  902. * @param {!Array.<T>} streams
  903. * @param {T} outputStream
  904. * @return {?T} Returns null if no match can be found.
  905. *
  906. * @template T
  907. * Accepts either a StreamDB or Stream type.
  908. *
  909. * @private
  910. */
  911. static findBestMatchInPeriod_(streams, outputStream) {
  912. const areCompatible = {
  913. 'audio': shaka.util.PeriodCombiner.areAVStreamsCompatible_,
  914. 'video': shaka.util.PeriodCombiner.areAVStreamsCompatible_,
  915. 'text': shaka.util.PeriodCombiner.areTextStreamsCompatible_,
  916. 'image': shaka.util.PeriodCombiner.areImageStreamsCompatible_,
  917. }[outputStream.type];
  918. const isBetterMatch = {
  919. 'audio': shaka.util.PeriodCombiner.isAudioStreamBetterMatch_,
  920. 'video': shaka.util.PeriodCombiner.isVideoStreamBetterMatch_,
  921. 'text': shaka.util.PeriodCombiner.isTextStreamBetterMatch_,
  922. 'image': shaka.util.PeriodCombiner.isImageStreamBetterMatch_,
  923. }[outputStream.type];
  924. let best = null;
  925. for (const stream of streams) {
  926. if (!areCompatible(outputStream, stream)) {
  927. continue;
  928. }
  929. if (!best || isBetterMatch(outputStream, best, stream)) {
  930. best = stream;
  931. }
  932. }
  933. return best;
  934. }
  935. /**
  936. * @param {T} outputStream An audio or video output stream
  937. * @param {T} candidate A candidate stream to be combined with the output
  938. * @return {boolean} True if the candidate could be combined with the
  939. * output stream
  940. *
  941. * @template T
  942. * Accepts either a StreamDB or Stream type.
  943. *
  944. * @private
  945. */
  946. static areAVStreamsCompatible_(outputStream, candidate) {
  947. const getCodec = (codecs) => {
  948. if (!shaka.util.PeriodCombiner.memoizedCodecs.has(codecs)) {
  949. const normalizedCodec = shaka.util.MimeUtils.getNormalizedCodec(codecs);
  950. shaka.util.PeriodCombiner.memoizedCodecs.set(codecs, normalizedCodec);
  951. }
  952. return shaka.util.PeriodCombiner.memoizedCodecs.get(codecs);
  953. };
  954. // Check MIME type and codecs, which should always be the same.
  955. if (candidate.mimeType != outputStream.mimeType ||
  956. getCodec(candidate.codecs) != getCodec(outputStream.codecs)) {
  957. return false;
  958. }
  959. // This field is only available on Stream, not StreamDB.
  960. if (outputStream.drmInfos) {
  961. // Check for compatible DRM systems. Note that clear streams are
  962. // implicitly compatible with any DRM and with each other.
  963. if (!shaka.media.DrmEngine.areDrmCompatible(outputStream.drmInfos,
  964. candidate.drmInfos)) {
  965. return false;
  966. }
  967. }
  968. return true;
  969. }
  970. /**
  971. * @param {T} outputStream A text output stream
  972. * @param {T} candidate A candidate stream to be combined with the output
  973. * @return {boolean} True if the candidate could be combined with the
  974. * output
  975. *
  976. * @template T
  977. * Accepts either a StreamDB or Stream type.
  978. *
  979. * @private
  980. */
  981. static areTextStreamsCompatible_(outputStream, candidate) {
  982. const LanguageUtils = shaka.util.LanguageUtils;
  983. // For text, we don't care about MIME type or codec. We can always switch
  984. // between text types.
  985. // The output stream should not be a dummy stream inserted to fill a period
  986. // gap. So reject any candidate if the output has no language. This would
  987. // cause findMatchesInAllPeriods_ to return null and this output stream to
  988. // be skipped (meaning no output streams based on it).
  989. if (!outputStream.language) {
  990. return false;
  991. }
  992. // If the candidate is a dummy, then it is compatible, and we could use it
  993. // if nothing else matches.
  994. if (!candidate.language) {
  995. return true;
  996. }
  997. const languageRelatedness = LanguageUtils.relatedness(
  998. outputStream.language, candidate.language);
  999. // We will strictly avoid combining text across languages or "kinds"
  1000. // (caption vs subtitle).
  1001. if (languageRelatedness == 0 ||
  1002. candidate.kind != outputStream.kind) {
  1003. return false;
  1004. }
  1005. return true;
  1006. }
  1007. /**
  1008. * @param {T} outputStream A image output stream
  1009. * @param {T} candidate A candidate stream to be combined with the output
  1010. * @return {boolean} True if the candidate could be combined with the
  1011. * output
  1012. *
  1013. * @template T
  1014. * Accepts either a StreamDB or Stream type.
  1015. *
  1016. * @private
  1017. */
  1018. static areImageStreamsCompatible_(outputStream, candidate) {
  1019. // For image, we don't care about MIME type. We can always switch
  1020. // between image types.
  1021. // The output stream should not be a dummy stream inserted to fill a period
  1022. // gap. So reject any candidate if the output has no tilesLayout. This
  1023. // would cause findMatchesInAllPeriods_ to return null and this output
  1024. // stream to be skipped (meaning no output streams based on it).
  1025. if (!outputStream.tilesLayout) {
  1026. return false;
  1027. }
  1028. return true;
  1029. }
  1030. /**
  1031. * @param {T} outputStream An audio output stream
  1032. * @param {T} best The best match so far for this period
  1033. * @param {T} candidate A candidate stream which might be better
  1034. * @return {boolean} True if the candidate is a better match
  1035. *
  1036. * @template T
  1037. * Accepts either a StreamDB or Stream type.
  1038. *
  1039. * @private
  1040. */
  1041. static isAudioStreamBetterMatch_(outputStream, best, candidate) {
  1042. const LanguageUtils = shaka.util.LanguageUtils;
  1043. const {BETTER, EQUAL, WORSE} = shaka.util.PeriodCombiner.BetterOrWorse;
  1044. // If the output stream was based on the candidate stream, the candidate
  1045. // stream should be considered a better match. We can check this by
  1046. // comparing their ids.
  1047. if (outputStream.id == candidate.id) {
  1048. return true;
  1049. }
  1050. // Otherwise, compare the streams' characteristics to determine the best
  1051. // match.
  1052. // The most important thing is language. In some cases, we will accept a
  1053. // different language across periods when we must.
  1054. const bestRelatedness = LanguageUtils.relatedness(
  1055. outputStream.language, best.language);
  1056. const candidateRelatedness = LanguageUtils.relatedness(
  1057. outputStream.language, candidate.language);
  1058. if (candidateRelatedness > bestRelatedness) {
  1059. return true;
  1060. }
  1061. if (candidateRelatedness < bestRelatedness) {
  1062. return false;
  1063. }
  1064. // If language-based differences haven't decided this, look at roles. If
  1065. // the candidate has more roles in common with the output, upgrade to the
  1066. // candidate.
  1067. if (outputStream.roles.length) {
  1068. const bestRoleMatches =
  1069. best.roles.filter((role) => outputStream.roles.includes(role));
  1070. const candidateRoleMatches =
  1071. candidate.roles.filter((role) => outputStream.roles.includes(role));
  1072. if (candidateRoleMatches.length > bestRoleMatches.length) {
  1073. return true;
  1074. } else if (candidateRoleMatches.length < bestRoleMatches.length) {
  1075. return false;
  1076. } else {
  1077. // Both streams have the same role overlap with the outputStream
  1078. // If this is the case, choose the stream with the fewer roles overall.
  1079. // Streams that match best together tend to be streams with the same
  1080. // roles, e g stream1 with roles [r1, r2] is likely a better match
  1081. // for stream2 with roles [r1, r2] vs stream3 with roles
  1082. // [r1, r2, r3, r4].
  1083. // If we match stream1 with stream3 due to the same role overlap,
  1084. // stream2 is likely to be left unmatched and error out later.
  1085. // See https://github.com/shaka-project/shaka-player/issues/2542 for
  1086. // more details.
  1087. return candidate.roles.length < best.roles.length;
  1088. }
  1089. } else if (!candidate.roles.length && best.roles.length) {
  1090. // If outputStream has no roles, and only one of the streams has no roles,
  1091. // choose the one with no roles.
  1092. return true;
  1093. } else if (candidate.roles.length && !best.roles.length) {
  1094. return false;
  1095. }
  1096. // If the language doesn't match, but the candidate is the "primary"
  1097. // language, then that should be preferred as a fallback.
  1098. if (!best.primary && candidate.primary) {
  1099. return true;
  1100. }
  1101. if (best.primary && !candidate.primary) {
  1102. return false;
  1103. }
  1104. // If language-based and role-based features are equivalent, take the audio
  1105. // with the closes channel count to the output.
  1106. const channelsBetterOrWorse =
  1107. shaka.util.PeriodCombiner.compareClosestPreferLower(
  1108. outputStream.channelsCount,
  1109. best.channelsCount,
  1110. candidate.channelsCount);
  1111. if (channelsBetterOrWorse == BETTER) {
  1112. return true;
  1113. } else if (channelsBetterOrWorse == WORSE) {
  1114. return false;
  1115. }
  1116. // If channels are equal, take the closest sample rate to the output.
  1117. const sampleRateBetterOrWorse =
  1118. shaka.util.PeriodCombiner.compareClosestPreferLower(
  1119. outputStream.audioSamplingRate,
  1120. best.audioSamplingRate,
  1121. candidate.audioSamplingRate);
  1122. if (sampleRateBetterOrWorse == BETTER) {
  1123. return true;
  1124. } else if (sampleRateBetterOrWorse == WORSE) {
  1125. return false;
  1126. }
  1127. if (outputStream.bandwidth) {
  1128. // Take the audio with the closest bandwidth to the output.
  1129. const bandwidthBetterOrWorse =
  1130. shaka.util.PeriodCombiner.compareClosestPreferMinimalAbsDiff_(
  1131. outputStream.bandwidth,
  1132. best.bandwidth,
  1133. candidate.bandwidth);
  1134. if (bandwidthBetterOrWorse == BETTER) {
  1135. return true;
  1136. } else if (bandwidthBetterOrWorse == WORSE) {
  1137. return false;
  1138. }
  1139. }
  1140. // If the result of each comparison was inconclusive, default to false.
  1141. return false;
  1142. }
  1143. /**
  1144. * @param {T} outputStream A video output stream
  1145. * @param {T} best The best match so far for this period
  1146. * @param {T} candidate A candidate stream which might be better
  1147. * @return {boolean} True if the candidate is a better match
  1148. *
  1149. * @template T
  1150. * Accepts either a StreamDB or Stream type.
  1151. *
  1152. * @private
  1153. */
  1154. static isVideoStreamBetterMatch_(outputStream, best, candidate) {
  1155. const {BETTER, EQUAL, WORSE} = shaka.util.PeriodCombiner.BetterOrWorse;
  1156. // If the output stream was based on the candidate stream, the candidate
  1157. // stream should be considered a better match. We can check this by
  1158. // comparing their ids.
  1159. if (outputStream.id == candidate.id) {
  1160. return true;
  1161. }
  1162. // Otherwise, compare the streams' characteristics to determine the best
  1163. // match.
  1164. // Take the video with the closest resolution to the output.
  1165. const resolutionBetterOrWorse =
  1166. shaka.util.PeriodCombiner.compareClosestPreferLower(
  1167. outputStream.width * outputStream.height,
  1168. best.width * best.height,
  1169. candidate.width * candidate.height);
  1170. if (resolutionBetterOrWorse == BETTER) {
  1171. return true;
  1172. } else if (resolutionBetterOrWorse == WORSE) {
  1173. return false;
  1174. }
  1175. // We may not know the frame rate for the content, in which case this gets
  1176. // skipped.
  1177. if (outputStream.frameRate) {
  1178. // Take the video with the closest frame rate to the output.
  1179. const frameRateBetterOrWorse =
  1180. shaka.util.PeriodCombiner.compareClosestPreferLower(
  1181. outputStream.frameRate,
  1182. best.frameRate,
  1183. candidate.frameRate);
  1184. if (frameRateBetterOrWorse == BETTER) {
  1185. return true;
  1186. } else if (frameRateBetterOrWorse == WORSE) {
  1187. return false;
  1188. }
  1189. }
  1190. if (outputStream.bandwidth) {
  1191. // Take the video with the closest bandwidth to the output.
  1192. const bandwidthBetterOrWorse =
  1193. shaka.util.PeriodCombiner.compareClosestPreferMinimalAbsDiff_(
  1194. outputStream.bandwidth,
  1195. best.bandwidth,
  1196. candidate.bandwidth);
  1197. if (bandwidthBetterOrWorse == BETTER) {
  1198. return true;
  1199. } else if (bandwidthBetterOrWorse == WORSE) {
  1200. return false;
  1201. }
  1202. }
  1203. // If the result of each comparison was inconclusive, default to false.
  1204. return false;
  1205. }
  1206. /**
  1207. * @param {T} outputStream A text output stream
  1208. * @param {T} best The best match so far for this period
  1209. * @param {T} candidate A candidate stream which might be better
  1210. * @return {boolean} True if the candidate is a better match
  1211. *
  1212. * @template T
  1213. * Accepts either a StreamDB or Stream type.
  1214. *
  1215. * @private
  1216. */
  1217. static isTextStreamBetterMatch_(outputStream, best, candidate) {
  1218. const LanguageUtils = shaka.util.LanguageUtils;
  1219. // If the output stream was based on the candidate stream, the candidate
  1220. // stream should be considered a better match. We can check this by
  1221. // comparing their ids.
  1222. if (outputStream.id == candidate.id) {
  1223. return true;
  1224. }
  1225. // Otherwise, compare the streams' characteristics to determine the best
  1226. // match.
  1227. // The most important thing is language. In some cases, we will accept a
  1228. // different language across periods when we must.
  1229. const bestRelatedness = LanguageUtils.relatedness(
  1230. outputStream.language, best.language);
  1231. const candidateRelatedness = LanguageUtils.relatedness(
  1232. outputStream.language, candidate.language);
  1233. if (candidateRelatedness > bestRelatedness) {
  1234. return true;
  1235. }
  1236. if (candidateRelatedness < bestRelatedness) {
  1237. return false;
  1238. }
  1239. // If the language doesn't match, but the candidate is the "primary"
  1240. // language, then that should be preferred as a fallback.
  1241. if (!best.primary && candidate.primary) {
  1242. return true;
  1243. }
  1244. if (best.primary && !candidate.primary) {
  1245. return false;
  1246. }
  1247. // If the candidate has more roles in common with the output, upgrade to the
  1248. // candidate.
  1249. if (outputStream.roles.length) {
  1250. const bestRoleMatches =
  1251. best.roles.filter((role) => outputStream.roles.includes(role));
  1252. const candidateRoleMatches =
  1253. candidate.roles.filter((role) => outputStream.roles.includes(role));
  1254. if (candidateRoleMatches.length > bestRoleMatches.length) {
  1255. return true;
  1256. }
  1257. if (candidateRoleMatches.length < bestRoleMatches.length) {
  1258. return false;
  1259. }
  1260. } else if (!candidate.roles.length && best.roles.length) {
  1261. // If outputStream has no roles, and only one of the streams has no roles,
  1262. // choose the one with no roles.
  1263. return true;
  1264. } else if (candidate.roles.length && !best.roles.length) {
  1265. return false;
  1266. }
  1267. // If the candidate has the same MIME type and codec, upgrade to the
  1268. // candidate. It's not required that text streams use the same format
  1269. // across periods, but it's a helpful signal. Some content in our demo app
  1270. // contains the same languages repeated with two different text formats in
  1271. // each period. This condition ensures that all text streams are used.
  1272. // Otherwise, we wind up with some one stream of each language left unused,
  1273. // triggering a failure.
  1274. if (candidate.mimeType == outputStream.mimeType &&
  1275. candidate.codecs == outputStream.codecs &&
  1276. (best.mimeType != outputStream.mimeType ||
  1277. best.codecs != outputStream.codecs)) {
  1278. return true;
  1279. }
  1280. // If the result of each comparison was inconclusive, default to false.
  1281. return false;
  1282. }
  1283. /**
  1284. * @param {T} outputStream A image output stream
  1285. * @param {T} best The best match so far for this period
  1286. * @param {T} candidate A candidate stream which might be better
  1287. * @return {boolean} True if the candidate is a better match
  1288. *
  1289. * @template T
  1290. * Accepts either a StreamDB or Stream type.
  1291. *
  1292. * @private
  1293. */
  1294. static isImageStreamBetterMatch_(outputStream, best, candidate) {
  1295. const {BETTER, EQUAL, WORSE} = shaka.util.PeriodCombiner.BetterOrWorse;
  1296. // If the output stream was based on the candidate stream, the candidate
  1297. // stream should be considered a better match. We can check this by
  1298. // comparing their ids.
  1299. if (outputStream.id == candidate.id) {
  1300. return true;
  1301. }
  1302. // Take the image with the closest resolution to the output.
  1303. const resolutionBetterOrWorse =
  1304. shaka.util.PeriodCombiner.compareClosestPreferLower(
  1305. outputStream.width * outputStream.height,
  1306. best.width * best.height,
  1307. candidate.width * candidate.height);
  1308. if (resolutionBetterOrWorse == BETTER) {
  1309. return true;
  1310. } else if (resolutionBetterOrWorse == WORSE) {
  1311. return false;
  1312. }
  1313. // If the result of each comparison was inconclusive, default to false.
  1314. return false;
  1315. }
  1316. /**
  1317. * Create a dummy StreamDB to fill in periods that are missing a certain type,
  1318. * to avoid failing the general flattening algorithm. This won't be used for
  1319. * audio or video, since those are strictly required in all periods if they
  1320. * exist in any period.
  1321. *
  1322. * @param {shaka.util.ManifestParserUtils.ContentType} type
  1323. * @return {shaka.extern.StreamDB}
  1324. * @private
  1325. */
  1326. static dummyStreamDB_(type) {
  1327. return {
  1328. id: 0,
  1329. originalId: '',
  1330. groupId: null,
  1331. primary: false,
  1332. type,
  1333. mimeType: '',
  1334. codecs: '',
  1335. language: '',
  1336. originalLanguage: null,
  1337. label: null,
  1338. width: null,
  1339. height: null,
  1340. encrypted: false,
  1341. keyIds: new Set(),
  1342. segments: [],
  1343. variantIds: [],
  1344. roles: [],
  1345. forced: false,
  1346. channelsCount: null,
  1347. audioSamplingRate: null,
  1348. spatialAudio: false,
  1349. closedCaptions: null,
  1350. external: false,
  1351. fastSwitching: false,
  1352. };
  1353. }
  1354. /**
  1355. * Create a dummy Stream to fill in periods that are missing a certain type,
  1356. * to avoid failing the general flattening algorithm. This won't be used for
  1357. * audio or video, since those are strictly required in all periods if they
  1358. * exist in any period.
  1359. *
  1360. * @param {shaka.util.ManifestParserUtils.ContentType} type
  1361. * @return {shaka.extern.Stream}
  1362. * @private
  1363. */
  1364. static dummyStream_(type) {
  1365. return {
  1366. id: 0,
  1367. originalId: '',
  1368. groupId: null,
  1369. createSegmentIndex: () => Promise.resolve(),
  1370. segmentIndex: new shaka.media.SegmentIndex([]),
  1371. mimeType: '',
  1372. codecs: '',
  1373. encrypted: false,
  1374. drmInfos: [],
  1375. keyIds: new Set(),
  1376. language: '',
  1377. originalLanguage: null,
  1378. label: null,
  1379. type,
  1380. primary: false,
  1381. trickModeVideo: null,
  1382. emsgSchemeIdUris: null,
  1383. roles: [],
  1384. forced: false,
  1385. channelsCount: null,
  1386. audioSamplingRate: null,
  1387. spatialAudio: false,
  1388. closedCaptions: null,
  1389. accessibilityPurpose: null,
  1390. external: false,
  1391. fastSwitching: false,
  1392. };
  1393. }
  1394. /**
  1395. * Compare the best value so far with the candidate value and the output
  1396. * value. Decide if the candidate is better, equal, or worse than the best
  1397. * so far. Any value less than or equal to the output is preferred over a
  1398. * larger value, and closer to the output is better than farther.
  1399. *
  1400. * This provides us a generic way to choose things that should match as
  1401. * closely as possible, like resolution, frame rate, audio channels, or
  1402. * sample rate. If we have to go higher to make a match, we will. But if
  1403. * the user selects 480p, for example, we don't want to surprise them with
  1404. * 720p and waste bandwidth if there's another choice available to us.
  1405. *
  1406. * @param {number} outputValue
  1407. * @param {number} bestValue
  1408. * @param {number} candidateValue
  1409. * @return {shaka.util.PeriodCombiner.BetterOrWorse}
  1410. */
  1411. static compareClosestPreferLower(outputValue, bestValue, candidateValue) {
  1412. const {BETTER, EQUAL, WORSE} = shaka.util.PeriodCombiner.BetterOrWorse;
  1413. // If one is the exact match for the output value, and the other isn't,
  1414. // prefer the one that is the exact match.
  1415. if (bestValue == outputValue && outputValue != candidateValue) {
  1416. return WORSE;
  1417. } else if (candidateValue == outputValue && outputValue != bestValue) {
  1418. return BETTER;
  1419. }
  1420. if (bestValue > outputValue) {
  1421. if (candidateValue <= outputValue) {
  1422. // Any smaller-or-equal-to-output value is preferable to a
  1423. // bigger-than-output value.
  1424. return BETTER;
  1425. }
  1426. // Both "best" and "candidate" are greater than the output. Take
  1427. // whichever is closer.
  1428. if (candidateValue - outputValue < bestValue - outputValue) {
  1429. return BETTER;
  1430. } else if (candidateValue - outputValue > bestValue - outputValue) {
  1431. return WORSE;
  1432. }
  1433. } else {
  1434. // The "best" so far is less than or equal to the output. If the
  1435. // candidate is bigger than the output, we don't want it.
  1436. if (candidateValue > outputValue) {
  1437. return WORSE;
  1438. }
  1439. // Both "best" and "candidate" are less than or equal to the output.
  1440. // Take whichever is closer.
  1441. if (outputValue - candidateValue < outputValue - bestValue) {
  1442. return BETTER;
  1443. } else if (outputValue - candidateValue > outputValue - bestValue) {
  1444. return WORSE;
  1445. }
  1446. }
  1447. return EQUAL;
  1448. }
  1449. /**
  1450. * @param {number} outputValue
  1451. * @param {number} bestValue
  1452. * @param {number} candidateValue
  1453. * @return {shaka.util.PeriodCombiner.BetterOrWorse}
  1454. * @private
  1455. */
  1456. static compareClosestPreferMinimalAbsDiff_(
  1457. outputValue, bestValue, candidateValue) {
  1458. const {BETTER, EQUAL, WORSE} = shaka.util.PeriodCombiner.BetterOrWorse;
  1459. const absDiffBest = Math.abs(outputValue - bestValue);
  1460. const absDiffCandidate = Math.abs(outputValue - candidateValue);
  1461. if (absDiffCandidate < absDiffBest) {
  1462. return BETTER;
  1463. } else if (absDiffBest < absDiffCandidate) {
  1464. return WORSE;
  1465. }
  1466. return EQUAL;
  1467. }
  1468. };
  1469. /**
  1470. * @enum {number}
  1471. */
  1472. shaka.util.PeriodCombiner.BetterOrWorse = {
  1473. BETTER: 1,
  1474. EQUAL: 0,
  1475. WORSE: -1,
  1476. };
  1477. /**
  1478. * @private {Map<string, string>}
  1479. */
  1480. shaka.util.PeriodCombiner.memoizedCodecs = new Map();