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