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.  
  37. const ttmlList = results.map((mdat) => utf8ArrayToStr(mdat));
  38.  
  39. const syncTime = toTimescaleFromScale(initPTS, 1, timescale);
  40.  
  41. try {
  42. ttmlList.forEach((ttml) => callBack(parseTTML(ttml, syncTime)));
  43. } catch (error) {
  44. errorCallBack(error);
  45. }
  46. }
  47.  
  48. function parseTTML(ttml: string, syncTime: number): Array<VTTCue> {
  49. const parser = new DOMParser();
  50. const xmlDoc = parser.parseFromString(ttml, 'text/xml');
  51. const tt = xmlDoc.getElementsByTagName('tt')[0];
  52. if (!tt) {
  53. throw new Error('Invalid ttml');
  54. }
  55. const defaultRateInfo = {
  56. frameRate: 30,
  57. subFrameRate: 1,
  58. frameRateMultiplier: 0,
  59. tickRate: 0,
  60. };
  61. const rateInfo: Object = Object.keys(defaultRateInfo).reduce(
  62. (result, key) => {
  63. result[key] = tt.getAttribute(`ttp:${key}`) || defaultRateInfo[key];
  64. return result;
  65. },
  66. {}
  67. );
  68.  
  69. const trim = tt.getAttribute('xml:space') !== 'preserve';
  70.  
  71. const styleElements = collectionToDictionary(
  72. getElementCollection(tt, 'styling', 'style')
  73. );
  74. const regionElements = collectionToDictionary(
  75. getElementCollection(tt, 'layout', 'region')
  76. );
  77. const cueElements = getElementCollection(tt, 'body', '[begin]');
  78.  
  79. return [].map
  80. .call(cueElements, (cueElement) => {
  81. const cueText = getTextContent(cueElement, trim);
  82.  
  83. if (!cueText || !cueElement.hasAttribute('begin')) {
  84. return null;
  85. }
  86. const startTime = parseTtmlTime(
  87. cueElement.getAttribute('begin'),
  88. rateInfo
  89. );
  90. const duration = parseTtmlTime(cueElement.getAttribute('dur'), rateInfo);
  91. let endTime = parseTtmlTime(cueElement.getAttribute('end'), rateInfo);
  92. if (startTime === null) {
  93. throw timestampParsingError(cueElement);
  94. }
  95. if (endTime === null) {
  96. if (duration === null) {
  97. throw timestampParsingError(cueElement);
  98. }
  99. endTime = startTime + duration;
  100. }
  101. const cue = new VTTCue(startTime - syncTime, endTime - syncTime, cueText);
  102. cue.id = generateCueId(cue.startTime, cue.endTime, cue.text);
  103.  
  104. const region = regionElements[cueElement.getAttribute('region')];
  105. const style = styleElements[cueElement.getAttribute('style')];
  106.  
  107. // Apply styles to cue
  108. const styles = getTtmlStyles(region, style, styleElements);
  109. const { textAlign } = styles;
  110. if (textAlign) {
  111. // cue.positionAlign not settable in FF~2016
  112. const lineAlign = textAlignToLineAlign[textAlign];
  113. if (lineAlign) {
  114. cue.lineAlign = lineAlign;
  115. }
  116. cue.align = textAlign as AlignSetting;
  117. }
  118. Object.assign(cue, styles);
  119.  
  120. return cue;
  121. })
  122. .filter((cue) => cue !== null);
  123. }
  124.  
  125. function getElementCollection(
  126. fromElement,
  127. parentName,
  128. childName
  129. ): Array<HTMLElement> {
  130. const parent = fromElement.getElementsByTagName(parentName)[0];
  131. if (parent) {
  132. return [].slice.call(parent.querySelectorAll(childName));
  133. }
  134. return [];
  135. }
  136.  
  137. function collectionToDictionary(elementsWithId: Array<HTMLElement>): {
  138. [id: string]: HTMLElement;
  139. } {
  140. return elementsWithId.reduce((dict, element: HTMLElement) => {
  141. const id = element.getAttribute('xml:id');
  142. if (id) {
  143. dict[id] = element;
  144. }
  145. return dict;
  146. }, {});
  147. }
  148.  
  149. function getTextContent(element, trim): string {
  150. return [].slice.call(element.childNodes).reduce((str, node, i) => {
  151. if (node.nodeName === 'br' && i) {
  152. return str + '\n';
  153. }
  154. if (node.childNodes?.length) {
  155. return getTextContent(node, trim);
  156. } else if (trim) {
  157. return str + node.textContent.trim().replace(/\s+/g, ' ');
  158. }
  159. return str + node.textContent;
  160. }, '');
  161. }
  162.  
  163. function getTtmlStyles(
  164. region,
  165. style,
  166. styleElements
  167. ): { [style: string]: string } {
  168. const ttsNs = 'http://www.w3.org/ns/ttml#styling';
  169. let regionStyle = null;
  170. const styleAttributes = [
  171. 'displayAlign',
  172. 'textAlign',
  173. 'color',
  174. 'backgroundColor',
  175. 'fontSize',
  176. 'fontFamily',
  177. // 'fontWeight',
  178. // 'lineHeight',
  179. // 'wrapOption',
  180. // 'fontStyle',
  181. // 'direction',
  182. // 'writingMode'
  183. ];
  184.  
  185. const regionStyleName = region?.hasAttribute('style')
  186. ? region.getAttribute('style')
  187. : null;
  188.  
  189. if (regionStyleName && styleElements.hasOwnProperty(regionStyleName)) {
  190. regionStyle = styleElements[regionStyleName];
  191. }
  192.  
  193. return styleAttributes.reduce((styles, name) => {
  194. const value =
  195. getAttributeNS(style, ttsNs, name) ||
  196. getAttributeNS(region, ttsNs, name) ||
  197. getAttributeNS(regionStyle, ttsNs, name);
  198. if (value) {
  199. styles[name] = value;
  200. }
  201. return styles;
  202. }, {});
  203. }
  204.  
  205. function getAttributeNS(element, ns, name): string | null {
  206. if (!element) {
  207. return null;
  208. }
  209. return element.hasAttributeNS(ns, name)
  210. ? element.getAttributeNS(ns, name)
  211. : null;
  212. }
  213.  
  214. function timestampParsingError(node) {
  215. return new Error(`Could not parse ttml timestamp ${node}`);
  216. }
  217.  
  218. function parseTtmlTime(timeAttributeValue, rateInfo): number | null {
  219. if (!timeAttributeValue) {
  220. return null;
  221. }
  222. let seconds: number | null = parseTimeStamp(timeAttributeValue);
  223. if (seconds === null) {
  224. if (HMSF_REGEX.test(timeAttributeValue)) {
  225. seconds = parseHoursMinutesSecondsFrames(timeAttributeValue, rateInfo);
  226. } else if (TIME_UNIT_REGEX.test(timeAttributeValue)) {
  227. seconds = parseTimeUnits(timeAttributeValue, rateInfo);
  228. }
  229. }
  230. return seconds;
  231. }
  232.  
  233. function parseHoursMinutesSecondsFrames(timeAttributeValue, rateInfo): number {
  234. const m = HMSF_REGEX.exec(timeAttributeValue) as Array<any>;
  235. const frames = (m[4] | 0) + (m[5] | 0) / rateInfo.subFrameRate;
  236. return (
  237. (m[1] | 0) * 3600 +
  238. (m[2] | 0) * 60 +
  239. (m[3] | 0) +
  240. frames / rateInfo.frameRate
  241. );
  242. }
  243.  
  244. function parseTimeUnits(timeAttributeValue, rateInfo): number {
  245. const m = TIME_UNIT_REGEX.exec(timeAttributeValue) as Array<any>;
  246. const value = Number(m[1]);
  247. const unit = m[2];
  248. switch (unit) {
  249. case 'h':
  250. return value * 3600;
  251. case 'm':
  252. return value * 60;
  253. case 'ms':
  254. return value * 1000;
  255. case 'f':
  256. return value / rateInfo.frameRate;
  257. case 't':
  258. return value / rateInfo.tickRate;
  259. }
  260. return value;
  261. }