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