Home Reference Source

src/utils/webvtt-parser.ts

  1. import { VTTParser } from './vttparser';
  2. import { utf8ArrayToStr } from '../demux/id3';
  3. import { toMpegTsClockFromTimescale } from './timescale-conversion';
  4. import { PTSNormalize } from '../remux/mp4-remuxer';
  5. import type { VTTCCs } from '../types/vtt';
  6.  
  7. const LINEBREAKS = /\r\n|\n\r|\n|\r/g;
  8.  
  9. // String.prototype.startsWith is not supported in IE11
  10. const startsWith = function (
  11. inputString: string,
  12. searchString: string,
  13. position: number = 0
  14. ) {
  15. return inputString.substr(position, searchString.length) === searchString;
  16. };
  17.  
  18. const cueString2millis = function (timeString: string) {
  19. let ts = parseInt(timeString.substr(-3));
  20. const secs = parseInt(timeString.substr(-6, 2));
  21. const mins = parseInt(timeString.substr(-9, 2));
  22. const hours =
  23. timeString.length > 9
  24. ? parseInt(timeString.substr(0, timeString.indexOf(':')))
  25. : 0;
  26.  
  27. if (
  28. !Number.isFinite(ts) ||
  29. !Number.isFinite(secs) ||
  30. !Number.isFinite(mins) ||
  31. !Number.isFinite(hours)
  32. ) {
  33. throw Error(`Malformed X-TIMESTAMP-MAP: Local:${timeString}`);
  34. }
  35.  
  36. ts += 1000 * secs;
  37. ts += 60 * 1000 * mins;
  38. ts += 60 * 60 * 1000 * hours;
  39.  
  40. return ts;
  41. };
  42.  
  43. // From https://github.com/darkskyapp/string-hash
  44. const hash = function (text: string) {
  45. let hash = 5381;
  46. let i = text.length;
  47. while (i) {
  48. hash = (hash * 33) ^ text.charCodeAt(--i);
  49. }
  50.  
  51. return (hash >>> 0).toString();
  52. };
  53.  
  54. // Create a unique hash id for a cue based on start/end times and text.
  55. // This helps timeline-controller to avoid showing repeated captions.
  56. export function generateCueId(
  57. startTime: number,
  58. endTime: number,
  59. text: string
  60. ) {
  61. return hash(startTime.toString()) + hash(endTime.toString()) + hash(text);
  62. }
  63.  
  64. const calculateOffset = function (vttCCs: VTTCCs, cc, presentationTime) {
  65. let currCC = vttCCs[cc];
  66. let prevCC = vttCCs[currCC.prevCC];
  67.  
  68. // This is the first discontinuity or cues have been processed since the last discontinuity
  69. // Offset = current discontinuity time
  70. if (!prevCC || (!prevCC.new && currCC.new)) {
  71. vttCCs.ccOffset = vttCCs.presentationOffset = currCC.start;
  72. currCC.new = false;
  73. return;
  74. }
  75.  
  76. // There have been discontinuities since cues were last parsed.
  77. // Offset = time elapsed
  78. while (prevCC?.new) {
  79. vttCCs.ccOffset += currCC.start - prevCC.start;
  80. currCC.new = false;
  81. currCC = prevCC;
  82. prevCC = vttCCs[currCC.prevCC];
  83. }
  84.  
  85. vttCCs.presentationOffset = presentationTime;
  86. };
  87.  
  88. export function parseWebVTT(
  89. vttByteArray: ArrayBuffer,
  90. initPTS: number,
  91. timescale: number,
  92. vttCCs: VTTCCs,
  93. cc: number,
  94. timeOffset: number,
  95. callBack: (cues: VTTCue[]) => void,
  96. errorCallBack: (error: Error) => void
  97. ) {
  98. const parser = new VTTParser();
  99. // Convert byteArray into string, replacing any somewhat exotic linefeeds with "\n", then split on that character.
  100. // Uint8Array.prototype.reduce is not implemented in IE11
  101. const vttLines = utf8ArrayToStr(new Uint8Array(vttByteArray))
  102. .trim()
  103. .replace(LINEBREAKS, '\n')
  104. .split('\n');
  105. const cues: VTTCue[] = [];
  106. const initPTS90Hz = toMpegTsClockFromTimescale(initPTS, timescale);
  107. let cueTime = '00:00.000';
  108. let timestampMapMPEGTS = 0;
  109. let timestampMapLOCAL = 0;
  110. let parsingError: Error;
  111. let inHeader = true;
  112. let timestampMap = false;
  113.  
  114. parser.oncue = function (cue: VTTCue) {
  115. // Adjust cue timing; clamp cues to start no earlier than - and drop cues that don't end after - 0 on timeline.
  116. const currCC = vttCCs[cc];
  117. let cueOffset = vttCCs.ccOffset;
  118.  
  119. // Calculate subtitle PTS offset
  120. const webVttMpegTsMapOffset = (timestampMapMPEGTS - initPTS90Hz) / 90000;
  121.  
  122. // Update offsets for new discontinuities
  123. if (currCC?.new) {
  124. if (timestampMapLOCAL !== undefined) {
  125. // When local time is provided, offset = discontinuity start time - local time
  126. cueOffset = vttCCs.ccOffset = currCC.start;
  127. } else {
  128. calculateOffset(vttCCs, cc, webVttMpegTsMapOffset);
  129. }
  130. }
  131.  
  132. if (webVttMpegTsMapOffset) {
  133. // If we have MPEGTS, offset = presentation time + discontinuity offset
  134. cueOffset = webVttMpegTsMapOffset - vttCCs.presentationOffset;
  135. }
  136.  
  137. if (timestampMap) {
  138. const duration = cue.endTime - cue.startTime;
  139. const startTime =
  140. PTSNormalize(
  141. (cue.startTime + cueOffset - timestampMapLOCAL) * 90000,
  142. timeOffset * 90000
  143. ) / 90000;
  144. cue.startTime = startTime;
  145. cue.endTime = startTime + duration;
  146. }
  147.  
  148. //trim trailing webvtt block whitespaces
  149. const text = cue.text.trim();
  150.  
  151. // Fix encoding of special characters
  152. cue.text = decodeURIComponent(encodeURIComponent(text));
  153.  
  154. // If the cue was not assigned an id from the VTT file (line above the content), create one.
  155. if (!cue.id) {
  156. cue.id = generateCueId(cue.startTime, cue.endTime, text);
  157. }
  158.  
  159. if (cue.endTime > 0) {
  160. cues.push(cue);
  161. }
  162. };
  163.  
  164. parser.onparsingerror = function (error: Error) {
  165. parsingError = error;
  166. };
  167.  
  168. parser.onflush = function () {
  169. if (parsingError && errorCallBack) {
  170. errorCallBack(parsingError);
  171. return;
  172. }
  173. callBack(cues);
  174. };
  175.  
  176. // Go through contents line by line.
  177. vttLines.forEach((line) => {
  178. if (inHeader) {
  179. // Look for X-TIMESTAMP-MAP in header.
  180. if (startsWith(line, 'X-TIMESTAMP-MAP=')) {
  181. // Once found, no more are allowed anyway, so stop searching.
  182. inHeader = false;
  183. timestampMap = true;
  184. // Extract LOCAL and MPEGTS.
  185. line
  186. .substr(16)
  187. .split(',')
  188. .forEach((timestamp) => {
  189. if (startsWith(timestamp, 'LOCAL:')) {
  190. cueTime = timestamp.substr(6);
  191. } else if (startsWith(timestamp, 'MPEGTS:')) {
  192. timestampMapMPEGTS = parseInt(timestamp.substr(7));
  193. }
  194. });
  195. try {
  196. // Convert cue time to seconds
  197. timestampMapLOCAL = cueString2millis(cueTime) / 1000;
  198. } catch (error) {
  199. timestampMap = false;
  200. parsingError = error;
  201. }
  202. // Return without parsing X-TIMESTAMP-MAP line.
  203. return;
  204. } else if (line === '') {
  205. inHeader = false;
  206. }
  207. }
  208. // Parse line by default.
  209. parser.parse(line + '\n');
  210. });
  211.  
  212. parser.flush();
  213. }