Home Reference Source

src/utils/imsc1-ttml-parser.ts

  1. import { findBox } from './mp4-tools';
  2. import { parseTimeStamp } from './vttparser';
  3. import VTTCue from './vttcue';
  4. import { utf8ArrayToStr } from '../demux/id3';
  5. import { toTimescaleFromScale } from './timescale-conversion';
  6. import { generateCueId } from './webvtt-parser';
  7.  
  8. export const IMSC1_CODEC = 'stpp.ttml.im1t';
  9.  
  10. // Time format: h:m:s:frames(.subframes)
  11. const HMSF_REGEX = /^(\d{2,}):(\d{2}):(\d{2}):(\d{2})\.?(\d+)?$/;
  12.  
  13. // Time format: hours, minutes, seconds, milliseconds, frames, ticks
  14. const TIME_UNIT_REGEX = /^(\d*(?:\.\d*)?)(h|m|s|ms|f|t)$/;
  15.  
  16. const textAlignToLineAlign: Partial<Record<string, LineAlignSetting>> = {
  17. left: 'start',
  18. center: 'center',
  19. right: 'end',
  20. start: 'start',
  21. end: 'end',
  22. };
  23.  
  24. export function parseIMSC1(
  25. payload: ArrayBuffer,
  26. initPTS: number,
  27. timescale: number,
  28. callBack: (cues: Array<VTTCue>) => any,
  29. errorCallBack: (error: Error) => any
  30. ) {
  31. const results = findBox(new Uint8Array(payload), ['mdat']);
  32. if (results.length === 0) {
  33. errorCallBack(new Error('Could not parse IMSC1 mdat'));
  34. return;
  35. }
  36. const mdat = results[0];
  37. const ttml = utf8ArrayToStr(
  38. new Uint8Array(payload, mdat.start, mdat.end - mdat.start)
  39. );
  40. const syncTime = toTimescaleFromScale(initPTS, 1, timescale);
  41.  
  42. try {
  43. callBack(parseTTML(ttml, syncTime));
  44. } catch (error) {
  45. errorCallBack(error);
  46. }
  47. }
  48.  
  49. function parseTTML(ttml: string, syncTime: number): Array<VTTCue> {
  50. const parser = new DOMParser();
  51. const xmlDoc = parser.parseFromString(ttml, 'text/xml');
  52. const tt = xmlDoc.getElementsByTagName('tt')[0];
  53. if (!tt) {
  54. throw new Error('Invalid ttml');
  55. }
  56. const defaultRateInfo = {
  57. frameRate: 30,
  58. subFrameRate: 1,
  59. frameRateMultiplier: 0,
  60. tickRate: 0,
  61. };
  62. const rateInfo: Object = Object.keys(defaultRateInfo).reduce(
  63. (result, key) => {
  64. result[key] = tt.getAttribute(`ttp:${key}`) || defaultRateInfo[key];
  65. return result;
  66. },
  67. {}
  68. );
  69.  
  70. const trim = tt.getAttribute('xml:space') !== 'preserve';
  71.  
  72. const styleElements = collectionToDictionary(
  73. getElementCollection(tt, 'styling', 'style')
  74. );
  75. const regionElements = collectionToDictionary(
  76. getElementCollection(tt, 'layout', 'region')
  77. );
  78. const cueElements = getElementCollection(tt, 'body', '[begin]');
  79.  
  80. return [].map
  81. .call(cueElements, (cueElement) => {
  82. const cueText = getTextContent(cueElement, trim);
  83.  
  84. if (!cueText || !cueElement.hasAttribute('begin')) {
  85. return null;
  86. }
  87. const startTime = parseTtmlTime(
  88. cueElement.getAttribute('begin'),
  89. rateInfo
  90. );
  91. const duration = parseTtmlTime(cueElement.getAttribute('dur'), rateInfo);
  92. let endTime = parseTtmlTime(cueElement.getAttribute('end'), rateInfo);
  93. if (startTime === null) {
  94. throw timestampParsingError(cueElement);
  95. }
  96. if (endTime === null) {
  97. if (duration === null) {
  98. throw timestampParsingError(cueElement);
  99. }
  100. endTime = startTime + duration;
  101. }
  102. const cue = new VTTCue(startTime - syncTime, endTime - syncTime, cueText);
  103. cue.id = generateCueId(cue.startTime, cue.endTime, cue.text);
  104.  
  105. const region = regionElements[cueElement.getAttribute('region')];
  106. const style = styleElements[cueElement.getAttribute('style')];
  107.  
  108. // TODO: Add regions to track and cue (origin and extend)
  109. // These values are hard-coded (for now) to simulate region settings in the demo
  110. cue.position = 10;
  111. cue.size = 80;
  112.  
  113. // Apply styles to cue
  114. const styles = getTtmlStyles(region, style);
  115. const { textAlign } = styles;
  116. if (textAlign) {
  117. // cue.positionAlign not settable in FF~2016
  118. const lineAlign = textAlignToLineAlign[textAlign];
  119. if (lineAlign) {
  120. cue.lineAlign = lineAlign;
  121. }
  122. cue.align = textAlign as AlignSetting;
  123. }
  124. Object.assign(cue, styles);
  125.  
  126. return cue;
  127. })
  128. .filter((cue) => cue !== null);
  129. }
  130.  
  131. function getElementCollection(
  132. fromElement,
  133. parentName,
  134. childName
  135. ): Array<HTMLElement> {
  136. const parent = fromElement.getElementsByTagName(parentName)[0];
  137. if (parent) {
  138. return [].slice.call(parent.querySelectorAll(childName));
  139. }
  140. return [];
  141. }
  142.  
  143. function collectionToDictionary(elementsWithId: Array<HTMLElement>): {
  144. [id: string]: HTMLElement;
  145. } {
  146. return elementsWithId.reduce((dict, element: HTMLElement) => {
  147. const id = element.getAttribute('xml:id');
  148. if (id) {
  149. dict[id] = element;
  150. }
  151. return dict;
  152. }, {});
  153. }
  154.  
  155. function getTextContent(element, trim): string {
  156. return [].slice.call(element.childNodes).reduce((str, node, i) => {
  157. if (node.nodeName === 'br' && i) {
  158. return str + '\n';
  159. }
  160. if (node.childNodes?.length) {
  161. return getTextContent(node, trim);
  162. } else if (trim) {
  163. return str + node.textContent.trim().replace(/\s+/g, ' ');
  164. }
  165. return str + node.textContent;
  166. }, '');
  167. }
  168.  
  169. function getTtmlStyles(region, style): { [style: string]: string } {
  170. const ttsNs = 'http://www.w3.org/ns/ttml#styling';
  171. const styleAttributes = [
  172. 'displayAlign',
  173. 'textAlign',
  174. 'color',
  175. 'backgroundColor',
  176. 'fontSize',
  177. 'fontFamily',
  178. // 'fontWeight',
  179. // 'lineHeight',
  180. // 'wrapOption',
  181. // 'fontStyle',
  182. // 'direction',
  183. // 'writingMode'
  184. ];
  185. return styleAttributes.reduce((styles, name) => {
  186. const value =
  187. getAttributeNS(style, ttsNs, name) || getAttributeNS(region, ttsNs, name);
  188. if (value) {
  189. styles[name] = value;
  190. }
  191. return styles;
  192. }, {});
  193. }
  194.  
  195. function getAttributeNS(element, ns, name): string | null {
  196. return element.hasAttributeNS(ns, name)
  197. ? element.getAttributeNS(ns, name)
  198. : null;
  199. }
  200.  
  201. function timestampParsingError(node) {
  202. return new Error(`Could not parse ttml timestamp ${node}`);
  203. }
  204.  
  205. function parseTtmlTime(timeAttributeValue, rateInfo): number | null {
  206. if (!timeAttributeValue) {
  207. return null;
  208. }
  209. let seconds: number | null = parseTimeStamp(timeAttributeValue);
  210. if (seconds === null) {
  211. if (HMSF_REGEX.test(timeAttributeValue)) {
  212. seconds = parseHoursMinutesSecondsFrames(timeAttributeValue, rateInfo);
  213. } else if (TIME_UNIT_REGEX.test(timeAttributeValue)) {
  214. seconds = parseTimeUnits(timeAttributeValue, rateInfo);
  215. }
  216. }
  217. return seconds;
  218. }
  219.  
  220. function parseHoursMinutesSecondsFrames(timeAttributeValue, rateInfo): number {
  221. const m = HMSF_REGEX.exec(timeAttributeValue) as Array<any>;
  222. const frames = (m[4] | 0) + (m[5] | 0) / rateInfo.subFrameRate;
  223. return (
  224. (m[1] | 0) * 3600 +
  225. (m[2] | 0) * 60 +
  226. (m[3] | 0) +
  227. frames / rateInfo.frameRate
  228. );
  229. }
  230.  
  231. function parseTimeUnits(timeAttributeValue, rateInfo): number {
  232. const m = TIME_UNIT_REGEX.exec(timeAttributeValue) as Array<any>;
  233. const value = Number(m[1]);
  234. const unit = m[2];
  235. switch (unit) {
  236. case 'h':
  237. return value * 3600;
  238. case 'm':
  239. return value * 60;
  240. case 'ms':
  241. return value * 1000;
  242. case 'f':
  243. return value / rateInfo.frameRate;
  244. case 't':
  245. return value / rateInfo.tickRate;
  246. }
  247. return value;
  248. }