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