Source: lib/text/text_engine.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.text.TextEngine');
  7. goog.require('goog.asserts');
  8. goog.require('shaka.text.Cue');
  9. goog.require('shaka.util.BufferUtils');
  10. goog.require('shaka.util.Functional');
  11. goog.require('shaka.util.IDestroyable');
  12. goog.require('shaka.util.MimeUtils');
  13. goog.requireType('shaka.cea.ICaptionDecoder');
  14. // TODO: revisit this when Closure Compiler supports partially-exported classes.
  15. /**
  16. * @summary Manages text parsers and cues.
  17. * @implements {shaka.util.IDestroyable}
  18. * @export
  19. */
  20. shaka.text.TextEngine = class {
  21. /** @param {shaka.extern.TextDisplayer} displayer */
  22. constructor(displayer) {
  23. /** @private {?shaka.extern.TextParser} */
  24. this.parser_ = null;
  25. /** @private {shaka.extern.TextDisplayer} */
  26. this.displayer_ = displayer;
  27. /** @private {number} */
  28. this.timestampOffset_ = 0;
  29. /** @private {number} */
  30. this.appendWindowStart_ = 0;
  31. /** @private {number} */
  32. this.appendWindowEnd_ = Infinity;
  33. /** @private {?number} */
  34. this.bufferStart_ = null;
  35. /** @private {?number} */
  36. this.bufferEnd_ = null;
  37. /** @private {string} */
  38. this.selectedClosedCaptionId_ = '';
  39. /**
  40. * The closed captions map stores the CEA closed captions by closed captions
  41. * id and start and end time.
  42. * It's used as the buffer of closed caption text streams, to show captions
  43. * when we start displaying captions or switch caption tracks, we need to be
  44. * able to get the cues for the other language and display them without
  45. * re-fetching the video segments they were embedded in.
  46. * Structure of closed caption map:
  47. * closed caption id -> {start and end time -> cues}
  48. * @private {!Map.<string, !Map.<string, !Array.<shaka.text.Cue>>>} */
  49. this.closedCaptionsMap_ = new Map();
  50. }
  51. /**
  52. * @param {string} mimeType
  53. * @param {!shaka.extern.TextParserPlugin} plugin
  54. * @export
  55. */
  56. static registerParser(mimeType, plugin) {
  57. shaka.text.TextEngine.parserMap_[mimeType] = plugin;
  58. }
  59. /**
  60. * @param {string} mimeType
  61. * @export
  62. */
  63. static unregisterParser(mimeType) {
  64. delete shaka.text.TextEngine.parserMap_[mimeType];
  65. }
  66. /**
  67. * @return {?shaka.extern.TextParserPlugin}
  68. * @export
  69. */
  70. static findParser(mimeType) {
  71. return shaka.text.TextEngine.parserMap_[mimeType];
  72. }
  73. /**
  74. * @param {string} mimeType
  75. * @return {boolean}
  76. */
  77. static isTypeSupported(mimeType) {
  78. if (shaka.text.TextEngine.parserMap_[mimeType]) {
  79. // An actual parser is available.
  80. return true;
  81. }
  82. if (mimeType == shaka.util.MimeUtils.CEA608_CLOSED_CAPTION_MIMETYPE ||
  83. mimeType == shaka.util.MimeUtils.CEA708_CLOSED_CAPTION_MIMETYPE ) {
  84. // Closed captions.
  85. return true;
  86. }
  87. return false;
  88. }
  89. // TODO: revisit this when the compiler supports partially-exported classes.
  90. /**
  91. * @override
  92. * @export
  93. */
  94. destroy() {
  95. this.parser_ = null;
  96. this.displayer_ = null;
  97. this.closedCaptionsMap_.clear();
  98. return Promise.resolve();
  99. }
  100. /**
  101. * @param {!shaka.extern.TextDisplayer} displayer
  102. */
  103. setDisplayer(displayer) {
  104. this.displayer_ = displayer;
  105. }
  106. /**
  107. * Initialize the parser. This can be called multiple times, but must be
  108. * called at least once before appendBuffer.
  109. *
  110. * @param {string} mimeType
  111. */
  112. initParser(mimeType) {
  113. // No parser for CEA, which is extracted from video and side-loaded
  114. // into TextEngine and TextDisplayer.
  115. if (mimeType == shaka.util.MimeUtils.CEA608_CLOSED_CAPTION_MIMETYPE ||
  116. mimeType == shaka.util.MimeUtils.CEA708_CLOSED_CAPTION_MIMETYPE) {
  117. this.parser_ = null;
  118. return;
  119. }
  120. const factory = shaka.text.TextEngine.parserMap_[mimeType];
  121. goog.asserts.assert(
  122. factory, 'Text type negotiation should have happened already');
  123. this.parser_ = shaka.util.Functional.callFactory(factory);
  124. }
  125. /**
  126. * @param {BufferSource} buffer
  127. * @param {?number} startTime relative to the start of the presentation
  128. * @param {?number} endTime relative to the start of the presentation
  129. * @return {!Promise}
  130. */
  131. async appendBuffer(buffer, startTime, endTime) {
  132. goog.asserts.assert(
  133. this.parser_, 'The parser should already be initialized');
  134. // Start the operation asynchronously to avoid blocking the caller.
  135. await Promise.resolve();
  136. // Check that TextEngine hasn't been destroyed.
  137. if (!this.parser_ || !this.displayer_) {
  138. return;
  139. }
  140. if (startTime == null || endTime == null) {
  141. this.parser_.parseInit(shaka.util.BufferUtils.toUint8(buffer));
  142. return;
  143. }
  144. /** @type {shaka.extern.TextParser.TimeContext} **/
  145. const time = {
  146. periodStart: this.timestampOffset_,
  147. segmentStart: startTime,
  148. segmentEnd: endTime,
  149. };
  150. // Parse the buffer and add the new cues.
  151. const allCues = this.parser_.parseMedia(
  152. shaka.util.BufferUtils.toUint8(buffer), time);
  153. const cuesToAppend = allCues.filter((cue) => {
  154. return cue.startTime >= this.appendWindowStart_ &&
  155. cue.startTime < this.appendWindowEnd_;
  156. });
  157. this.displayer_.append(cuesToAppend);
  158. // NOTE: We update the buffered range from the start and end times
  159. // passed down from the segment reference, not with the start and end
  160. // times of the parsed cues. This is important because some segments
  161. // may contain no cues, but we must still consider those ranges
  162. // buffered.
  163. if (this.bufferStart_ == null) {
  164. this.bufferStart_ = Math.max(startTime, this.appendWindowStart_);
  165. } else {
  166. // We already had something in buffer, and we assume we are extending
  167. // the range from the end.
  168. goog.asserts.assert(
  169. this.bufferEnd_ != null,
  170. 'There should already be a buffered range end.');
  171. goog.asserts.assert(
  172. (startTime - this.bufferEnd_) <= 1,
  173. 'There should not be a gap in text references >1s');
  174. }
  175. this.bufferEnd_ = Math.min(endTime, this.appendWindowEnd_);
  176. }
  177. /**
  178. * @param {number} startTime relative to the start of the presentation
  179. * @param {number} endTime relative to the start of the presentation
  180. * @return {!Promise}
  181. */
  182. async remove(startTime, endTime) {
  183. // Start the operation asynchronously to avoid blocking the caller.
  184. await Promise.resolve();
  185. if (this.displayer_ && this.displayer_.remove(startTime, endTime)) {
  186. if (this.bufferStart_ == null) {
  187. goog.asserts.assert(
  188. this.bufferEnd_ == null, 'end must be null if startTime is null');
  189. } else {
  190. goog.asserts.assert(
  191. this.bufferEnd_ != null,
  192. 'end must be non-null if startTime is non-null');
  193. // Update buffered range.
  194. if (endTime <= this.bufferStart_ || startTime >= this.bufferEnd_) {
  195. // No intersection. Nothing was removed.
  196. } else if (startTime <= this.bufferStart_ &&
  197. endTime >= this.bufferEnd_) {
  198. // We wiped out everything.
  199. this.bufferStart_ = this.bufferEnd_ = null;
  200. } else if (startTime <= this.bufferStart_ &&
  201. endTime < this.bufferEnd_) {
  202. // We removed from the beginning of the range.
  203. this.bufferStart_ = endTime;
  204. } else if (startTime > this.bufferStart_ &&
  205. endTime >= this.bufferEnd_) {
  206. // We removed from the end of the range.
  207. this.bufferEnd_ = startTime;
  208. } else {
  209. // We removed from the middle? StreamingEngine isn't supposed to.
  210. goog.asserts.assert(
  211. false, 'removal from the middle is not supported by TextEngine');
  212. }
  213. }
  214. }
  215. }
  216. /** @param {number} timestampOffset */
  217. setTimestampOffset(timestampOffset) {
  218. this.timestampOffset_ = timestampOffset;
  219. }
  220. /**
  221. * @param {number} appendWindowStart
  222. * @param {number} appendWindowEnd
  223. */
  224. setAppendWindow(appendWindowStart, appendWindowEnd) {
  225. this.appendWindowStart_ = appendWindowStart;
  226. this.appendWindowEnd_ = appendWindowEnd;
  227. }
  228. /**
  229. * @return {?number} Time in seconds of the beginning of the buffered range,
  230. * or null if nothing is buffered.
  231. */
  232. bufferStart() {
  233. return this.bufferStart_;
  234. }
  235. /**
  236. * @return {?number} Time in seconds of the end of the buffered range,
  237. * or null if nothing is buffered.
  238. */
  239. bufferEnd() {
  240. return this.bufferEnd_;
  241. }
  242. /**
  243. * @param {number} t A timestamp
  244. * @return {boolean}
  245. */
  246. isBuffered(t) {
  247. if (this.bufferStart_ == null || this.bufferEnd_ == null) {
  248. return false;
  249. }
  250. return t >= this.bufferStart_ && t < this.bufferEnd_;
  251. }
  252. /**
  253. * @param {number} t A timestamp
  254. * @return {number} Number of seconds ahead of 't' we have buffered
  255. */
  256. bufferedAheadOf(t) {
  257. if (this.bufferEnd_ == null || this.bufferEnd_ < t) {
  258. return 0;
  259. }
  260. goog.asserts.assert(
  261. this.bufferStart_ != null,
  262. 'start should not be null if end is not null');
  263. return this.bufferEnd_ - Math.max(t, this.bufferStart_);
  264. }
  265. /**
  266. * Set the selected closed captions id.
  267. * Append the cues stored in the closed captions map until buffer end time.
  268. * This is to fill the gap between buffered and unbuffered captions, and to
  269. * avoid duplicates that would be caused by any future video segments parsed
  270. * for captions.
  271. *
  272. * @param {string} id
  273. * @param {number} bufferEndTime Load any stored cues up to this time.
  274. */
  275. setSelectedClosedCaptionId(id, bufferEndTime) {
  276. this.selectedClosedCaptionId_ = id;
  277. const captionsMap = this.closedCaptionsMap_.get(id);
  278. if (captionsMap) {
  279. for (const startAndEndTime of captionsMap.keys()) {
  280. /** @type {Array.<!shaka.text.Cue>} */
  281. const cues = captionsMap.get(startAndEndTime)
  282. .filter((c) => c.endTime <= bufferEndTime);
  283. if (cues) {
  284. this.displayer_.append(cues);
  285. }
  286. }
  287. }
  288. }
  289. /**
  290. * @param {!Array<muxjs.mp4.ClosedCaption>} closedCaptions
  291. * @return {!Array<!shaka.cea.ICaptionDecoder.ClosedCaption>}
  292. */
  293. convertMuxjsCaptionsToShakaCaptions(closedCaptions) {
  294. const cues = [];
  295. for (const caption of closedCaptions) {
  296. const cue = new shaka.text.Cue(
  297. caption.startTime, caption.endTime, caption.text);
  298. cues.push({
  299. stream: caption.stream,
  300. cue,
  301. });
  302. }
  303. return cues;
  304. }
  305. /**
  306. * @param {!shaka.text.Cue} cue the cue to apply the timestamp to recursively
  307. * @param {number} videoTimestampOffset the timestamp offset of the video
  308. * @private
  309. */
  310. applyVideoTimestampOffsetRecursive_(cue, videoTimestampOffset) {
  311. cue.startTime += videoTimestampOffset;
  312. cue.endTime += videoTimestampOffset;
  313. for (const nested of cue.nestedCues) {
  314. this.applyVideoTimestampOffsetRecursive_(nested, videoTimestampOffset);
  315. }
  316. }
  317. /**
  318. * Store the closed captions in the text engine, and append the cues to the
  319. * text displayer. This is a side-channel used for embedded text only.
  320. *
  321. * @param {!Array.<!shaka.cea.ICaptionDecoder.ClosedCaption>} closedCaptions
  322. * @param {?number} startTime relative to the start of the presentation
  323. * @param {?number} endTime relative to the start of the presentation
  324. * @param {number} videoTimestampOffset the timestamp offset of the video
  325. * stream in which these captions were embedded
  326. */
  327. storeAndAppendClosedCaptions(
  328. closedCaptions, startTime, endTime, videoTimestampOffset) {
  329. const startAndEndTime = startTime + ' ' + endTime;
  330. /** @type {!Map.<string, !Map.<string, !Array.<!shaka.text.Cue>>>} */
  331. const captionsMap = new Map();
  332. for (const caption of closedCaptions) {
  333. const id = caption.stream;
  334. const cue = caption.cue;
  335. if (!captionsMap.has(id)) {
  336. captionsMap.set(id, new Map());
  337. }
  338. if (!captionsMap.get(id).has(startAndEndTime)) {
  339. captionsMap.get(id).set(startAndEndTime, []);
  340. }
  341. // Adjust CEA captions with respect to the timestamp offset of the video
  342. // stream in which they were embedded.
  343. this.applyVideoTimestampOffsetRecursive_(cue, videoTimestampOffset);
  344. const keepThisCue =
  345. cue.startTime >= this.appendWindowStart_ &&
  346. cue.startTime < this.appendWindowEnd_;
  347. if (!keepThisCue) {
  348. continue;
  349. }
  350. captionsMap.get(id).get(startAndEndTime).push(cue);
  351. if (id == this.selectedClosedCaptionId_) {
  352. this.displayer_.append([cue]);
  353. }
  354. }
  355. for (const id of captionsMap.keys()) {
  356. if (!this.closedCaptionsMap_.has(id)) {
  357. this.closedCaptionsMap_.set(id, new Map());
  358. }
  359. for (const startAndEndTime of captionsMap.get(id).keys()) {
  360. const cues = captionsMap.get(id).get(startAndEndTime);
  361. this.closedCaptionsMap_.get(id).set(startAndEndTime, cues);
  362. }
  363. }
  364. if (this.bufferStart_ == null) {
  365. this.bufferStart_ = Math.max(startTime, this.appendWindowStart_);
  366. } else {
  367. this.bufferStart_ = Math.min(
  368. this.bufferStart_, Math.max(startTime, this.appendWindowStart_));
  369. }
  370. this.bufferEnd_ = Math.max(
  371. this.bufferEnd_, Math.min(endTime, this.appendWindowEnd_));
  372. }
  373. /**
  374. * Get the number of closed caption channels.
  375. *
  376. * This function is for TESTING ONLY. DO NOT USE in the library.
  377. *
  378. * @return {number}
  379. */
  380. getNumberOfClosedCaptionChannels() {
  381. return this.closedCaptionsMap_.size;
  382. }
  383. /**
  384. * Get the number of closed caption cues for a given channel. If there is
  385. * no channel for the given channel id, this will return 0.
  386. *
  387. * This function is for TESTING ONLY. DO NOT USE in the library.
  388. *
  389. * @param {string} channelId
  390. * @return {number}
  391. */
  392. getNumberOfClosedCaptionsInChannel(channelId) {
  393. const channel = this.closedCaptionsMap_.get(channelId);
  394. return channel ? channel.size : 0;
  395. }
  396. };
  397. /** @private {!Object.<string, !shaka.extern.TextParserPlugin>} */
  398. shaka.text.TextEngine.parserMap_ = {};