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