Setting the file. One moment.
Safe Zones · Embedded Captions · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page Hershey Script1
— line 425
This file
Number 14.39
Position 39 of 95
Type JavaScript
Size 30 KB
Lines 813 scripts/ safe-zones.cjs
JavaScript · 813 lines · 30 KB
15 * computed PER TIME WINDOW (the union over just that window's frames), not one global box.
16 */
17 const path = require ( "path" );
18 const fs = require ( "fs" );
19 const os = require ( "os" );
20
21 const THRESH = 30 / 255 ; // a cell is "subject" if ≥12% covered at any sampled frame in the window
22 const SAMPLES = 48 ; // frames cached across the clip (windows aggregate the cached grids)
23
24 const HF_ROOTS = [
25 process.env. HYPERFRAMES_ROOT ,
26 path. resolve (__dirname, "../../.." ),
27 path. join (os. homedir (), "Downloads" , "hyperframes" ),
28 ]. filter (Boolean);
29 let sharp = null ;
30 for ( const root of HF_ROOTS ) {
31 const cands = [path. join (root, "node_modules" , "sharp" )];
32 const bunDir = path. join (root, "node_modules" , ".bun" );
33 try {
34 if (fs. existsSync (bunDir))
35 for ( const d of fs. readdirSync (bunDir))
36 if (d. startsWith ( "sharp@" )) cands. push (path. join (bunDir, d, "node_modules" , "sharp" ));
37 } catch {}
38 for ( const c of cands) {
39 try {
40 if (fs. existsSync (c)) {
41 sharp = require (c);
42 break ;
43 }
44 } catch {}
45 }
46 if (sharp) break ;
47 }
48
49 // largest all-clear (1) rectangle within [c0,c1)×[r0,r1), in CELL units
50 function largestRect ( safe , GW , c0 , c1 , r0 , r1 ) {
51 const cols = c1 - c0;
52 const heights = new Array (cols). fill ( 0 );
53 let best = { area: 0 , x: 0 , y: 0 , w: 0 , h: 0 };
54 for ( let r = r0; r < r1; r ++ ) {
55 for ( let cc = 0 ; cc < cols; cc ++ ) heights[cc] = safe[r * GW + (c0 + cc)] ? heights[cc] + 1 : 0 ;
56 const st = [];
57 for ( let cc = 0 ; cc <= cols; cc ++ ) {
58 const h = cc < cols ? heights[cc] : 0 ;
59 let start = cc;
60 while (st. length && st[st. length - 1 ].h >= h) {
61 const top = st. pop ();
62 const area = top.h * (cc - top.i);
63 if (area > best.area)
64 best = { area, x: c0 + top.i, y: r - top.h + 1 , w: cc - top.i, h: top.h };
65 start = top.i;
66 }
67 st. push ({ i: start, h });
68 }
69 }
70 return best;
71 }
72
73 // turn a max-coverage grid into {coverage, subject, zones, recommendation}
74 function analyze ( occ , GW , GH , W , H , lum , lumSeries ) {
75 const occCell = new Uint8Array ( GW * GH );
76 for ( let c = 0 ; c < GW * GH ; c ++ ) occCell[c] = occ[c] >= THRESH ? 1 : 0 ;
77 const safe = new Uint8Array ( GW * GH ); // 1-cell dilation margin around the silhouette
78 for ( let y = 0 ; y < GH ; y ++ )
79 for ( let x = 0 ; x < GW ; x ++ ) {
80 let o = 0 ;
81 for ( let dy = - 1 ; dy <= 1 && ! o; dy ++ )
82 for ( let dx = - 1 ; dx <= 1 && ! o; dx ++ ) {
83 const nx = x + dx,
84 ny = y + dy;
85 if (nx >= 0 && nx < GW && ny >= 0 && ny < GH && occCell[ny * GW + nx]) o = 1 ;
86 }
87 safe[y * GW + x] = o ? 0 : 1 ;
88 }
89 const occupied = occCell. reduce (( a , b ) => a + b, 0 );
90 const coverage = occupied / ( GW * GH );
91 let colMin = GW ,
92 colMax = - 1 ,
93 rowMin = GH ,
94 rowMax = - 1 ;
95 for ( let x = 0 ; x < GW ; x ++ )
96 for ( let y = 0 ; y < GH ; y ++ )
97 if (occCell[y * GW + x]) {
98 if (x < colMin) colMin = x;
99 if (x > colMax) colMax = x;
100 if (y < rowMin) rowMin = y;
101 if (y > rowMax) rowMax = y;
102 }
103 if (colMax < 0 ) {
104 colMin = 0 ;
105 colMax = - 1 ;
106 rowMin = 0 ;
107 rowMax = - 1 ;
108 }
109 const clearerSide = colMin >= GW - 1 - colMax ? "left" : "right" ;
110 const cellW = W / GW ,
111 cellH = H / GH ;
112 // HERO anchor — where the ONE big promoted word should sit: ON the subject (centered,
113 // crossing it so the head/torso occludes the middle). The OPPOSITE of the clean zones
114 // above (those are for narration). A wide band ≈ centered on the subject, vertically
115 // crossing the head/upper torso. Only meaningful when there IS a subject.
116 let heroAnchor = null ;
117 if (colMax >= 0 ) {
118 const subjCx = ((colMin + colMax + 1 ) / 2 / GW ) * 100 ;
119 const subjWpct = ((colMax - colMin + 1 ) / GW ) * 100 ;
120 const yTop = (rowMin / GH ) * 100 ,
121 yBot = ((rowMax + 1 ) / GH ) * 100 ;
122 const wPct = Math. round (Math. min ( 92 , Math. max ( 58 , subjWpct + 26 )));
123 const xPct = Math. round (Math. min ( 98 - wPct, Math. max ( 2 , subjCx - wPct / 2 )));
124 const yPct = Math. round (yTop + (yBot - yTop) * 0.12 ); // band crosses the head / upper torso
125 let bandLuma = null ;
126 if (lum) {
127 const y0 = Math. round ((yPct / 100 ) * GH ),
128 y1 = Math. min ( GH , y0 + Math. max ( 2 , Math. round ( GH * 0.14 )));
129 const x0 = Math. round ((xPct / 100 ) * GW ),
130 x1 = Math. min ( GW , Math. round (((xPct + wPct) / 100 ) * GW ));
131 // luma of the BACKGROUND cells only — that's where the hero's glyphs are visible
132 // (the subject-occluded middle doesn't show text; averaging it in hides washout).
133 let s2 = 0 ,
134 n = 0 ;
135 for ( let y = y0; y < y1; y ++ )
136 for ( let x = x0; x < x1; x ++ ) {
137 if (occCell[y * GW + x]) continue ;
138 s2 += lum[y * GW + x];
139 n ++ ;
140 }
141 bandLuma = n ? Math. round (s2 / n) : null ;
142 }
143 heroAnchor = {
144 centerXPct: + subjCx. toFixed ( 1 ),
145 plane: { xPct, yPct, wPct, align: "center" },
146 ... (bandLuma != null ? { bandLuma, washoutRisk: bandLuma > 175 } : {}),
147 note:
148 "Place the ONE big hero here (centered on the subject); the head/torso occludes its middle (~30-55%) — that is the embed. Do NOT put the hero in a clean zone." +
149 (bandLuma != null && bandLuma > 175
150 ? " ⚠ BAND IS BRIGHT (luma " +
151 bandLuma +
152 "): cream/screen text will wash out — lower the hero onto the darker subject body, or use a template/mode with opaque text."
153 : "" ),
154 };
155 }
156 const zoneLuma = ( r ) => {
157 if ( ! lum || r.area === 0 ) return null ;
158 let s2 = 0 ,
159 n = 0 ;
160 for ( let y = r.y; y < r.y + r.h; y ++ )
161 for ( let x = r.x; x < r.x + r.w; x ++ ) {
162 s2 += lum[y * GW + x];
163 n ++ ;
164 }
165 return n ? Math. round (s2 / n) : null ;
166 };
167 // a TIME-AVERAGED map walks a moving minefield: a dark wall swept by a bright
168 // moving object (handheld drift, screens) averages "clean" while peaking hot.
169 // peakLuma = p95 of the zone's per-sample mean over the window.
170 const zoneLumaPeak = ( r ) => {
171 if ( ! lumSeries || ! lumSeries. length || r.area === 0 ) return null ;
172 const means = lumSeries
173 . map (( Lg ) => {
174 let s2 = 0 ,
175 n = 0 ;
176 for ( let y = r.y; y < r.y + r.h; y ++ )
177 for ( let x = r.x; x < r.x + r.w; x ++ ) {
178 s2 += Lg[y * GW + x];
179 n ++ ;
180 }
181 return n ? s2 / n : 0 ;
182 })
183 . sort (( a , b ) => a - b);
184 return Math. round (means[Math. max ( 0 , Math. ceil (means. length * 0.95 ) - 1 )]);
185 };
186 const toZone = ( r ) =>
187 r.area === 0
188 ? null
189 : {
190 xPct: + ((r.x / GW ) * 100 ). toFixed ( 1 ),
191 yPct: + ((r.y / GH ) * 100 ). toFixed ( 1 ),
192 wPct: + ((r.w / GW ) * 100 ). toFixed ( 1 ),
193 hPct: + ((r.h / GH ) * 100 ). toFixed ( 1 ),
194 areaPct: + (((r.w * r.h) / ( GW * GH )) * 100 ). toFixed ( 1 ),
195 px: {
196 x: Math. round (r.x * cellW),
197 y: Math. round (r.y * cellH),
198 w: Math. round (r.w * cellW),
199 h: Math. round (r.h * cellH),
200 },
201 ... ( zoneLuma (r) != null
202 ? {
203 meanLuma: zoneLuma (r),
204 bright: zoneLuma (r) > 180 ,
205 ... ( zoneLumaPeak (r) != null ? { peakLuma: zoneLumaPeak (r) } : {}),
206 }
207 : {}),
208 };
209 const zones = {
210 largest: toZone ( largestRect (safe, GW , 0 , GW , 0 , GH )),
211 left: toZone ( largestRect (safe, GW , 0 , Math. round ( GW / 2 ), 0 , GH )),
212 right: toZone ( largestRect (safe, GW , Math. round ( GW / 2 ), GW , 0 , GH )),
213 top: toZone ( largestRect (safe, GW , 0 , GW , 0 , Math. max ( 2 , Math. round ( GH * 0.38 )))),
214 };
215 // HUGGING zones — clean strips that ABUT the silhouette (the embed aesthetic wants
216 // text NEAR the subject, not parked in the farthest corner). Grown outward from the
217 // subject's edge at upper-body height; prefer these for narration.
218 const hug = ( side ) => {
219 if (colMax < 0 ) return null ;
220 const pad = Math. max ( 1 , Math. round ( GW * 0.02 ));
221 const y0 = rowMin,
222 y1 = Math. min ( GH , rowMin + Math. max ( 3 , Math. round ((rowMax - rowMin + 1 ) * 0.45 )));
223 let x0, x1;
224 if (side === "right" ) {
225 x0 = Math. min ( GW - 1 , colMax + 1 + pad);
226 x1 = GW - pad;
227 } else {
228 x1 = Math. max ( 1 , colMin - pad);
229 x0 = pad;
230 }
231 if (x1 - x0 < Math. round ( GW * 0.1 )) return null ;
232 // shrink until actually clean (≤8% occupied cells)
233 let occN = 0 ,
234 tot = 0 ;
235 for ( let y = y0; y < y1; y ++ )
236 for ( let x = x0; x < x1; x ++ ) {
237 tot ++ ;
238 if (occCell[y * GW + x]) occN ++ ;
239 }
240 if (tot === 0 || occN / tot > 0.08 ) return null ;
241 const r = { x: x0, y: y0, w: x1 - x0, h: y1 - y0, area: (x1 - x0) * (y1 - y0) };
242 const z2 = toZone (r);
243 // GLYPHS must hug, not just the plane: in a wide column, text aligned to the far
244 // edge parks the words a third of the frame away from the subject. Align TOWARD
245 // the silhouette: right-side column → text-align:left (text starts beside the
246 // subject); left-side column → text-align:right.
247 if (z2) z2.align = side === "right" ? "left" : "right" ;
248 return z2;
249 };
250 zones.hugLeft = hug ( "left" );
251 zones.hugRight = hug ( "right" );
252 // HERO BAND PROFILE — per-height predicted occlusion of a centered hero band. The hero
253 // WANTS ~30–55% (occlusion IS the embed); fg is the LAST resort, only when no height
254 // achieves ≤62%. Even an 88%-coverage frame usually has a feasible band over the hairline.
255 let heroBands = null ;
256 if (colMax >= 0 ) {
257 const bandH = Math. max ( 2 , Math. round ( GH * 0.13 ));
258 const hx0 = Math. round (((heroAnchor ? heroAnchor.plane.xPct : 8 ) / 100 ) * GW );
259 const hx1 = Math. min (
260 GW ,
261 Math. round (((heroAnchor ? heroAnchor.plane.xPct + heroAnchor.plane.wPct : 92 ) / 100 ) * GW ),
262 );
263 const profile = [];
264 for ( let y0 = 0 ; y0 + bandH <= GH ; y0 += Math. max ( 1 , Math. round ( GH * 0.02 ))) {
265 let n = 0 ,
266 occN = 0 ,
267 lsum = 0 ;
268 for ( let y = y0; y < y0 + bandH; y ++ )
269 for ( let x = hx0; x < hx1; x ++ ) {
270 n ++ ;
271 if (occCell[y * GW + x]) occN ++ ;
272 if (lum) lsum += lum[y * GW + x];
273 }
274 profile. push ({
275 topPct: + ((y0 / GH ) * 100 ). toFixed ( 1 ),
276 occPct: + ((occN / n) * 100 ). toFixed ( 1 ),
277 ... (lum ? { bgLuma: Math. round (lsum / n) } : {}),
278 });
279 }
280 const ok = profile. filter (( b ) => b.occPct >= 12 && b.occPct <= 62 );
281 const best = (ok. length ? ok : profile). reduce (( a , b ) =>
282 Math. abs (b.occPct - 40 ) < Math. abs (a.occPct - 40 ) ? b : a,
283 );
284 heroBands = { feasible: ok. length > 0 , best, profile };
285 }
286 const big = zones.largest;
287 const embeddable = !! big && big.areaPct >= 8 && big.hPct >= 10 && big.wPct >= 18 ;
288 return {
289 coverage: + (coverage * 100 ). toFixed ( 1 ),
290 subject: {
291 colMinPct: + ((colMin / GW ) * 100 ). toFixed ( 1 ),
292 colMaxPct: + (((colMax + 1 ) / GW ) * 100 ). toFixed ( 1 ),
293 clearerSide,
294 },
295 zones,
296 heroAnchor,
297 heroBands,
298 recommendation: embeddable ? "embed" : "fg" ,
299 };
300 }
301
302 // ── SCENE OPTICS + PALETTE (v2) ──────────────────────────────────────────────
303 // Deterministic scene measurements that drive the DNA tokens, so "design that fits
304 // the scene" is a pipeline product, not agent inspiration:
305 // palette — dominant scene colors + a READABLE accent suggestion (sampled, then
306 // clamped to usable saturation/lightness) + warm/cool temperature
307 // optics — background vs subject sharpness (Laplacian proxy) → suggested text
308 // blur so embed type matches the scene's depth-of-field
309 // lighting — bright-side estimate → contact-shadow direction for embed type
310
311 function rgb2hsv ( r , g , b ) {
312 r /= 255 ;
313 g /= 255 ;
314 b /= 255 ;
315 const mx = Math. max (r, g, b),
316 mn = Math. min (r, g, b),
317 d = mx - mn;
318 let h = 0 ;
319 if (d > 0 ) {
320 if (mx === r) h = ((g - b) / d) % 6 ;
321 else if (mx === g) h = (b - r) / d + 2 ;
322 else h = (r - g) / d + 4 ;
323 h *= 60 ;
324 if (h < 0 ) h += 360 ;
325 }
326 return { h, s: mx === 0 ? 0 : d / mx, v: mx };
327 }
328 function hsv2hex ( h , s , v ) {
329 const c = v * s,
330 x = c * ( 1 - Math. abs (((h / 60 ) % 2 ) - 1 )),
331 m = v - c;
332 let [r, g, b] =
333 h < 60
334 ? [c, x, 0 ]
335 : h < 120
336 ? [x, c, 0 ]
337 : h < 180
338 ? [ 0 , c, x]
339 : h < 240
340 ? [ 0 , x, c]
341 : h < 300
342 ? [x, 0 , c]
343 : [c, 0 , x];
344 const f = ( n ) =>
345 Math. round ((n + m) * 255 )
346 . toString ( 16 )
347 . padStart ( 2 , "0" );
348 return `#${ f ( r ) }${ f ( g ) }${ f ( b ) }` ;
349 }
350
351 // dominant colors + accent suggestion from the BACKGROUND cells of a mid frame
352 async function scenePalette ( bgPath , occCell , GW , GH ) {
353 const { data , info } = await sharp (bgPath)
354 . resize ( GW , GH , { fit: "fill" })
355 . removeAlpha ()
356 . raw ()
357 . toBuffer ({ resolveWithObject: true });
358 const ch = info.channels;
359 const cells = [];
360 for ( let c = 0 ; c < GW * GH ; c ++ ) {
361 if (occCell && occCell[c]) continue ; // background only
362 cells. push ([data[c * ch], data[c * ch + 1 ], data[c * ch + 2 ]]);
363 }
364 if ( ! cells. length ) return null ;
365 // dominant: quantize to 3 bits/channel, top buckets by count
366 const buckets = new Map ();
367 for ( const [ r , g , b ] of cells) {
368 const k = ((r >> 5 ) << 6 ) | ((g >> 5 ) << 3 ) | (b >> 5 );
369 const e = buckets. get (k) || { n: 0 , r: 0 , g: 0 , b: 0 };
370 e.n ++ ;
371 e.r += r;
372 e.g += g;
373 e.b += b;
374 buckets. set (k, e);
375 }
376 const hex = ( e ) =>
377 "#" +
378 [e.r, e.g, e.b]
379 . map (( x ) =>
380 Math. round (x / e.n)
381 . toString ( 16 )
382 . padStart ( 2 , "0" ),
383 )
384 . join ( "" );
385 const dominant = [ ... buckets. values ()]
386 . sort (( a , b ) => b.n - a.n)
387 . slice ( 0 , 3 )
388 . map (( e ) => ({ hex: hex (e), sharePct: + ((e.n / cells. length ) * 100 ). toFixed ( 1 ) }));
389 // chromatic accent: hue histogram over saturated cells, weighted s·v
390 const bins = Array. from ({ length: 12 }, () => ({ w: 0 , h: 0 , s: 0 , v: 0 , n: 0 }));
391 let warmW = 0 ,
392 coolW = 0 ;
393 for ( const [ r , g , b ] of cells) {
394 const { h , s , v } = rgb2hsv (r, g, b);
395 if (h <= 90 || h >= 330 ) warmW += s * v;
396 else if (h >= 150 && h <= 300 ) coolW += s * v;
397 if (s < 0.18 || v < 0.12 || v > 0.97 ) continue ;
398 const bi = Math. floor (h / 30 ) % 12 ,
399 w = s * v;
400 const B = bins[bi];
401 B .w += w;
402 B .h += h * w;
403 B .s += s * w;
404 B .v += v * w;
405 B .n ++ ;
406 }
407 const top = bins. reduce (( a , b ) => (b.w > a.w ? b : a));
408 let accent = null ;
409 if (top.w > 0.5 && top.n >= 3 ) {
410 const h = top.h / top.w,
411 s = top.s / top.w,
412 v = top.v / top.w;
413 // clamp to a readable accent: saturated enough to read as a choice, light enough to sit on video
414 accent = hsv2hex (
415 h,
416 Math. min ( 0.78 , Math. max ( 0.5 , s * 1.2 )),
417 Math. min ( 0.8 , Math. max ( 0.55 , v * 1.15 )),
418 );
419 }
420 const temperature = warmW > coolW * 1.25 ? "warm" : coolW > warmW * 1.25 ? "cool" : "neutral" ;
421 return { dominant, accentSuggestion: accent, temperature };
422 }
423
424 // Laplacian-stdev sharpness proxy of a region crop (full-res)
425 async function regionSharpness ( imgPath , rect , W , H ) {
426 const x = Math. max ( 0 , Math. min ( W - 2 , Math. round (rect.x))),
427 y = Math. max ( 0 , Math. min ( H - 2 , Math. round (rect.y)));
428 const w = Math. max ( 2 , Math. min ( W - x, Math. round (rect.w))),
429 h = Math. max ( 2 , Math. min ( H - y, Math. round (rect.h)));
430 // two passes: crop to a buffer FIRST, then convolve+stats on the crop — sharp's
431 // internal pipeline ordering otherwise convolves/stats the full frame and the two
432 // regions measure identical.
433 const crop = await sharp (imgPath)
434 . extract ({ left: x, top: y, width: w, height: h })
435 . png ()
436 . toBuffer ();
437 const st = await sharp (crop)
438 . greyscale ()
439 . convolve ({ width: 3 , height: 3 , kernel: [ 0 , 1 , 0 , 1 , - 4 , 1 , 0 , 1 , 0 ], scale: 1 , offset: 128 })
440 . stats ();
441 return st.channels[ 0 ].stdev;
442 }
443
444 async function sceneOptics ( project , bgPath , fgPath , zones , subjectBox , W , H ) {
445 let bgSharp = null ,
446 subjSharp = null ;
447 const bigZone = zones && zones.largest && zones.largest.px;
448 try {
449 if (bigZone && bigZone.w >= 64 && bigZone.h >= 64 )
450 bgSharp = await regionSharpness (bgPath, bigZone, W , H );
451 } catch {}
452 try {
453 if (subjectBox && subjectBox.w >= 64 )
454 subjSharp = await regionSharpness (bgPath, subjectBox, W , H );
455 } catch {}
456 let suggestedTextBlurPx = 0 ,
457 ratio = null ;
458 if (bgSharp != null && subjSharp != null && subjSharp > 1 ) {
459 ratio = + (bgSharp / subjSharp). toFixed ( 3 );
460 // strong bokeh → text in that depth plane should soften to match
461 suggestedTextBlurPx = ratio < 0.35 ? 1.6 : ratio < 0.55 ? 1.0 : ratio < 0.8 ? 0.5 : 0 ;
462 }
463 return {
464 bgSharpness: bgSharp != null ? + bgSharp. toFixed ( 2 ) : null ,
465 subjSharpness: subjSharp != null ? + subjSharp. toFixed ( 2 ) : null ,
466 sharpnessRatio: ratio,
467 suggestedTextBlurPx,
468 };
469 }
470
471 // bright-side estimate from the luminance grid → contact-shadow vector (shadow falls AWAY from light)
472 function sceneLighting ( lum , occCell , GW , GH ) {
473 if ( ! lum) return null ;
474 let sw = 0 ,
475 sx = 0 ,
476 sy = 0 ,
477 n = 0 ,
478 mean = 0 ;
479 for ( let c = 0 ; c < GW * GH ; c ++ ) {
480 if ( ! occCell[c]) {
481 mean += lum[c];
482 n ++ ;
483 }
484 }
485 if ( ! n) return null ;
486 mean /= n;
487 for ( let y = 0 ; y < GH ; y ++ )
488 for ( let x = 0 ; x < GW ; x ++ ) {
489 const c = y * GW + x;
490 if (occCell[c]) continue ;
491 const w = Math. max ( 0 , lum[c] - mean);
492 sw += w;
493 sx += w * (x / GW - 0.5 );
494 sy += w * (y / GH - 0.5 );
495 }
496 if (sw < 1 ) return { lightFrom: "flat" , shadow: { dx: 0 , dy: 3 } };
497 const lx = sx / sw,
498 ly = sy / sw; // light centroid offset from center, −0.5..0.5
499 const mag = Math. hypot (lx, ly);
500 if (mag < 0.04 ) return { lightFrom: "frontal" , shadow: { dx: 0 , dy: 3 } };
501 // shadow direction = opposite the light, scaled to a subtle px offset
502 const s = Math. min ( 1 , mag / 0.25 );
503 const dx = Math. round (( - lx / mag) * 4 * s),
504 dy = Math. round (Math. max ( 1 , ( - ly / mag) * 4 * s + 2 ));
505 const compass =
506 Math. abs (lx) > Math. abs (ly) * 1.8
507 ? lx > 0
508 ? "right"
509 : "left"
510 : Math. abs (ly) > Math. abs (lx) * 1.8
511 ? ly > 0
512 ? "below"
513 : "above"
514 : `${ ly > 0 ? "lower" : "upper"}-${ lx > 0 ? "right" : "left"}` ;
515 return { lightFrom: compass, shadow: { dx, dy } };
516 }
517
518 // split the transcript into sentence windows (punctuation, or a > 0.7s gap)
519 function sentenceWindows ( project ) {
520 const tp = path. join (project, "transcript.json" );
521 if ( ! fs. existsSync (tp)) return [];
522 let words;
523 try {
524 words = ( JSON . parse (fs. readFileSync (tp, "utf8" )).words || []). filter (( w ) => w && "start" in w);
525 } catch {
526 return [];
527 }
528 const out = [];
529 let cur = [];
530 for ( let i = 0 ; i < words. length ; i ++ ) {
531 cur. push (words[i]);
532 const w = words[i],
533 nx = words[i + 1 ];
534 const ends = / [.!?…] $ / . test ((w.text || "" ). trim ());
535 const gap = nx ? nx.start - w.end > 0.7 : true ;
536 if (ends || gap || ! nx) {
537 if (cur. length )
538 out. push ({
539 in: + cur[ 0 ].start. toFixed ( 2 ),
540 out: + cur[cur. length - 1 ].end. toFixed ( 2 ),
541 text: cur. map (( x ) => x.text). join ( " " ),
542 });
543 cur = [];
544 }
545 }
546 return out;
547 }
548
549 async function main () {
550 const project = path. resolve (process.argv[ 2 ] || "" );
551 if ( ! process.argv[ 2 ]) {
552 console. error ( "usage: safe-zones.cjs <project-dir> [in out]" );
553 process. exit ( 1 );
554 }
555 const fgDir = path. join (project, "frames_fg" );
556 if ( ! fs. existsSync (fgDir)) {
557 console. error ( `[safe-zones] no ${ fgDir } — run matte.cjs first` );
558 process. exit ( 2 );
559 }
560 if ( ! sharp) {
561 console. error ( "[safe-zones] sharp unavailable — set HYPERFRAMES_ROOT" );
562 process. exit ( 0 );
563 }
564
565 const frames = fs
566 . readdirSync (fgDir)
567 . filter (( f ) => / \. png $ / i . test (f))
568 . sort ();
569 if ( ! frames. length ) {
570 console. error ( "[safe-zones] no PNG frames" );
571 process. exit ( 2 );
572 }
573 const meta = await sharp (path. join (fgDir, frames[ 0 ])). metadata ();
574 const W = meta.width,
575 H = meta.height;
576 const CELL = Math. max ( W , H ) / 48 ;
577 const GW = Math. max ( 8 , Math. round ( W / CELL )),
578 GH = Math. max ( 8 , Math. round ( H / CELL ));
579 let fps = 24 ;
580 try {
581 const f = parseFloat (
582 String (fs. readFileSync (path. join (project, "matte.fps" ), "utf8" )). replace ( / [ ^ \d.] / g , "" ),
583 );
584 if (f > 0 ) fps = f;
585 } catch {}
586
587 const bgDir = path. join (project, "frames_bg" );
588 const hasBg = fs. existsSync (bgDir);
589 // cache evenly-sampled frame grids once (each = per-cell avg subject alpha 0..1,
590 // plus per-cell mean LUMINANCE from frames_bg — bright zones wash out cream/screen text)
591 const sampleIdx = [
592 ...new Set (
593 Array. from ({ length: SAMPLES }, ( _ , i ) =>
594 Math. min (frames. length - 1 , Math. round ((i / ( SAMPLES - 1 )) * (frames. length - 1 ))),
595 ),
596 ),
597 ];
598 const grids = [];
599 for ( const i of sampleIdx) {
600 const { data , info } = await sharp (path. join (fgDir, frames[i]))
601 . resize ( GW , GH , { fit: "fill" })
602 . raw ()
603 . toBuffer ({ resolveWithObject: true });
604 const ch = info.channels,
605 g = new Float32Array ( GW * GH );
606 for ( let c = 0 ; c < GW * GH ; c ++ ) g[c] = (ch >= 4 ? data[c * ch + 3 ] : 255 ) / 255 ;
607 let lum = null ;
608 if (hasBg && fs. existsSync (path. join (bgDir, frames[i]))) {
609 const { data : bd , info : bi } = await sharp (path. join (bgDir, frames[i]))
610 . resize ( GW , GH , { fit: "fill" })
611 . greyscale ()
612 . raw ()
613 . toBuffer ({ resolveWithObject: true });
614 const bch = bi.channels;
615 lum = new Float32Array ( GW * GH );
616 for ( let c = 0 ; c < GW * GH ; c ++ ) lum[c] = bd[c * bch];
617 }
618 grids. push ({ t: i / fps, g, lum });
619 }
620 const lumWindow = ( t0 , t1 ) => {
621 const acc = new Float32Array ( GW * GH );
622 let n = 0 ;
623 let inWin = grids. filter (( s2 ) => s2.t >= t0 - 1e-6 && s2.t <= t1 + 1e-6 && s2.lum);
624 if ( ! inWin. length ) inWin = grids. filter (( s2 ) => s2.lum);
625 for ( const s2 of inWin) {
626 for ( let c = 0 ; c < GW * GH ; c ++ ) acc[c] += s2.lum[c];
627 n ++ ;
628 }
629 if ( ! n) return null ;
630 for ( let c = 0 ; c < GW * GH ; c ++ ) acc[c] /= n;
631 return acc;
632 };
633 const lumSeriesWindow = ( t0 , t1 ) => {
634 let inWin = grids. filter (( s2 ) => s2.t >= t0 - 1e-6 && s2.t <= t1 + 1e-6 && s2.lum);
635 if ( ! inWin. length ) inWin = grids. filter (( s2 ) => s2.lum);
636 return inWin. map (( s2 ) => s2.lum);
637 };
638 const occWindow = ( t0 , t1 ) => {
639 const occ = new Float32Array ( GW * GH );
640 let inWin = grids. filter (( s ) => s.t >= t0 - 1e-6 && s.t <= t1 + 1e-6 );
641 if ( ! inWin. length ) {
642 // window between samples → use nearest grid
643 const mid = (t0 + t1) / 2 ;
644 inWin = [grids. reduce (( a , b ) => (Math. abs (b.t - mid) < Math. abs (a.t - mid) ? b : a))];
645 }
646 for ( const s of inWin) for ( let c = 0 ; c < GW * GH ; c ++ ) if (s.g[c] > occ[c]) occ[c] = s.g[c];
647 return occ;
648 };
649
650 // ad-hoc window query
651 const qIn = parseFloat (process.argv[ 3 ]),
652 qOut = parseFloat (process.argv[ 4 ]);
653 if (Number. isFinite (qIn) && Number. isFinite (qOut)) {
654 const a = analyze (
655 occWindow (qIn, qOut),
656 GW ,
657 GH ,
658 W ,
659 H ,
660 lumWindow (qIn, qOut),
661 lumSeriesWindow (qIn, qOut),
662 );
663 console. log (
664 `[safe-zones] window ${ qIn }-${ qOut }s: ${ a . recommendation . toUpperCase () } coverage ${ a . coverage }% clearer:${ a . subject . clearerSide }` ,
665 );
666 const z = a.zones;
667 for ( const k of [ "largest" , "left" , "right" , "top" ])
668 if (z[k]) console. log ( ` ${ k }: ${ z [ k ]. wPct }%×${ z [ k ]. hPct }% @ (${ z [ k ]. xPct }%,${ z [ k ]. yPct }%)` );
669 console. log ( JSON . stringify ({ in: qIn, out: qOut, ... a }));
670 return ;
671 }
672
673 const globalOcc = occWindow ( - 1e9 , 1e9 );
674 const globalLum = lumWindow ( - 1e9 , 1e9 );
675 const global = analyze (globalOcc, GW , GH , W , H , globalLum, lumSeriesWindow ( - 1e9 , 1e9 ));
676 const windows = sentenceWindows (project). map (( s ) => {
677 const a = analyze (
678 occWindow (s.in, s.out),
679 GW ,
680 GH ,
681 W ,
682 H ,
683 lumWindow (s.in, s.out),
684 lumSeriesWindow (s.in, s.out),
685 );
686 return {
687 in: s.in,
688 out: s.out,
689 text: s.text. slice ( 0 , 48 ),
690 coverage: a.coverage,
691 recommendation: a.recommendation,
692 clearerSide: a.subject.clearerSide,
693 zones: a.zones,
694 };
695 });
696
697 // ── v2: palette / optics / lighting from the mid frame + global grids ───────
698 let palette = null ,
699 optics = null ,
700 lighting = null ;
701 try {
702 const occCellG = new Uint8Array ( GW * GH );
703 for ( let c = 0 ; c < GW * GH ; c ++ ) occCellG[c] = globalOcc[c] >= THRESH ? 1 : 0 ;
704 const midName = frames[Math. floor (frames. length / 2 )];
705 const midBg = path. join (bgDir, midName);
706 if (hasBg && fs. existsSync (midBg)) {
707 palette = await scenePalette (midBg, occCellG, GW , GH );
708 // subject bbox in px from the global occupancy grid
709 let cx0 = GW ,
710 cx1 = - 1 ,
711 cy0 = GH ,
712 cy1 = - 1 ;
713 for ( let y = 0 ; y < GH ; y ++ )
714 for ( let x = 0 ; x < GW ; x ++ )
715 if (occCellG[y * GW + x]) {
716 if (x < cx0) cx0 = x;
717 if (x > cx1) cx1 = x;
718 if (y < cy0) cy0 = y;
719 if (y > cy1) cy1 = y;
720 }
721 const subjectBox =
722 cx1 >= 0
723 ? {
724 x: (cx0 / GW ) * W ,
725 y: (cy0 / GH ) * H ,
726 w: ((cx1 - cx0 + 1 ) / GW ) * W ,
727 h: ((cy1 - cy0 + 1 ) / GH ) * H ,
728 }
729 : null ;
730 optics = await sceneOptics (
731 project,
732 midBg,
733 path. join (fgDir, midName),
734 global.zones,
735 subjectBox,
736 W ,
737 H ,
738 );
739 lighting = sceneLighting (globalLum, occCellG, GW , GH );
740 }
741 } catch (e) {
742 console. error ( `[safe-zones] scene optics skipped — ${ e . message }` );
743 }
744
745 const out = {
746 width: W ,
747 height: H ,
748 fps,
749 grid: { cols: GW , rows: GH },
750 ... global,
751 palette,
752 optics,
753 lighting,
754 windows,
755 };
756 fs. writeFileSync (path. join (project, "safe-zones.json" ), JSON . stringify (out, null , 2 ));
757
758 const z = ( n , zn ) =>
759 zn
760 ? `${ n }: ${ zn . wPct }%×${ zn . hPct }% @ (${ zn . xPct }%,${ zn . yPct }%) [${ zn . areaPct }%${ zn . meanLuma != null ? ` · luma ${ zn . meanLuma }${ zn . bright ? " ⚠BRIGHT" : ""}` : ""}]`
761 : `${ n }: —` ;
762 console. log (
763 `[safe-zones] ${ W }×${ H } grid ${ GW }×${ GH } @ ${ fps }fps · GLOBAL coverage ${ global . coverage }% · clearer ${ global . subject . clearerSide } · verdict ${ global . recommendation . toUpperCase () }` ,
764 );
765 console. log (
766 ` ${ z ( "largest" , global . zones . largest ) } | ${ z ( "left" , global . zones . left ) } | ${ z ( "right" , global . zones . right ) } | ${ z ( "top" , global . zones . top ) }` ,
767 );
768 if (global.recommendation === "embed" ) {
769 console. log (
770 `[safe-zones] ✅ EMBED — NARRATION planes go in the clean zones (prefer ${ global . subject . clearerSide }/top).` ,
771 );
772 if (global.heroAnchor)
773 console. log (
774 `[safe-zones] 🎯 HERO → centered ON the subject: plane ≈ x${ global . heroAnchor . plane . xPct }% y${ global . heroAnchor . plane . yPct }% w${ global . heroAnchor . plane . wPct }% center · BIG (~0.22–0.34·h) · target ~30–55% occlusion${ global . heroAnchor . bandLuma != null ? ` · band luma ${ global . heroAnchor . bandLuma }${ global . heroAnchor . washoutRisk ? " ⚠WASHOUT RISK — see heroAnchor.note" : ""}` : ""}` ,
775 );
776 if (global.heroBands)
777 console. log (
778 `[safe-zones] hero bands: best top ${ global . heroBands . best . topPct }% (predicted occlusion ${ global . heroBands . best . occPct }%) · bg-hero ${ global . heroBands . feasible ? "FEASIBLE — keep the hero EMBEDDED (fg is last resort)" : "INFEASIBLE (no band ≤62%) → hero fg"}` ,
779 );
780 if (global.zones.hugLeft || global.zones.hugRight)
781 console. log (
782 `[safe-zones] hugging zones (narration belongs HERE, abutting the silhouette): L ${ global . zones . hugLeft ? global . zones . hugLeft . wPct + "%×" + global . zones . hugLeft . hPct + "%@x" + global . zones . hugLeft . xPct + "%" + ( global . zones . hugLeft . bright ? "⚠bright" : "" ) : "—"} · R ${ global . zones . hugRight ? global . zones . hugRight . wPct + "%×" + global . zones . hugRight . hPct + "%@x" + global . zones . hugRight . xPct + "%" + ( global . zones . hugRight . bright ? "⚠bright" : "" ) : "—"}` ,
783 );
784 } else {
785 console. log (
786 `[safe-zones] ⚠ FG — subject fills the frame; use caption_layer:"fg" (no clean region to embed behind).` ,
787 );
788 }
789 if (palette)
790 console. log (
791 `[safe-zones] 🎨 palette: dominant ${ palette . dominant . map (( d ) => d . hex ). join ( " " ) } · accent suggestion ${ palette . accentSuggestion || "— (no chromatic anchor; use the DNA default)"} · ${ palette . temperature }` ,
792 );
793 if (optics && optics.sharpnessRatio != null )
794 console. log (
795 `[safe-zones] 🔭 depth: bg/subject sharpness ${ optics . sharpnessRatio } → embed text blur ${ optics . suggestedTextBlurPx }px${ optics . suggestedTextBlurPx ? " (match the scene's depth-of-field)" : " (scene is uniformly sharp)"}` ,
796 );
797 if (lighting)
798 console. log (
799 `[safe-zones] 💡 light from ${ lighting . lightFrom } → contact shadow offset (${ lighting . shadow . dx }px, ${ lighting . shadow . dy }px)` ,
800 );
801 if (windows. length ) {
802 console. log ( `[safe-zones] per-sentence windows (place each group using ITS window's zones):` );
803 for ( const w of windows)
804 console. log (
805 ` [${ w . in }-${ w . out }s] ${ w . recommendation . toUpperCase () } cov ${ w . coverage }% clear:${ w . clearerSide } ${ w . zones . largest ? `best ${ w . zones . largest . wPct }%×${ w . zones . largest . hPct }%@(${ w . zones . largest . xPct }%,${ w . zones . largest . yPct }%)` : ""} "${ w . text }"` ,
806 );
807 }
808 console. log ( `[safe-zones] → ${ path . join ( project , "safe-zones.json" ) }` );
809 }
810 main (). catch (( e ) => {
811 console. error ( `[safe-zones] (skipped — ${ e . message })` );
812 process. exit ( 0 );
813 });