Setting the file. One moment.
Captions Test · PR To Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page scripts/captions.test.mjs
scripts/ captions.test.mjs
JavaScript · 260 lines · 11 KB
from
"node:url"
;
8 import { brandFontFaces, buildFromSkin } from "./captions.mjs" ;
9
10 const presetsDir = fileURLToPath (
11 new URL ( "../../hyperframes-creative/frame-presets/" , import . meta .url),
12 );
13 const skins = readdirSync (presetsDir, { withFileTypes: true })
14 . filter (( entry ) => entry. isDirectory ())
15 . map (( entry ) => ({
16 name: entry.name,
17 source: readFileSync (
18 new URL (
19 `../../hyperframes-creative/frame-presets/${ entry . name }/caption-skin.html` ,
20 import . meta .url,
21 ),
22 "utf8" ,
23 ),
24 }))
25 . filter (({ source }) => source. includes ( ".caption-word.is-active" ));
26
27 for ( const canvas of [ "#f7f3e8" , "#111827" ]) {
28 for ( const skin of skins) {
29 test ( `${ skin . name } preserves word-state rules on ${ canvas }` , () => {
30 const active = skin.source. match ( / \. caption-word \. is-active \s * \{ [ ^ }] * \} / s )?.[ 0 ];
31 const spoken = skin.source. match ( / \. caption-word \. is-spoken \s * \{ [ ^ }] * \} / s )?.[ 0 ];
32 assert. ok (active, "skin must define an active-word rule" );
33 assert. ok (spoken, "skin must define a spoken-word rule" );
34
35 const output = buildFromSkin (
36 skin.source,
37 [],
38 1 ,
39 1920 ,
40 1080 ,
41 `:root { --cap-canvas: ${ canvas }; --cap-ink: #111111; --cap-accent: #ffcc00; }` ,
42 ( message ) => {
43 throw new Error (message);
44 },
45 );
46
47 assert. ok (output. includes (active));
48 assert. ok (output. includes (spoken));
49 assert. equal (output. match ( / \. caption-word \. is-active \s * \{ / g )?. length , 1 );
50 assert. equal (output. match ( / \. caption-word \. is-spoken \s * \{ / g )?. length , 1 );
51 });
52 }
53 }
54
55 // @font-face is document-global on purpose — the composition CSS scoper exempts it, and it
56 // has to, since a face declaration cannot be scoped. That makes brandFontFaces the one part
57 // of the captions sub-composition whose output reaches every sibling composition, so it has
58 // to describe each face exactly: get an axis wrong and the whole document renders the brand
59 // family wrong.
60 function withFontProject ( files , run ) {
61 const dir = mkdtempSync ( join ( tmpdir (), "hf-captions-fonts-" ));
62 try {
63 mkdirSync ( join (dir, "assets/fonts" ), { recursive: true });
64 for ( const name of files) writeFileSync ( join (dir, "assets/fonts" , name), "" );
65 writeFileSync (
66 join (dir, "frame.md" ),
67 'typography: \n display: { fontFamily: "Newsreader", weight: 400 } \n body: { fontFamily: "Inter", weight: 400 } \n ' ,
68 );
69 return run ( join (dir, "frame.md" ), dir);
70 } finally {
71 rmSync (dir, { recursive: true , force: true });
72 }
73 }
74
75 test ( "an italic file is declared italic, and never squats the family's upright slot" , () => {
76 // Google Fonts' own Newsreader download. The italic sorts first, so before the style axis
77 // existed it claimed the family's only 400 slot, the upright was dropped as a duplicate,
78 // and the face shipped with no font-style — italicizing every sibling composition that
79 // used Newsreader.
80 const faces = withFontProject (
81 [ "Newsreader-Italic-VariableFont_opsz,wght.ttf" , "Newsreader-VariableFont_opsz,wght.ttf" ],
82 brandFontFaces,
83 );
84 const lines = faces. split ( " \n " ). filter (( line ) => line. includes ( "Newsreader" ));
85 assert. equal (lines. length , 2 , "both the upright and the italic file must be declared" );
86
87 const upright = lines. find (( line ) => line. includes ( "Newsreader-VariableFont" ));
88 const italic = lines. find (( line ) => line. includes ( "Newsreader-Italic-VariableFont" ));
89 assert. ok (upright, "the upright file must survive" );
90 assert. match (upright, /font-style: normal/ );
91 assert. ok (italic, "the italic file must survive" );
92 assert. match (italic, /font-style: italic/ );
93 });
94
95 test ( "a numeric weight in the filename is read as the weight" , () => {
96 // Fontsource names every face numerically and carries no weight WORD, so word-only
97 // parsing scored the whole family 400 and shipped exactly one of its four faces.
98 const faces = withFontProject (
99 [
100 "inter-latin-400-normal.woff2" ,
101 "inter-latin-500-normal.woff2" ,
102 "inter-latin-600-italic.woff2" ,
103 "inter-latin-700-normal.woff2" ,
104 ],
105 brandFontFaces,
106 );
107 const lines = faces. split ( " \n " ). filter (( line ) => line. includes ( "Inter" ));
108 assert. equal (lines. length , 4 , "each face is a distinct weight/style pair" );
109 for ( const [ file , weight , style ] of [
110 [ "inter-latin-400-normal" , 400 , "normal" ],
111 [ "inter-latin-500-normal" , 500 , "normal" ],
112 [ "inter-latin-600-italic" , 600 , "italic" ],
113 [ "inter-latin-700-normal" , 700 , "normal" ],
114 ]) {
115 const line = lines. find (( candidate ) => candidate. includes (file));
116 assert. ok (line, `${ file } must be declared` );
117 assert. match (line, new RegExp ( `font-weight: ${ weight };` ));
118 assert. match (line, new RegExp ( `font-style: ${ style };` ));
119 }
120 });
121
122 test ( "word-named weights still parse when the filename carries no numeric axis" , () => {
123 const faces = withFontProject (
124 [ "Newsreader_Bold.woff2" , "Newsreader_Regular.woff2" ],
125 brandFontFaces,
126 );
127 const lines = faces. split ( " \n " ). filter (( line ) => line. includes ( "Newsreader" ));
128 assert. equal (lines. length , 2 );
129 assert. match (
130 lines. find (( line ) => line. includes ( "Bold" )),
131 /font-weight: 700; font-style: normal/ ,
132 );
133 assert. match (
134 lines. find (( line ) => line. includes ( "Regular" )),
135 /font-weight: 400; font-style: normal/ ,
136 );
137 });
138
139 // build-frame.mjs stages captured brand fonts under a REWRITTEN name, and brandFontFaces
140 // derives the face's axes back out of that name. The two are a contract, and it is easy to
141 // break silently from either side: build-frame used to drop the style token while renaming,
142 // so an italic file arrived as "Newsreader-Regular.ttf" and was declared upright — leaving
143 // the document-global normal slot pointing at italic bytes even once brandFontFaces learned
144 // about styles. These two tests pin both ends of that contract.
145 test ( "the names build-frame.mjs stages round-trip back to the right face" , () => {
146 const faces = withFontProject (
147 [
148 "Newsreader-Regular.ttf" ,
149 "Newsreader-Regular-Italic.ttf" ,
150 "Inter-400.woff2" ,
151 "Inter-600-Italic.woff2" ,
152 ],
153 brandFontFaces,
154 );
155 for ( const [ file , weight , style ] of [
156 [ "Newsreader-Regular.ttf" , 400 , "normal" ],
157 [ "Newsreader-Regular-Italic.ttf" , 400 , "italic" ],
158 [ "Inter-400.woff2" , 400 , "normal" ],
159 [ "Inter-600-Italic.woff2" , 600 , "italic" ],
160 ]) {
161 const line = faces. split ( " \n " ). find (( candidate ) => candidate. includes ( `/${ file }'` ));
162 assert. ok (line, `${ file } must be declared` );
163 assert. match (line, new RegExp ( `font-weight: ${ weight };` ));
164 assert. match (line, new RegExp ( `font-style: ${ style };` ));
165 }
166 });
167
168 test ( "a weight token buried in a longer run is not read as a weight" , () => {
169 // capture/assets/fonts commonly holds hash-named files, and a hash is not a weight.
170 const faces = withFontProject (
171 [ "Newsreader-a1b200c3.woff2" , "Inter-2100.woff2" , "Inter900.woff2" ],
172 brandFontFaces,
173 );
174 const weightOf = ( file ) =>
175 Number ( /font-weight: ( \d + );/ . exec (faces. split ( " \n " ). find (( l ) => l. includes (file)))?.[ 1 ]);
176
177 // "200" sits mid-run (…b200c3), so the word path decides: Regular.
178 assert. equal ( weightOf ( "Newsreader-a1b200c3.woff2" ), 400 );
179 // 4-digit guard: "2100" must not read as 100.
180 assert. equal ( weightOf ( "Inter-2100.woff2" ), 400 );
181 // ...but a trailing weight with no separator is still a weight.
182 assert. equal ( weightOf ( "Inter900.woff2" ), 900 );
183 });
184
185 // captions.mjs ships once per creation workflow because each skill installs standalone,
186 // and the three copies are meant to be byte-identical. This PR alone had to land the same
187 // two-axis fix in all three; a future one that lands in only one drifts silently.
188 test ( "captions.mjs is byte-identical across the three workflows that ship it" , () => {
189 const [ first , ... rest ] = [ "product-launch-video" , "faceless-explainer" , "pr-to-video" ]. map (
190 ( skill ) => ({
191 skill,
192 source: readFileSync ( new URL ( `../../${ skill }/scripts/captions.mjs` , import . meta .url), "utf8" ),
193 }),
194 );
195 for ( const other of rest) {
196 assert. equal (other.source, first.source, `${ other . skill } drifted from ${ first . skill }` );
197 }
198 });
199
200 test ( "every build-frame.mjs copy stages the style axis it promises" , () => {
201 for ( const skill of [ "product-launch-video" , "faceless-explainer" , "pr-to-video" ]) {
202 const source = readFileSync (
203 new URL ( `../../${ skill }/scripts/build-frame.mjs` , import . meta .url),
204 "utf8" ,
205 );
206 // The staged filename must carry the style, or the italic and upright faces of one
207 // weight collide on a single name and only whichever sorts first survives.
208 assert. match (
209 source,
210 /const clean = ` \$\{ fam \. replace \(\/\[\^ A-Za-z0-9 \]\/ g, "" \)\} - \$\{ w \}\$\{ style === "italic" \? "-Italic" : "" \}\. / ,
211 `${ skill }/build-frame.mjs must keep the style token in the staged name` ,
212 );
213 // ...and the emitted descriptor must report the real style, not a hardcoded normal.
214 assert. doesNotMatch (
215 source,
216 /font-weight: \$\{ n \} ;font-style:normal/ ,
217 `${ skill }/build-frame.mjs must not assert font-style:normal over captured bytes` ,
218 );
219 }
220 });
221
222 for ( const workflow of [ "pr-to-video" , "faceless-explainer" , "product-launch-video" ]) {
223 for ( const skin of [ null , skins[ 0 ]]) {
224 test ( `${ workflow } ${ skin ? "skin" : "default"} keeps transcript markup inside script data` , () => {
225 const project = mkdtempSync ( join ( tmpdir (), "caption-script-data-" ));
226 const text = '</ScRiPt><img src=x onerror="alert(1)"><!-- <script> & end' ;
227 try {
228 writeFileSync (
229 join (project, "STORYBOARD.md" ),
230 "--- \n format: 1920x1080 \n message: T \n --- \n\n ## Frame 1 — A \n - duration: 3s \n - src: compositions/frames/01-a.html \n " ,
231 );
232 writeFileSync (
233 join (project, "audio_meta.json" ),
234 JSON . stringify ({
235 voices: [{ frame: 1 , words: [{ text, start: 0 , end: 1 }] }],
236 }),
237 );
238 if (skin) writeFileSync ( join (project, "caption-skin.html" ), skin.source);
239 const script = fileURLToPath (
240 new URL ( `../../${ workflow }/scripts/captions.mjs` , import . meta .url),
241 );
242 execFileSync (process.execPath, [script, "build" , "--hyperframes" , project]);
243 const html = readFileSync ( join (project, "compositions/captions.html" ), "utf8" );
244 const start = html. indexOf ( "var GROUPS =" );
245 assert. ok (start >= 0 , "generated caption data exists" );
246 const end = html. toLowerCase (). indexOf ( "</script" , start);
247 assert. ok (end > start, "generated script has a closing tag" );
248 const inline = html. slice (start, end);
249 assert. ok ( ! html. includes ( "<img" ), "transcript cannot create an HTML element" );
250 const data = inline. match ( /var GROUPS = ( . * );/ );
251 assert. ok (data, "entire data assignment remains inside the script element" );
252 const groups = JSON . parse (data[ 1 ]);
253 assert. equal (groups[ 0 ].text, text);
254 assert. equal (groups[ 0 ].words[ 0 ].text, text);
255 } finally {
256 rmSync (project, { recursive: true , force: true });
257 }
258 });
259 }
260 }