src/srt.js (2758 bytes)
1 /* Subtitles as data: read an .srt (or .vtt), and find the cue for a moment of 2 * the video. No browser APIs here, so Node can test it. 3 * 4 * The video on YouTube is often not the same file as the audio the subtitles 5 * were made from: an intro was added, or the speed differs a little. So the 6 * time of the video is first moved onto the clock of the subtitles: 7 * 8 * subtitle time = (video time - offset) * rate 9 * 10 * The transcript panel needs the other way round, from a cue's time back to a 11 * video time to seek to: 12 * 13 * video time = subtitle time / rate + offset 14 */ 15 (function (root) { 16 'use strict'; 17 18 const STAMP = /(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/; 19 20 function seconds(stamp) { 21 const m = STAMP.exec(stamp); 22 if (!m) return NaN; 23 return (+(m[1] || 0)) * 3600 + (+m[2]) * 60 + (+m[3]) + (+m[4].padEnd(3, '0')) / 1000; 24 } 25 26 /** Cues in order of start: [{ start, end, text }]. Blocks that are not cues are passed over. */ 27 function parse(source) { 28 const cues = []; 29 const blocks = String(source).replace(/^/, '').replace(/\r\n?/g, '\n').split(/\n{2,}/); 30 for (const block of blocks) { 31 const lines = block.split('\n'); 32 const at = lines.findIndex((l) => l.includes('-->')); 33 if (at < 0) continue; 34 const [from, to] = lines[at].split('-->'); 35 const start = seconds(from), end = seconds(to); 36 const text = lines.slice(at + 1).join('\n').replace(/<[^>]*>/g, '').trim(); 37 if (Number.isFinite(start) && Number.isFinite(end) && end > start && text) cues.push({ start, end, text }); 38 } 39 return cues.sort((a, b) => a.start - b.start); 40 } 41 42 /** The cue that is on at `t` (seconds on the clock of the subtitles), or null. */ 43 function cueAt(cues, t) { 44 let lo = 0, hi = cues.length - 1, found = -1; 45 while (lo <= hi) { // the last cue that starts at or before t 46 const mid = (lo + hi) >> 1; 47 if (cues[mid].start <= t) { found = mid; lo = mid + 1; } else hi = mid - 1; 48 } 49 // Cues can overlap a little; the latest one that is still on wins. 50 for (let i = found; i >= 0 && i > found - 4; i--) if (t < cues[i].end) return cues[i]; 51 return null; 52 } 53 54 const subtitleTime = (videoT, offset, rate) => (videoT - offset) * rate; 55 56 // The inverse: a moment on the clock of the subtitles, back to a moment of the video. 57 // Used by the transcript panel to seek: videoTime(subtitleTime(t, offset, rate), offset, rate) is t. 58 const videoTime = (subTime, offset, rate) => subTime / rate + offset; 59 60 const api = { parse, cueAt, subtitleTime, videoTime, seconds }; 61 if (typeof module !== 'undefined' && module.exports) module.exports = api; 62 else root.SubReadSrt = api; 63 })(typeof globalThis !== 'undefined' ? globalThis : this);