Setting the file. One moment.
Contrast Report · Hyperframes Creative · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page 22.10
Narration
440
function buildOverlaySVG
— line 440
This file
Number 22.17
Position 17 of 72
Type JavaScript
Size 21 KB
Lines 538 scripts/ contrast-report.mjs
JavaScript · 538 lines · 21 KB
15 // Env:
16 // HYPERFRAMES_SKILL_PKG_VERSION — pin the @hyperframes/producer version used
17 // when bootstrapping (global skill installs cannot infer it; falls back to
18 // @latest with a warning otherwise).
19 //
20 // The composition directory must contain an index.html. Raw authoring HTML
21 // works — the producer's file server auto-injects the runtime at serve time.
22 // Exits 1 if any text element fails WCAG AA.
23 //
24 // Background sampling: each sample time is captured TWICE — once via the
25 // producer's normal (video-accurate) captureFrameToBuffer for the overlay
26 // image, and once via a plain page.screenshot() taken right after hiding
27 // every candidate element's own text paint (color/fill → transparent,
28 // layout-neutral). The second capture reveals the REAL composited pixels
29 // that were directly behind the glyphs, which this script then samples
30 // straight from each element's own bbox — no proximity heuristic needed.
31 // This is deliberately NOT routed through captureFrameToBuffer: that
32 // pipeline has a static-frame dedup cache keyed by frame index/time that
33 // knows nothing about our DOM mutation and would happily hand back a
34 // cached pre-mutation buffer. A direct page.screenshot() bypasses that
35 // entirely and is the same technique validated in
36 // packages/cli/src/commands/contrast-audit.browser.js.
37 //
38 // The previous approach sampled a 4px ring just OUTSIDE the bbox, which
39 // breaks down whenever what's immediately outside the text differs from
40 // what's actually behind it: a neighboring panel/component just past the
41 // text's edge, a backdrop-filter-blurred glass panel sized only a couple
42 // pixels larger than the text, or a translucent decoration that only
43 // partially overlaps the ring (or sits entirely inside the bbox, never
44 // touching the ring at all).
45
46 import { mkdir, writeFile } from "node:fs/promises" ;
47 import { resolve } from "node:path" ;
48 import {
49 bundleCompositionForCapture,
50 hyperframesPackageSpec,
51 importPackagesOrBootstrap,
52 initializeSessionWithRetry,
53 } from "./package-loader.mjs" ;
54
55 // Bundle first so mounted sub-compositions are inlined before the producer's
56 // file server injects the HyperFrames runtime and render-seek bridge.
57 const packages = await importPackagesOrBootstrap (
58 [ "@hyperframes/producer" , "@hyperframes/core" , "@hyperframes/core/compiler" , "sharp" ],
59 {
60 npmPackages: [
61 hyperframesPackageSpec ( "@hyperframes/producer" ),
62 hyperframesPackageSpec ( "@hyperframes/core" ),
63 "sharp@0.34.5" ,
64 ],
65 },
66 );
67 const sharp = packages.sharp.default;
68 const { parseFps } = packages[ "@hyperframes/core" ];
69 const {
70 createFileServer ,
71 createCaptureSession ,
72 closeCaptureSession ,
73 captureFrameToBuffer ,
74 getCompositionDuration ,
75 } = packages[ "@hyperframes/producer" ];
76
77 // ─── CLI ─────────────────────────────────────────────────────────────────────
78
79 const args = parseArgs (process.argv. slice ( 2 ));
80 if ( ! args.composition) die ( "missing <composition-dir>" );
81
82 const SAMPLES = Number (args.samples ?? 10 );
83 const OUT_DIR = resolve (args.out ?? ".hyperframes/contrast" );
84 const WIDTH = Number (args.width ?? 1920 );
85 const HEIGHT = Number (args.height ?? 1080 );
86 const parsedFps = parseFps (args.fps ?? 30 );
87 if ( ! parsedFps.ok) die ( `Invalid --fps "${ args . fps ?? ""}": ${ parsedFps . reason }` );
88 const FPS = parsedFps.value;
89 const COMP_DIR = resolve (args.composition);
90
91 // ─── Main ────────────────────────────────────────────────────────────────────
92
93 await mkdir ( OUT_DIR , { recursive: true });
94
95 const bundle = await bundleCompositionForCapture (packages[ "@hyperframes/core/compiler" ], COMP_DIR );
96 let server;
97 let session;
98 try {
99 server = await createFileServer ({
100 projectDir: COMP_DIR ,
101 compiledDir: bundle.compiledDir,
102 port: 0 ,
103 });
104 // Canonical transient-init retry/cleanup (mirrors the render pipeline's
105 // probeStage) — same reasoning as animation-map.mjs: don't false-fail a
106 // valid modular project whose sub-composition timelines land a beat late.
107 session = await initializeSessionWithRetry (
108 packages[ "@hyperframes/producer" ],
109 () =>
110 createCaptureSession (
111 server.url,
112 OUT_DIR ,
113 { width: WIDTH , height: HEIGHT , fps: FPS , format: "png" },
114 null ,
115 ),
116 { log : ( message ) => console. error ( `contrast-report: ${ message }` ) },
117 );
118
119 const duration = await getCompositionDuration (session);
120 const times = Array. from (
121 { length: SAMPLES },
122 ( _ , i ) => + (((i + 0.5 ) / SAMPLES ) * duration). toFixed ( 3 ),
123 );
124
125 const allEntries = [];
126 const overlayFrames = [];
127
128 for ( let i = 0 ; i < times. length ; i ++ ) {
129 const t = times[i];
130 // Visible frame — used only for the human-facing overlay image.
131 const { buffer : pngBuf } = await captureFrameToBuffer (session, i, t);
132
133 // Hides each candidate's own text paint and returns its selector/fg/bbox.
134 const candidates = await prepareTextElements (session);
135 let elements;
136 try {
137 // Deliberately session.page.screenshot(), not captureFrameToBuffer —
138 // see the header comment for why.
139 const hiddenB64 = await session.page. screenshot ({ encoding: "base64" , type: "png" });
140 elements = await measureAgainstHiddenTextFrame (hiddenB64, candidates);
141 } finally {
142 await restoreTextElements (session);
143 }
144
145 const annotated = await annotateFrame (pngBuf, elements);
146 overlayFrames. push ({ t, png: annotated });
147 for ( const el of elements) allEntries. push ({ time: t, ... el });
148 }
149
150 const report = {
151 composition: COMP_DIR ,
152 width: WIDTH ,
153 height: HEIGHT ,
154 duration,
155 samples: times,
156 entries: allEntries,
157 summary: summarize (allEntries),
158 };
159
160 await writeFile ( resolve ( OUT_DIR , "contrast-report.json" ), JSON . stringify (report, null , 2 ));
161 await writeOverlaySprite (overlayFrames, resolve ( OUT_DIR , "contrast-overlay.png" ));
162
163 printSummary (report);
164 process.exitCode = report.summary.failAA > 0 ? 1 : 0 ;
165 } finally {
166 if (session) await closeCaptureSession (session). catch (() => {});
167 server?. close ();
168 bundle. cleanup ();
169 }
170
171 // ─── DOM probe + text-hide (runs in the page) ────────────────────────────────
172
173 // Walks the DOM for text-bearing elements, computes each one's foreground
174 // paint, and hides that element's own text (color/fill → transparent,
175 // !important, layout-neutral) so the caller's next screenshot reveals the
176 // real pixels behind the glyphs. Returns the candidate list; call
177 // restoreTextElements() afterward (in a finally) to undo the hide.
178 async function prepareTextElements ( session ) {
179 return await session.page. evaluate (() => {
180 /** @type {Array<{selector: string, text: string, fg: [number,number,number,number], fontSize: number, fontWeight: number, bbox: {x:number,y:number,w:number,h:number}}>} */
181 const out = [];
182 const restores = [];
183 // Registered BEFORE the walk starts and pushed to incrementally as each
184 // element is hidden: if something in the walk throws partway through,
185 // everything hidden so far is still reachable for restore instead of
186 // leaking hidden indefinitely.
187 window.__contrastReportRestores = restores;
188 const walker = document. createTreeWalker (document.body, NodeFilter. SHOW_ELEMENT );
189 const parseColor = ( c ) => {
190 const m = c. match ( /rgba ? \( ( [ ^ )] + ) \) / );
191 if ( ! m) return [ 0 , 0 , 0 , 1 ];
192 const parts = m[ 1 ]. split ( "," ). map (( s ) => parseFloat (s. trim ()));
193 return [parts[ 0 ], parts[ 1 ], parts[ 2 ], parts[ 3 ] ?? 1 ];
194 };
195 // Like parseColor, but returns null instead of defaulting to black when
196 // the value isn't a solid rgb()/rgba() color — e.g. SVG paint keywords
197 // such as "none"/"context-fill", or a gradient/pattern reference like
198 // 'url("#grad")'. Callers should fall back to another source of truth
199 // rather than trust a fabricated black.
200 const tryParseSolidColor = ( c ) => {
201 const m = c. match ( /rgba ? \( ( [ ^ )] + ) \) / );
202 if ( ! m) return null ;
203 const parts = m[ 1 ]. split ( "," ). map (( s ) => parseFloat (s. trim ()));
204 if (parts. some (( v ) => Number. isNaN (v))) return null ;
205 return [parts[ 0 ], parts[ 1 ], parts[ 2 ], parts[ 3 ] ?? 1 ];
206 };
207 // SVG text (<text>, <tspan>, <textPath>) is painted via the `fill`
208 // property, not `color` — the two are independent CSS properties in
209 // SVG. A page can set `fill` without ever touching `color`, in which
210 // case getComputedStyle(el).color resolves to the inherited/initial
211 // value (often black) and does not reflect what's actually rendered.
212 const isSvgTextElement = ( el ) => !! el.ownerSVGElement;
213 const selectorOf = ( el ) => {
214 if (el.id) return `#${ el . id }` ;
215 const cls = [ ... el.classList]. slice ( 0 , 2 ). join ( "." );
216 return cls ? `${ el . tagName . toLowerCase () }.${ cls }` : el.tagName. toLowerCase ();
217 };
218 let el;
219 while ((el = walker. nextNode ())) {
220 // must have direct text
221 const direct = [ ... el.childNodes]. some (
222 ( n ) => n.nodeType === 3 && n.textContent. trim (). length ,
223 );
224 if ( ! direct) continue ;
225 const cs = getComputedStyle (el);
226 if (cs.visibility === "hidden" || cs.display === "none" ) continue ;
227 if ( parseFloat (cs.opacity) <= 0.01 ) continue ;
228 const rect = el. getBoundingClientRect ();
229 if (rect.width < 8 || rect.height < 8 ) continue ;
230 const isSvgText = isSvgTextElement (el);
231 const fg = isSvgText
232 ? tryParseSolidColor (cs.fill) || parseColor (cs.color)
233 : parseColor (cs.color);
234 if (fg[ 3 ] <= 0.01 ) continue ;
235 const strokeWidth = parseFloat (cs.webkitTextStrokeWidth || "0" );
236 const stroke = strokeWidth > 0 ? tryParseSolidColor (cs.webkitTextStrokeColor || "" ) : null ;
237
238 // A `transition` on color/fill would otherwise animate this hide
239 // instead of applying it instantly — the screenshot taken right after
240 // can catch a partially-transparent glyph mid-transition instead of a
241 // fully hidden one, contaminating the background sample. Force
242 // `transition: none` alongside color/fill so the hide is atomic.
243 const origTransition = el.style. getPropertyValue ( "transition" );
244 const origTransitionPriority = el.style. getPropertyPriority ( "transition" );
245 el.style. setProperty ( "transition" , "none" , "important" );
246 const origColor = el.style. getPropertyValue ( "color" );
247 const origColorPriority = el.style. getPropertyPriority ( "color" );
248 el.style. setProperty ( "color" , "transparent" , "important" );
249 let origFill = null ;
250 let origFillPriority = null ;
251 if (isSvgText) {
252 origFill = el.style. getPropertyValue ( "fill" );
253 origFillPriority = el.style. getPropertyPriority ( "fill" );
254 el.style. setProperty ( "fill" , "transparent" , "important" );
255 }
256 let origStrokeColor = null ;
257 let origStrokeColorPriority = null ;
258 if (stroke && stroke[ 3 ] > 0.01 ) {
259 origStrokeColor = el.style. getPropertyValue ( "-webkit-text-stroke-color" );
260 origStrokeColorPriority = el.style. getPropertyPriority ( "-webkit-text-stroke-color" );
261 el.style. setProperty ( "-webkit-text-stroke-color" , "transparent" , "important" );
262 }
263 restores. push ({
264 el,
265 origTransition,
266 origTransitionPriority,
267 origColor,
268 origColorPriority,
269 origFill,
270 origFillPriority,
271 origStrokeColor,
272 origStrokeColorPriority,
273 hasStroke: !! stroke && stroke[ 3 ] > 0.01 ,
274 isSvgText,
275 });
276
277 out. push ({
278 selector: selectorOf (el),
279 text: el.textContent. trim (). slice ( 0 , 60 ),
280 fg,
281 stroke: stroke && stroke[ 3 ] > 0.01 ? stroke : null ,
282 fontSize: parseFloat (cs.fontSize),
283 fontWeight: Number (cs.fontWeight) || 400 ,
284 bbox: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
285 });
286 }
287 return out;
288 });
289 }
290
291 async function restoreTextElements ( session ) {
292 await session.page. evaluate (() => {
293 const restores = window.__contrastReportRestores;
294 if ( ! restores) return ;
295 for ( const r of restores) {
296 if (r.origColor) r.el.style. setProperty ( "color" , r.origColor, r.origColorPriority);
297 else r.el.style. removeProperty ( "color" );
298 if (r.isSvgText) {
299 if (r.origFill) r.el.style. setProperty ( "fill" , r.origFill, r.origFillPriority);
300 else r.el.style. removeProperty ( "fill" );
301 }
302 if (r.hasStroke) {
303 if (r.origStrokeColor) {
304 r.el.style. setProperty (
305 "-webkit-text-stroke-color" ,
306 r.origStrokeColor,
307 r.origStrokeColorPriority,
308 );
309 } else r.el.style. removeProperty ( "-webkit-text-stroke-color" );
310 }
311 if (r.origTransition) {
312 r.el.style. setProperty ( "transition" , r.origTransition, r.origTransitionPriority);
313 } else r.el.style. removeProperty ( "transition" );
314 }
315 window.__contrastReportRestores = null ;
316 });
317 }
318
319 // ─── Pixel sampling + WCAG math ──────────────────────────────────────────────
320
321 // Samples the REAL composited background directly inside each candidate's
322 // own bbox, from a screenshot taken with every candidate's text hidden —
323 // robust to panel edges, backdrop-filter blur, and translucent decoration
324 // in ways a proximity-based ring outside the bbox isn't. Mirrors
325 // packages/cli/src/commands/contrast-sample.ts's computeSampleRect /
326 // sampleGridPoints (kept in sync, not imported — this script bootstraps
327 // npm-published packages and can't reach into the cli package's sources).
328 async function measureAgainstHiddenTextFrame ( hiddenImgBase64 , candidates ) {
329 const raw = Buffer. from (hiddenImgBase64, "base64" );
330 const img = sharp (raw);
331 const { width , height } = await img. metadata ();
332 const pixels = await img. ensureAlpha (). raw (). toBuffer ();
333 const channels = 4 ;
334
335 const measured = [];
336 for ( const c of candidates) {
337 const bg = sampleBboxMedian (pixels, width, height, channels, c.bbox);
338 if ( ! bg) continue ;
339 let fg = compositeOver (c.fg, bg); // flatten any alpha against measured bg
340 let ratio = wcagRatio (fg, bg);
341 if (c.stroke) {
342 const stroke = compositeOver (c.stroke, bg);
343 const strokeRatio = wcagRatio (stroke, bg);
344 if (strokeRatio > ratio) {
345 fg = stroke;
346 ratio = strokeRatio;
347 }
348 }
349 const large = isLargeText (c.fontSize, c.fontWeight);
350 measured. push ({
351 selector: c.selector,
352 text: c.text,
353 fg,
354 fontSize: c.fontSize,
355 fontWeight: c.fontWeight,
356 bbox: c.bbox,
357 bg,
358 ratio: + ratio. toFixed ( 2 ),
359 wcagAA: large ? ratio >= 3 : ratio >= 4.5 ,
360 wcagAALarge: ratio >= 3 ,
361 wcagAAA: large ? ratio >= 4.5 : ratio >= 7 ,
362 });
363 }
364 return measured;
365 }
366
367 async function annotateFrame ( pngBuf , elements ) {
368 const { width , height } = await sharp (pngBuf). metadata ();
369 // Draw boxes + ratio labels as an SVG overlay (sharp composite).
370 const svg = buildOverlaySVG (elements, width, height);
371 return await sharp (pngBuf)
372 . composite ([{ input: Buffer. from (svg), top: 0 , left: 0 }])
373 . png ()
374 . toBuffer ();
375 }
376
377 function sampleBboxMedian ( raw , width , height , channels , bbox ) {
378 // Sample the element's OWN box (glyphs are hidden in this frame), inset
379 // 1px on each side to dodge anti-aliased edge pixels, clamped to the
380 // frame bounds. A bounded grid, not a full scan, so a wide caption bar
381 // doesn't turn into thousands of samples.
382 const x0 = Math. max ( 0 , Math. round (bbox.x) + 1 );
383 const x1 = Math. min (width - 1 , Math. round (bbox.x + bbox.w) - 1 );
384 const y0 = Math. max ( 0 , Math. round (bbox.y) + 1 );
385 const y1 = Math. min (height - 1 , Math. round (bbox.y + bbox.h) - 1 );
386 if (x1 <= x0 || y1 <= y0) return null ;
387
388 const stepX = Math. max ( 1 , Math. floor ((x1 - x0) / 12 ));
389 const stepY = Math. max ( 1 , Math. floor ((y1 - y0) / 6 ));
390 const r = [],
391 g = [],
392 b = [];
393 for ( let y = y0; y <= y1; y += stepY) {
394 for ( let x = x0; x <= x1; x += stepX) {
395 const i = (y * width + x) * channels;
396 r. push (raw[i]);
397 g. push (raw[i + 1 ]);
398 b. push (raw[i + 2 ]);
399 }
400 }
401 if (r. length === 0 ) return null ;
402 return [ median (r), median (g), median (b), 1 ];
403 }
404
405 function median ( arr ) {
406 const s = [ ... arr]. sort (( a , b ) => a - b);
407 return s[Math. floor (s. length / 2 )];
408 }
409
410 function compositeOver ([ fr , fg , fb , fa ], [ br , bg , bb ]) {
411 return [
412 Math. round (fr * fa + br * ( 1 - fa)),
413 Math. round (fg * fa + bg * ( 1 - fa)),
414 Math. round (fb * fa + bb * ( 1 - fa)),
415 1 ,
416 ];
417 }
418
419 function relLum ([ r , g , b ]) {
420 const ch = ( v ) => {
421 const s = v / 255 ;
422 return s <= 0.03928 ? s / 12.92 : ((s + 0.055 ) / 1.055 ) ** 2.4 ;
423 };
424 return 0.2126 * ch (r) + 0.7152 * ch (g) + 0.0722 * ch (b);
425 }
426
427 function wcagRatio ( a , b ) {
428 const la = relLum (a);
429 const lb = relLum (b);
430 const [ L1 , L2 ] = la > lb ? [la, lb] : [lb, la];
431 return ( L1 + 0.05 ) / ( L2 + 0.05 );
432 }
433
434 function isLargeText ( fontSize , fontWeight ) {
435 return fontSize >= 24 || (fontSize >= 19 && fontWeight >= 700 );
436 }
437
438 // ─── Overlay rendering ───────────────────────────────────────────────────────
439
440 function buildOverlaySVG ( elements , w , h ) {
441 const rects = elements
442 . map (( el ) => {
443 const color = ! el.wcagAA ? "#ff00aa" : ! el.wcagAAA ? "#ffcc00" : "#00e08a" ;
444 const { x , y , w : bw , h : bh } = el.bbox;
445 return `
446 <rect x="${ x }" y="${ y }" width="${ bw }" height="${ bh }"
447 fill="none" stroke="${ color }" stroke-width="3"/>
448 <rect x="${ x }" y="${ y - 18 }" width="${ 48 }" height="16" fill="${ color }"/>
449 <text x="${ x + 4 }" y="${ y - 5 }" font-family="monospace" font-size="12" fill="#000">
450 ${ el . ratio . toFixed ( 1 ) }:1
451 </text>` ;
452 })
453 . join ( "" );
454 return `<svg xmlns="http://www.w3.org/2000/svg" width="${ w }" height="${ h }">${ rects }</svg>` ;
455 }
456
457 async function writeOverlaySprite ( frames , outPath ) {
458 if ( ! frames. length ) return ;
459 const cols = Math. min (frames. length , 5 );
460 const rows = Math. ceil (frames. length / cols);
461 const { width , height } = await sharp (frames[ 0 ].png). metadata ();
462 const scale = 0.25 ;
463 const cellW = Math. round (width * scale);
464 const cellH = Math. round (height * scale);
465
466 const cells = await Promise . all (
467 frames. map ( async ( f ) => ({
468 input: await sharp (f.png). resize (cellW, cellH). png (). toBuffer (),
469 time: f.t,
470 })),
471 );
472
473 const composites = cells. map (( c , i ) => ({
474 input: c.input,
475 top: Math. floor (i / cols) * cellH,
476 left: (i % cols) * cellW,
477 }));
478
479 await sharp ({
480 create: {
481 width: cols * cellW,
482 height: rows * cellH,
483 channels: 3 ,
484 background: { r: 16 , g: 16 , b: 20 },
485 },
486 })
487 . composite (composites)
488 . png ()
489 . toFile (outPath);
490 }
491
492 // ─── Summary ────────────────────────────────────────────────────────────────
493
494 function summarize ( entries ) {
495 const total = entries. length ;
496 const failAA = entries. filter (( e ) => ! e.wcagAA). length ;
497 const passAAonly = entries. filter (( e ) => e.wcagAA && ! e.wcagAAA). length ;
498 const passAAA = entries. filter (( e ) => e.wcagAAA). length ;
499 return { total, failAA, passAAonly, passAAA };
500 }
501
502 function printSummary ({ summary , entries }) {
503 const { total , failAA , passAAonly , passAAA } = summary;
504 console. log ( ` \n Contrast report: ${ total } text-element samples` );
505 console. log ( ` fail WCAG AA: ${ failAA }` );
506 console. log ( ` pass AA, not AAA: ${ passAAonly }` );
507 console. log ( ` pass AAA: ${ passAAA }` );
508 if (failAA) {
509 console. log ( " \n Failures:" );
510 for ( const e of entries. filter (( x ) => ! x.wcagAA)) {
511 console. log ( ` t=${ e . time }s ${ e . selector . padEnd ( 24 ) } ${ e . ratio . toFixed ( 2 ) }:1 "${ e . text }"` );
512 }
513 }
514 }
515
516 // ─── Utilities ──────────────────────────────────────────────────────────────
517
518 function parseArgs ( argv ) {
519 const out = {};
520 let positional = 0 ;
521 for ( let i = 0 ; i < argv. length ; i ++ ) {
522 const a = argv[i];
523 if (a. startsWith ( "--" )) {
524 const k = a. slice ( 2 );
525 const v = argv[i + 1 ]?. startsWith ( "--" ) ? true : argv[ ++ i];
526 out[k] = v;
527 } else if (positional === 0 ) {
528 out.composition = a;
529 positional ++ ;
530 }
531 }
532 return out;
533 }
534
535 function die ( msg ) {
536 console. error ( `contrast-report: ${ msg }` );
537 process. exit ( 2 );
538 }