Setting the file. One moment.
Check Occlusion · Embedded Captions · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page 14.42
Hershey Script1
Next
Script Check Overflow
scripts/ check-occlusion.cjs
JavaScript · 250 lines · 10 KB
);
10 const cp = require ( "child_process" );
11
12 function hfResolve ( pkg ) {
13 const roots = [
14 process.env. HYPERFRAMES_ROOT ,
15 path. resolve (__dirname, ".." , ".." , ".." ),
16 path. join (os. homedir (), "Downloads" , "hyperframes" ),
17 ]. filter (Boolean);
18 for ( const root of roots) {
19 const cands = [path. join (root, "node_modules" , pkg)];
20 const bun = path. join (root, "node_modules" , ".bun" );
21 try {
22 if (fs. existsSync (bun))
23 for ( const d of fs. readdirSync (bun))
24 if (d. startsWith (pkg + "@" )) cands. push (path. join (bun, d, "node_modules" , pkg));
25 } catch {}
26 for ( const c of cands) {
27 try {
28 if (fs. existsSync (c)) return require (c);
29 } catch {}
30 }
31 }
32 console. error ( `[v2] cannot find ${ pkg } — set HYPERFRAMES_ROOT` );
33 process. exit ( 3 );
34 }
35 const sharp = hfResolve ( "sharp" );
36
37 function ensureLayoutMeasured ( project , force ) {
38 const lp = path. join (project, "_layout.json" ),
39 idx = path. join (project, "index.html" );
40 let stale = force || ! fs. existsSync (lp);
41 if ( ! stale && fs. existsSync (idx) && fs. statSync (idx).mtimeMs > fs. statSync (lp).mtimeMs)
42 stale = true ;
43 if (stale)
44 cp. execFileSync ( "node" , [path. join (__dirname, "measure-layout.cjs" ), project], {
45 stdio: "inherit" ,
46 });
47 return JSON . parse (fs. readFileSync (lp, "utf8" ));
48 }
49 async function loadAlphaMask ( png ) {
50 if ( ! fs. existsSync (png)) return null ;
51 const { data , info } = await sharp (png)
52 . ensureAlpha ()
53 . extractChannel ( 3 )
54 . raw ()
55 . toBuffer ({ resolveWithObject: true });
56 return { data, W: info.width, H: info.height };
57 }
58 function occlusionForRect ( m , x , y , w , h ) {
59 if ( ! m) return 0 ;
60 const x0 = Math. max ( 0 , Math. round (x)),
61 y0 = Math. max ( 0 , Math. round (y));
62 const x1 = Math. min (m. W , Math. round (x + w)),
63 y1 = Math. min (m. H , Math. round (y + h));
64 if (x1 <= x0 || y1 <= y0) return 0 ;
65 let cnt = 0 ,
66 tot = 0 ;
67 for ( let yy = y0; yy < y1; yy ++ ) {
68 const row = yy * m. W ;
69 for ( let xx = x0; xx < x1; xx ++ ) {
70 tot ++ ;
71 if (m.data[row + xx] > 128 ) cnt ++ ;
72 }
73 }
74 return tot ? cnt / tot : 0 ;
75 }
76 function argf ( name , d ) {
77 const i = process.argv. indexOf (name);
78 return i >= 0 ? parseFloat (process.argv[i + 1 ]) : d;
79 }
80
81 async function main () {
82 const project = path. resolve (process.argv[ 2 ] || "" );
83 if ( ! process.argv[ 2 ]) {
84 console. error ( "usage: check-occlusion.cjs <project-dir> [--strict]" );
85 process. exit ( 1 );
86 }
87 const strict = process.argv. includes ( "--strict" );
88 const wordFail = argf ( "--word-fail" , 0.65 ),
89 wordWarn = argf ( "--word-warn" , 0.35 ),
90 capFail = argf ( "--cap-fail" , 0.5 );
91 const layout = ensureLayoutMeasured (project, process.argv. includes ( "--remeasure" ));
92 const framesDir = path. join (project, "frames_fg" );
93 if ( ! fs. existsSync (framesDir)) {
94 console. error ( `[v2] missing ${ framesDir }` );
95 process. exit ( 2 );
96 }
97 const planPath = path. join (project, "plan.json" );
98 const plan = fs. existsSync (planPath) ? JSON . parse (fs. readFileSync (planPath, "utf8" )) : {};
99 const planLayer = plan.caption_layer || "bg" ;
100 // Hero groups (the ONE big promoted word) are SUPPOSED to sit ON the subject — for them
101 // occlusion is a TARGET (~30–55%), not "minimize". Collect their ids for the advisory below.
102 const heroIds = new Set ();
103 const heroIn = {};
104 for ( const g of plan.groups || [])
105 if (g && (g.hero === true || / ^ (hero | crown) $ / i . test (g.plane || "" ))) {
106 heroIds. add (g.id);
107 heroIn[g.id] = g.in;
108 }
109 if (plan.crown_group && plan.crown_group.id) {
110 heroIds. add (plan.crown_group.id);
111 heroIn[plan.crown_group.id] = plan.crown_group.in;
112 }
113 const M = 2 ; // frame-edge tolerance (px) — matches check-overflow.cjs
114 const frameW = layout.width,
115 frameH = layout.height;
116
117 const capStats = {};
118 for ( const sample of layout.samples) {
119 const png = path. join (framesDir, `f_${ String ( sample . frame_idx ). padStart ( 4 , "0" ) }.png` );
120 const mask = await loadAlphaMask (png);
121 if ( ! mask) continue ;
122 for ( const cap of sample.caps) {
123 const entry = (capStats[cap.id] ||= { layer: cap.layer || planLayer, samples: [] });
124 const wordsData = [];
125 for ( const w of cap.words || []) {
126 if ((w.opacity ?? 1 ) < 0.3 ) continue ;
127 wordsData. push ({ text: w.text, occlusion: occlusionForRect (mask, w.x, w.y, w.w, w.h) });
128 // Frame-edge overflow — clipped SETTLED text is always wrong; a hero's first
129 // 0.5s is its entrance TRANSIENT (slam over-scale, streak fly-in pass through
130 // off-frame states by design) — judge overflow on the hold, not mid-flight.
131 if (heroIds. has (cap.id) && heroIn[cap.id] != null && sample.t < heroIn[cap.id] + 0.5 )
132 continue ;
133 const off = {
134 left: Math. max ( 0 , Math. round ( - w.x - M )),
135 right: Math. max ( 0 , Math. round (w.x + w.w - frameW - M )),
136 top: Math. max ( 0 , Math. round ( - w.y - M )),
137 bottom: Math. max ( 0 , Math. round (w.y + w.h - frameH - M )),
138 };
139 const score = off.left + off.right + off.top + off.bottom;
140 if (score > 0 && ( ! entry.overflow || score > entry.overflow.score))
141 entry.overflow = { text: w.text, off, score, t: sample.t };
142 }
143 const capOccl = occlusionForRect (
144 mask,
145 cap.cap_bbox.x,
146 cap.cap_bbox.y,
147 cap.cap_bbox.w,
148 cap.cap_bbox.h,
149 );
150 entry.samples. push ({
151 t: sample.t,
152 cap_occl: capOccl,
153 cap_bbox: cap.cap_bbox,
154 words: wordsData,
155 });
156 }
157 }
158
159 const failures = [];
160 console. log (
161 `[v2] ${ path . basename ( project ) } word-fail≥${ ( wordFail * 100 ). toFixed ( 0 ) }% cap-fail≥${ ( capFail * 100 ). toFixed ( 0 ) }%` ,
162 );
163 for ( const [ gid , entry ] of Object. entries (capStats)) {
164 // Frame-edge overflow applies to every layer (fg too). The skill allows the
165 // climax a few-px graze on the first/last letter, so only a clear glyph clip
166 // (>8px past an edge) is a hard FAIL; a sub-glyph graze is a WARN.
167 if (entry.overflow) {
168 const o = entry.overflow;
169 const maxOff = Math. max (o.off.left, o.off.right, o.off.top, o.off.bottom);
170 const sides = Object. entries (o.off)
171 . filter (([, v ]) => v > 0 )
172 . map (([ s , v ]) => `${ s } ${ v }px` )
173 . join ( ", " );
174 const hard = maxOff >= 8 ;
175 console. log (
176 ` ${ gid } [overflow${ hard ? "" : "-warn"}] "${ o . text }" off-frame: ${ sides } (@${ o . t }s)` +
177 (hard ? " — cropped text is always wrong" : " (graze — within climax tolerance)" ),
178 );
179 if (hard && ! failures. includes (gid)) failures. push (gid);
180 }
181 if (entry.layer === "fg" ) {
182 console. log ( ` ${ gid } fg (occlusion skipped — fg renders above matte)` );
183 continue ;
184 }
185 const capOccls = entry.samples. map (( s ) => s.cap_occl);
186 const avgCap = capOccls. length ? capOccls. reduce (( a , b ) => a + b, 0 ) / capOccls. length : 0 ;
187 const peakCap = capOccls. length ? Math. max ( ... capOccls) : 0 ;
188 const wordPeaks = {};
189 for ( const s of entry.samples)
190 for ( const wd of s.words)
191 wordPeaks[wd.text] = Math. max (wordPeaks[wd.text] || 0 , wd.occlusion);
192 const oblit = Object. entries (wordPeaks). filter (([, p ]) => p >= wordFail);
193 const warn = Object. entries (wordPeaks). filter (([, p ]) => p >= wordWarn && p < wordFail);
194 let status = "OK" ;
195 // HERO caps WANT occlusion (~30–55% is the embed); the generic cap threshold would
196 // fail a working hero. Heroes fail only past the feasibility ceiling (68%).
197 const capLimit = heroIds. has (gid) ? Math. max (capFail, 0.68 ) : capFail;
198 if (oblit. length || peakCap >= capLimit) {
199 status = "FAIL" ;
200 failures. push (gid);
201 } else if (warn. length ) status = "WARN" ;
202 let s = "" ;
203 if (oblit. length )
204 s =
205 oblit
206 . slice ( 0 , 5 )
207 . map (([ t , p ]) => `${ t }(${ ( p * 100 ). toFixed ( 0 ) }%)` )
208 . join ( " " ) + (oblit. length > 5 ? ` …+${ oblit . length - 5 }` : "" );
209 else if (warn. length )
210 s =
211 "[warn] " +
212 warn
213 . slice ( 0 , 3 )
214 . map (([ t , p ]) => `${ t }(${ ( p * 100 ). toFixed ( 0 ) }%)` )
215 . join ( " " );
216 console. log (
217 ` ${ gid } ${ entry . layer } avg ${ ( avgCap * 100 ). toFixed ( 0 ) }% peak ${ ( peakCap * 100 ). toFixed ( 0 ) }% ${ status } ${ s }` ,
218 );
219 // HERO target-occlusion advisory (not a failure): a hero should sit ON the subject
220 // (~30–55%). If it barely grazes, it reads as a small floating word, not an embed.
221 if (heroIds. has (gid) && peakCap < 0.15 ) {
222 // METRIC HONESTY: this advisory uses CAP-AREA occlusion, which saturates ~15%
223 // for a width-filled hero over a narrow subject (the 30–55% figure elsewhere is
224 // the safe-zones BAND metric — different denominator). If the hero already owns
225 // the width, "center it + make it BIG" is unactionable — stay quiet.
226 const widest = Math. max ( ... entry.samples. map (( sm ) => (sm.cap_bbox && sm.cap_bbox.w) || 0 ), 0 );
227 if (widest >= frameW * 0.8 ) {
228 console. log (
229 ` ${ gid } [hero-ok] peak ${ ( peakCap * 100 ). toFixed ( 0 ) }% cap-area — width-saturated hero over a narrow subject; cap-area can't reach the band target (this is the geometry, not a layout fault).` ,
230 );
231 } else {
232 console. log (
233 ` ${ gid } [hero-weak] peak ${ ( peakCap * 100 ). toFixed ( 0 ) }% — hero barely crosses the subject; it should sit ON the subject (~30–55% by the safe-zones BAND metric = the embed effect). Center it (safe-zones heroAnchor) + make it BIG; don't park it in a clean margin.` ,
234 );
235 }
236 }
237 }
238 if (failures. length ) {
239 const uniq = [ ...new Set (failures)];
240 console. error ( ` \n [v2] ${ uniq . length } cap(s) FAIL: ${ uniq . join ( ", " ) }` );
241 console. error (
242 " → occlusion: set that cap's layer to fg, OR shrink/reposition; overflow: shrink/reposition (fg won't help)" ,
243 );
244 }
245 process. exit (strict && failures. length ? 2 : 0 );
246 }
247 main (). catch (( e ) => {
248 console. error ( "[v2]" , e.message);
249 process. exit ( 1 );
250 });