Setting the file. One moment.
Align Captions · Changelog Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page Bundled file Master Skeleton
scripts/ align-captions.mjs
JavaScript · 127 lines · 4 KB
// last spoken end + tail). Fuzzy matching absorbs TTS/timestamp quirks; anything
15 // it can't absorb prints MISMATCH — resolve every one before trusting captions.
16
17 import { readFileSync, writeFileSync } from "node:fs" ;
18
19 const argv = process.argv. slice ( 2 );
20 const flag = ( n , d ) => {
21 const i = argv. indexOf ( "--" + n);
22 return i >= 0 ? argv[i + 1 ] : d;
23 };
24 const die = ( m ) => {
25 console. error ( "align-captions:" , m);
26 process. exit ( 2 );
27 };
28
29 const tokensFile = flag ( "tokens" , null ) ?? die ( "--tokens required" );
30 const wordsFile = flag ( "words" , null ) ?? die ( "--words required" );
31 const outFile = flag ( "out" , "captions.json" );
32 const tail = parseFloat ( flag ( "tail" , "0.6" ));
33
34 const script = JSON . parse ( readFileSync (tokensFile, "utf8" ));
35 const stream = JSON . parse ( readFileSync (wordsFile, "utf8" ));
36 if ( ! script.lines?. length ) die ( "tokens file has no lines[]" );
37 if ( ! stream. length ) die ( "words file is empty" );
38
39 const norm = ( s ) => s. toLowerCase (). replace ( / [ ^ a-z0-9] / g , "" );
40 const lev = ( a , b ) => {
41 if (a === b) return 0 ;
42 const m = a. length ,
43 n = b. length ;
44 if ( ! m || ! n) return Math. max (m, n);
45 let prev = Array. from ({ length: n + 1 }, ( _ , j ) => j);
46 for ( let i = 1 ; i <= m; i ++ ) {
47 const cur = [i];
48 for ( let j = 1 ; j <= n; j ++ )
49 cur[j] = Math. min (prev[j] + 1 , cur[j - 1 ] + 1 , prev[j - 1 ] + (a[i - 1 ] === b[j - 1 ] ? 0 : 1 ));
50 prev = cur;
51 }
52 return prev[n];
53 };
54 const close = ( a , b ) => {
55 if ( ! a || ! b) return false ;
56 if (a === b || a. startsWith (b) || b. startsWith (a)) return true ;
57 return lev (a, b) <= Math. max ( 1 , Math. floor (Math. min (a. length , b. length ) / 3 ));
58 };
59
60 let si = 0 ; // stream cursor
61 let mismatches = 0 ;
62 const outLines = [];
63
64 // Greedily consume stream words from `from` whose concatenated norm builds the
65 // token's full spoken norm ("hey-jen" may arrive as one word or several; "C L I"
66 // as three). Returns { start, next } or null.
67 function consume ( from , spokenNorm ) {
68 let acc = "" ,
69 start = null ,
70 k = from;
71 while (k < stream. length ) {
72 const wn = norm (stream[k].text);
73 if ( ! wn) {
74 k ++ ;
75 continue ;
76 }
77 const cand = acc + wn;
78 if (spokenNorm. startsWith (cand) || close (cand, spokenNorm)) {
79 if (start === null ) start = stream[k].start;
80 acc = cand;
81 k ++ ;
82 if ( close (acc, spokenNorm)) return { start, next: k };
83 continue ;
84 }
85 break ;
86 }
87 return acc && close (acc, spokenNorm) ? { start, next: k } : null ;
88 }
89
90 for ( const line of script.lines) {
91 const w = [];
92 for ( const tok of line.tokens) {
93 const display = typeof tok === "string" ? tok : tok.display;
94 const spoken = typeof tok === "string" ? tok : tok.spoken;
95 const spokenNorm = norm (spoken);
96 if ( ! spokenNorm) {
97 w. push ([display, si < stream. length ? stream[si].start : 0 ]);
98 continue ;
99 }
100 // try at the cursor, then resync up to 4 words ahead
101 let hit = null ;
102 for ( let off = 0 ; off <= 4 && ! hit; off ++ ) hit = consume (si + off, spokenNorm);
103 if ( ! hit) {
104 console. error (
105 `MISMATCH line=${ line . id } display="${ display }" expected~"${ spoken }" heard="${ stream [ si ]?. text ?? "<eof>"}" @${ stream [ si ]?. start ?. toFixed ( 2 ) ?? "?"}s` ,
106 );
107 mismatches ++ ;
108 w. push ([display, si < stream. length ? stream[si].start : stream. at ( - 1 ).end]);
109 continue ;
110 }
111 si = hit.next;
112 w. push ([display, + hit.start. toFixed ( 2 )]);
113 }
114 outLines. push ({ id: line.id, w });
115 }
116
117 for ( let i = 0 ; i < outLines. length ; i ++ ) {
118 outLines[i].end =
119 i + 1 < outLines. length ? outLines[i + 1 ].w[ 0 ][ 1 ] : + (stream. at ( - 1 ).end + tail). toFixed ( 2 );
120 }
121
122 writeFileSync (outFile, JSON . stringify ({ lines: outLines }, null , 1 ));
123 const status = mismatches ? `${ mismatches } MISMATCH(ES) — resolve before building` : "clean" ;
124 console. log (
125 `aligned ${ outLines . length } lines / ${ stream . length } spoken words → ${ outFile } (${ status })` ,
126 );
127 process. exit (mismatches ? 1 : 0 );