Setting the file. One moment.
Make Cinematic · Embedded Captions · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page 14.42
Hershey Script1
Type
JavaScript scripts/ make-cinematic.cjs
JavaScript · 1,206 lines · 56 KB
14 * - hero hand-off: the promoted word is lifted OUT of its line, enters as the hero
15 * (its own plane, BIG) when spoken, holds to the END of its block, exits cleanly
16 * - fg fallback: safe-zones verdict "fg" → caption_layer:"fg" automatically
17 *
18 * node make-cinematic.cjs <project-dir>
19 *
20 * cinematic.json schema:
21 * {
22 * "template": "cinematic-cream",
23 * "width": 1920, "height": 1080, "fps": 30,
24 * "planes": { // layout REGIONS — place them using safe-zones.json
25 * "narr": "top: 14%; left: 4%; width: 26%;", // string or {css}
26 * "hero": null // null/absent → auto from safe-zones heroAnchor
27 * },
28 * "blocks": [
29 * { "plane": "narr", "lines": [
30 * { "words": ["You","need","to"], "css": "font-size: calc(0.05*var(--h)); font-weight:600;" },
31 * { "words": ["judge","us","by","the"], "css": "font-size: calc(0.062*var(--h)); font-weight:700;" },
32 * { "words": ["actions"], "hero": true,
33 * "text": "ACTIONS", "css": "font-size: calc(0.24*var(--h)); font-weight:900; text-transform:uppercase;" },
34 * { "words": ["that","we","take."], "css": "font-size: calc(0.05*var(--h));" },
35 * // per-line escape hatch: "layer":"fg" lifts a line ABOVE the subject matte
36 * // (use when the occlusion gate reports a context line eaten by the subject)
37 * { "words": ["over","the","subject"], "layer": "fg" }
38 * ] },
39 * { "plane": "narr", "lines": [ ... next thought ... ] }
40 * ],
41 * "tones": { "default": "soft", "hero": "present" } // optional
42 * }
43 * Rules: words must match the transcript verbatim in spoken order across all blocks.
44 * Mark at most ONE line "hero": true per clip (scarcity); its words form the hero
45 * (usually a single word; "text" overrides the display form).
46 */
47 const path = require ( "path" );
48 const fs = require ( "fs" );
49
50 const norm = ( s ) =>
51 String (s == null ? "" : s)
52 . toLowerCase ()
53 . replace ( / [ ^ a-z0-9'] / g , "" );
54 const LOOKAHEAD = 40 ;
55
56 // Authored css WITHOUT a font-size renders at browser-default ~16px (an
57 // invisible ribbon) and every gate still passes — observed in the cold-start
58 // E2E. Guarantee the default is present whenever the author omitted it.
59 function ensureFontSize ( css , defaultDecl ) {
60 if ( ! css) return defaultDecl;
61 return /font-size \s * :/ . test (css) ? css : defaultDecl + " " + css;
62 }
63
64 function die ( m ) {
65 console. error ( `[make-cinematic] ${ m }` );
66 process. exit ( 1 );
67 }
68
69 function main () {
70 const project = path. resolve (process.argv[ 2 ] || "" );
71 if ( ! process.argv[ 2 ]) die ( "usage: make-cinematic.cjs <project-dir>" );
72 const cj = path. join (project, "cinematic.json" );
73 const tj = path. join (project, "transcript.json" );
74 if ( ! fs. existsSync (cj)) die ( `missing ${ cj } — author it first (schema in this header)` );
75 if ( ! fs. existsSync (tj)) die ( `missing ${ tj } — run prepare.sh first` );
76 const C = JSON . parse (fs. readFileSync (cj, "utf8" ));
77 const tr = ( JSON . parse (fs. readFileSync (tj, "utf8" )).words || []). filter (
78 ( w ) => w && "start" in w && "end" in w,
79 );
80 if ( ! tr. length ) die ( "transcript has no word timings" );
81
82 // safe-zones: fg verdict + heroAnchor default
83 let sz = null ;
84 try {
85 sz = JSON . parse (fs. readFileSync (path. join (project, "safe-zones.json" ), "utf8" ));
86 } catch {}
87 // author override first (borderline scenes: coverage near the line, agitated subject),
88 // else the safe-zones verdict.
89 // narration legibility follows the verdict; the HERO is judged separately — it WANTS
90 // occlusion (~30–55% is the product). fg for the hero is the LAST resort: only when no
91 // height band achieves ≤62% predicted occlusion (safe-zones heroBands). An explicit
92 // author caption_layer:"fg" still forces everything front.
93 const authorFg = C .caption_layer === "fg" ;
94 const narrFg = authorFg || (sz && sz.recommendation === "fg" );
95 const hb = sz && sz.heroBands;
96 const heroFeasible = authorFg ? false : hb ? hb.feasible : ! narrFg;
97 const globalFg = narrFg; // narration layer (name kept for the lowering below)
98
99 const W = C .width || 1920 ,
100 H = C .height || 1080 ,
101 FPS = C .fps || 24 ;
102 const blocks = C .blocks || [];
103 if ( ! blocks. length ) die ( "blocks is empty" );
104
105 // ── sequence-match every word across blocks/lines ──────────────────────────
106 let p = 0 ;
107 const missing = [];
108 for ( const b of blocks)
109 for ( const ln of b.lines || []) {
110 ln._w = (ln.words || []). map (( text ) => {
111 const t = norm (text);
112 let f = - 1 ;
113 for ( let j = p; j < Math. min (tr. length , p + LOOKAHEAD ); j ++ )
114 if ( norm (tr[j].text) === t) {
115 f = j;
116 break ;
117 }
118 if (f < 0 ) {
119 missing. push (text);
120 return { text, start: null , end: null };
121 }
122 p = f + 1 ;
123 return { text, start: tr[f].start, end: tr[f].end, ti: f };
124 });
125 }
126 if (missing. length )
127 die (
128 `words not in transcript (in order): ${ missing . join ( " " ) } — lines must match the transcript verbatim` ,
129 );
130
131 // COMPLETENESS GATE — verbatim is a hard rule, so enforce it at compile time: the
132 // sequence matcher's lookahead can silently SKIP transcript words the author forgot
133 // (a real case shipped missing "in our newly released model."). Every transcript word
134 // must be consumed; the only legitimate drops are filler (um/uh/stutters), declared in
135 // cinematic.json "drops": ["um", ...].
136 {
137 const consumed = new Set ();
138 for ( const b of blocks)
139 for ( const ln of b.lines || []) for ( const w of ln._w) if (w.ti != null ) consumed. add (w.ti);
140 const dropOk = new Set (( C .drops || []). map (norm));
141 const FILLER = new Set ([ "um" , "uh" , "er" , "ah" , "hmm" , "mm" ]);
142 const skipped = tr
143 . map (( w , i ) => ({ w, i }))
144 . filter (
145 ({ w , i }) => ! consumed. has (i) && ! dropOk. has ( norm (w.text)) && ! FILLER . has ( norm (w.text)),
146 );
147 if (skipped. length )
148 die (
149 `VERBATIM VIOLATION — ${ skipped . length } transcript word(s) never authored (the matcher skipped them): ` +
150 `${ skipped . map (({ w }) => `"${ w . text }"` ). join ( " " ) } — add them to a line, or declare true filler in "drops": []` ,
151 );
152 }
153
154 // text override + multiple words double-renders (override replaces word 1, words
155 // 2..n still draw) — caught cold by a clean-room agent; illegal for ANY hero line.
156 for ( const b of blocks)
157 for ( const ln of b.lines || []) {
158 if (ln.hero && ln.text && (ln.words || []). length > 1 )
159 die (
160 `hero line ["${ ( ln . words || []). join ( '" "' ) }"] has BOTH multiple words and a "text" override — drop "text" (words render verbatim; DNA case applies) or promote a single word.` ,
161 );
162 }
163
164 // ── block windows: in = first word −lead; out = page-flip = next block's first word −0.28 (or +1.2 hold) ──
165 const LEAD = 0.18 ;
166 const bw = blocks. map (( b ) => {
167 const ws = (b.lines || []). flatMap (( l ) => l._w);
168 return { first: Math. min ( ... ws. map (( w ) => w.start)), last: Math. max ( ... ws. map (( w ) => w.end)) };
169 });
170 let srcDur = null ;
171 try {
172 const cp = require ( "child_process" );
173 for ( const f of [ "source.mp4" ]. concat (
174 fs. readdirSync (project). filter (( x ) => / \. (mp4 | mov | webm) $ / i . test (x)),
175 )) {
176 const fp = path. join (project, f);
177 if ( ! fs. existsSync (fp)) continue ;
178 const d = parseFloat (
179 cp. execFileSync (
180 "ffprobe" ,
181 [
182 "-v" ,
183 "error" ,
184 "-show_entries" ,
185 "format=duration" ,
186 "-of" ,
187 "default=nokey=1:noprint_wrappers=1" ,
188 "--" ,
189 fp,
190 ],
191 { encoding: "utf8" },
192 ),
193 );
194 if (d > 0 ) {
195 srcDur = d;
196 break ;
197 }
198 }
199 } catch {}
200 const DUR = + (srcDur || bw[bw. length - 1 ].last + 0.6 ). toFixed ( 3 );
201 // ── SLOT layout + paging. Lines get FIXED absolute positions (no flex reflow — the
202 // "lower line jumps up when the upper one clears" bug is impossible by construction).
203 // Lines accumulate down the plane like a poem; the plane PAGE-FLIPS (all visible lines
204 // clear together, the next line restarts at the top) only when (a) the plane is full,
205 // (b) a block sets "flip": true, or (c) a block boundary coincides with a real speech
206 // pause (≥0.6s). Block windows (bw) are kept only for the hero's hold.
207 for ( let i = 0 ; i < bw. length ; i ++ ) {
208 bw[i].in = Math. max ( 0 , bw[i].first - LEAD );
209 bw[i].out = Math. min ( DUR - 0.05 , Math. min (bw[i].last, DUR - 0.05 ) + 0.9 );
210 // a block's hold (incl. a LOCKUP's) yields to the NEXT block's entrance — the same
211 // page-flip semantics columns already have; floor: its own words must finish
212 if (i + 1 < bw. length )
213 bw[i].out = Math. max (
214 Math. min (bw[i].last, DUR - 0.05 ) + 0.15 ,
215 Math. min (bw[i].out, bw[i + 1 ].first - 0.28 ),
216 );
217 }
218 const fracOfLine = ( css ) => {
219 const m = (css || "" ). match ( /font-size \s * : \s * calc \( \s * ( [\d.] + ) \s * \* \s * var \( --h \) / );
220 return m ? + m[ 1 ] : 0.05 ;
221 };
222 const planeBudgetPx = ( pk ) => {
223 const css =
224 ( C .planes &&
225 C .planes[pk] &&
226 ( typeof C .planes[pk] === "string" ? C .planes[pk] : C .planes[pk].css)) ||
227 "" ;
228 const m = css. match ( /height \s * : \s * ( [\d.] + )%/ );
229 return (m ? + m[ 1 ] / 100 : 0.3 ) * H ;
230 };
231
232 // ── planes: agent's + auto LOCKUP planes (one per hero block) ───────────────
233 // The LOCKUP is the layout answer to "the sentence reads broken around the hero":
234 // a hero block's lines do NOT scatter into the narration column — kicker, HERO and
235 // tail compose as ONE bonded ORBIT (context anchored at the hero's edges, diagonal
236 // read). DEPTH: context EMBEDS (bg) with the hero — one shared depth; per-line
237 // `layer:"fg"` is the escape hatch when the matte eats a context line (gate tells you).
238 const planes = {};
239 for ( const [ k , v ] of Object. entries ( C .planes || {})) {
240 if (v == null ) continue ;
241 let css = typeof v === "string" ? v : v.css || "" ;
242 if ( ! /text-align \s * :/ . test (css)) css += " text-align:center;" ; // slot children are absolute; only alignment carries
243 planes[k] = { css };
244 }
245 // multiple heroes allowed — scarcity is per BLOCK (≤1 per thought), not per clip.
246 // The LARGEST authored hero is the APEX: it gets the full lockup embed (centered on
247 // the subject, matte showcase). Smaller heroes are MINOR peaks: they stay IN their
248 // column's flow as oversized emphasis lines (fg, hero motion at reduced amplitude) —
249 // not every beat needs the matte showcase; that's what keeps the apex an event.
250 const allHeroBlocks = [];
251 blocks. forEach (( b , bi ) => {
252 const hs = (b.lines || []). filter (( l ) => l.hero === true );
253 if (hs. length > 1 )
254 die (
255 `block ${ bi } has ${ hs . length } hero lines — at most ONE hero per block (scarcity = per beat)` ,
256 );
257 if (hs. length === 1 )
258 allHeroBlocks. push ({ b, bi, ln: hs[ 0 ], frac: fracOfLine (hs[ 0 ].css) || 0.24 });
259 });
260 const apexFracAll = Math. max ( ... allHeroBlocks. map (( h ) => h.frac), 0 );
261 // placement: "column" keeps even an APEX in its column's flow (the hero bursts the
262 // column instead of crossing the subject) — the sanctioned alternative to the
263 // subject-anchored lockup. Author it per hero line: { "hero": true, "placement": "column" }.
264 const heroBlocks = allHeroBlocks. filter (
265 ( h ) => h.frac >= apexFracAll - 1e-6 && (h.ln.placement || "subject" ) !== "column" ,
266 );
267 const minorHeroBis = new Set (
268 allHeroBlocks
269 . filter (( h ) => h.frac < apexFracAll - 1e-6 || (h.ln.placement || "subject" ) === "column" )
270 . map (( h ) => h.bi),
271 );
272 const columnApexBis = new Set (
273 allHeroBlocks
274 . filter (( h ) => h.frac >= apexFracAll - 1e-6 && (h.ln.placement || "subject" ) === "column" )
275 . map (( h ) => h.bi),
276 );
277 if (minorHeroBis.size)
278 console. log (
279 `[make-cinematic] ${ minorHeroBis . size } minor hero(es) stay in the column flow (apex gets the lockup embed)` ,
280 );
281 const lockupName = ( bi ) => `lockup-b${ bi }` ;
282 const lockupTemplate =
283 C .planes && C .planes.lockup
284 ? typeof C .planes.lockup === "string"
285 ? C .planes.lockup
286 : C .planes.lockup.css
287 : null ;
288 delete planes.lockup; // template only — not a real plane
289 for ( const hRef of heroBlocks) {
290 const a = sz && sz.heroAnchor && sz.heroAnchor.plane;
291 // band pick: target ~40% occlusion AND a band the DNA's text can survive on
292 // (skip bright bands when luma data exists) — heroAnchor.yPct is the fallback
293 let heroTop = a ? a.yPct : 30 ;
294 if (hb && hb.profile && hb.profile. length ) {
295 const ok = hb.profile. filter (
296 ( b2 ) => b2.occPct >= 12 && b2.occPct <= 62 && (b2.bgLuma == null || b2.bgLuma <= 165 ),
297 );
298 const pool = ok. length ? ok : hb.profile;
299 const best = pool. reduce (( x , y ) =>
300 Math. abs (y.occPct - 40 ) < Math. abs (x.occPct - 40 ) ? y : x,
301 );
302 heroTop = + (best.topPct + 6.5 ). toFixed ( 1 );
303 }
304 const heroFrac = (hRef.ln.css || "" ). match ( /calc \( \s * ( [\d.] + ) \s * \* \s * var \( --h \) / );
305 // the lockup is taller than the hero alone (context above+below) — anchor so the
306 // HERO band (not the plane top) lands on the target, then clamp so the WHOLE
307 // lockup (pre + hero + post) stays on frame
308 const heroIdx2 = (hRef.b.lines || []). indexOf (hRef.ln);
309 const preH = (hRef.b.lines || [])
310 . filter (( l , i ) => l !== hRef.ln && i < heroIdx2)
311 . reduce (
312 ( s , l ) =>
313 s + ( fracOfLine (l.css) * 100 * 1.32 + Math. max ( 1.6 , fracOfLine (l.css) * 100 * 0.38 )),
314 0 ,
315 );
316 const postH = (hRef.b.lines || [])
317 . filter (( l , i ) => l !== hRef.ln && i > heroIdx2)
318 . reduce (
319 ( s , l ) =>
320 s + ( fracOfLine (l.css) * 100 * 1.32 + Math. max ( 1.6 , fracOfLine (l.css) * 100 * 0.38 )),
321 0 ,
322 );
323 const heroHpct = (heroFrac ? + heroFrac[ 1 ] : 0.24 ) * 100 * 1.12 ;
324 const totalH = preH + heroHpct + postH;
325 let top = + Math. max ( 2 , Math. min ( 96 - totalH, heroTop - heroHpct / 2 - preH)). toFixed ( 1 );
326 const css = lockupTemplate
327 ? lockupTemplate. replace ( /top: \s * [\d.] + %/ , `top: ${ top }%` )
328 : a
329 ? `top: ${ top }%; left: ${ a . xPct }%; width: ${ a . wPct }%; text-align: center;`
330 : `top: ${ top }%; left: 6%; width: 88%; text-align: center;` ;
331 planes[ lockupName (hRef.bi)] = { css };
332 hRef.plane = lockupName (hRef.bi);
333 }
334 // legacy alias: an author-defined "hero" plane positions the FIRST lockup
335 if (planes.hero) {
336 if (heroBlocks. length ) {
337 planes[ lockupName (heroBlocks[ 0 ].bi)] = planes.hero;
338 }
339 delete planes.hero;
340 }
341
342 // ── PER-PLANE LEGIBILITY COMPENSATION ────────────────────────────────────
343 // The DNA fixes hue + blend; the SCENE decides whether that survives. Match each
344 // plane to its best-overlapping measured zone and ladder the treatment:
345 // luma > 150 → opaque (blend:normal) + strong tight scrim — screen/blend washes out
346 // luma 95–150 → keep the DNA blend, strengthen the glyph scrim
347 const planeComp = {};
348 if (sz && sz.zones) {
349 const zlist = Object. entries (sz.zones). filter (([, z ]) => z && z.meanLuma != null );
350 for ( const [ pk , pv ] of Object. entries ( C .planes || {})) {
351 const css0 = typeof pv === "string" ? pv : (pv || {}).css || "" ;
352 const lm = css0. match ( /left: \s * ( [\d.] + )%/ ),
353 wm = css0. match ( /width: \s * ( [\d.] + )%/ );
354 if ( ! lm || ! wm) continue ;
355 const a0 = + lm[ 1 ],
356 a1 = a0 + + wm[ 1 ];
357 let best = null ,
358 bestOv = 0 ;
359 for ( const [, z ] of zlist) {
360 const ov = Math. min (a1, z.xPct + z.wPct) - Math. max (a0, z.xPct);
361 if (ov > bestOv) {
362 bestOv = ov;
363 best = z;
364 }
365 }
366 if ( ! best) continue ;
367 // gate on the time PEAK too — a dark wall swept by a bright moving object
368 // (handheld drift, screens) averages "clean" but peaks hot, and text there
369 // washes out exactly when the bright thing passes (real cold-start case)
370 const pkL = best.peakLuma != null ? best.peakLuma : best.meanLuma;
371 if (best.meanLuma > 150 || pkL > 175 )
372 planeComp[pk] =
373 " mix-blend-mode: normal; text-shadow: 0 1px 6px rgba(0,0,0,.65), 0 2px 16px rgba(0,0,0,.4);" ;
374 else if (best.meanLuma > 95 || pkL > 135 )
375 planeComp[pk] = " text-shadow: 0 1px 5px rgba(0,0,0,.6), 0 2px 14px rgba(0,0,0,.35);" ;
376 if (planeComp[pk])
377 console. log (
378 `[make-cinematic] plane "${ pk }" zone luma ${ best . meanLuma } (peak ${ pkL }) → legibility compensation (${ best . meanLuma > 150 || pkL > 175 ? "opaque+scrim" : "scrim"})` ,
379 );
380 }
381 }
382 // lockup context shares the hero band's luma
383 const bandLuma = sz && sz.heroAnchor && sz.heroAnchor.bandLuma;
384 const lockupComp =
385 bandLuma != null && bandLuma > 150
386 ? " mix-blend-mode: normal; text-shadow: 0 1px 6px rgba(0,0,0,.65), 0 2px 16px rgba(0,0,0,.4);"
387 : bandLuma != null && bandLuma > 95
388 ? " text-shadow: 0 1px 5px rgba(0,0,0,.6), 0 2px 14px rgba(0,0,0,.35);"
389 : "" ;
390
391 // ── lower to classic plan.json groups (slot layout + per-plane paging) ─────
392 const tones = C .tones || {};
393 let dnaBodyLayer = null ;
394 try {
395 const dnaLib0 = require ( "./lib-dna.cjs" );
396 const dn0 = C .dna || ( C .template ? dnaLib0. LEGACY [ C .template] : "cream" );
397 if (dn0) {
398 const d0 = dnaLib0. load (dn0);
399 dnaBodyLayer = d0.bodyLayer || null ;
400 // category lock (symmetric): column is every classic DNA's home today,
401 // so this only fires if a future DNA declares a different home.
402 if (d0.deliveries && d0.deliveries.home && d0.deliveries.home !== "column" )
403 console. warn (
404 `[make-cinematic] WARN: DNA "${ dn0 }" declares home="${ d0 . deliveries . home }" — cinematic use is cross-category and unvalidated.` ,
405 );
406 }
407 } catch (e) {
408 if (e.message && e.message. includes ( "does not support" )) throw e;
409 }
410 const groups = [];
411 const heroRefs = heroBlocks; // [{b, bi, ln, plane}]
412 const isHeroBlock = new Set (heroBlocks. map (( h ) => h.bi));
413 const flat = [];
414 blocks. forEach (( b , bi ) => {
415 const heroIdx = isHeroBlock. has (bi) ? (b.lines || []). findIndex (( l ) => l.hero === true ) : - 1 ;
416 (b.lines || []). forEach (( ln , li ) => {
417 if (ln.hero === true && isHeroBlock. has (bi)) return ; // APEX heroes slot into their lockup below
418 flat. push ({
419 b,
420 bi,
421 ln,
422 plane: heroIdx >= 0 ? lockupName (bi) : b.plane,
423 lockupPos: heroIdx >= 0 ? (li < heroIdx ? "pre" : "post" ) : null ,
424 isMinorHero: ln.hero === true && minorHeroBis. has (bi), // minor peak rides its column
425 first: Math. min ( ... ln._w. map (( w ) => w.start)),
426 lastEnd: Math. min (Math. max ( ... ln._w. map (( w ) => w.end)), DUR - 0.05 ),
427 frac: fracOfLine (ln.css),
428 });
429 });
430 });
431 // size sanity: narration below 0.05·h is whisper-tier (36px at 720p) — warn loudly
432 {
433 const tiny = flat. filter (( l ) => ! l.isMinorHero && l.frac > 0 && l.frac < 0.05 );
434 if (tiny. length )
435 console. log (
436 `[make-cinematic] ⚠ ${ tiny . length } narration line(s) authored below 0.05·h (whisper tier) — body narration wants 0.055–0.07·h: ${ tiny
437 . slice ( 0 , 3 )
438 . map (( l ) => `"${ l . ln . words . slice ( 0 , 3 ). join ( " " ) }…"` )
439 . join ( " " ) }` ,
440 );
441 }
442 // RATIO LOCK — poster lockup typography: context is sized FROM the hero, the way a
443 // title system locks its eyebrow/tagline (kicker & tail ≈ 0.26× the hero height,
444 // clamped to [0.05, 0.085]·h). Hero dominance is structural (~4× height + weight
445 // contrast), so the old mass-rule downscale is gone — whisper-small context was the
446 // disease, not the cure. Too MUCH context is a line-count problem, never a size one.
447 for ( const hRef of heroRefs) {
448 const ctx = flat. filter (( l ) => l.bi === hRef.bi);
449 if ( ! ctx. length ) continue ;
450 const heroFracV = fracOfLine (hRef.ln.css || "" ) || 0.24 ;
451 const ratioFrac = + Math. min ( 0.085 , Math. max ( 0.05 , heroFracV * 0.26 )). toFixed ( 4 );
452 if (ctx. length > 3 )
453 console. log (
454 `[make-cinematic] ⚠ lockup-b${ hRef . bi } has ${ ctx . length } context lines — a lockup wants kicker + hero + tail; move narration to the column (a leading clause can be its own block)` ,
455 );
456 const moved = ctx. filter (( l ) => Math. abs (l.frac - ratioFrac) > 0.002 ). length ;
457 ctx. forEach (( l ) => {
458 l.ln.css = (l.ln.css || "" ). replace (
459 /font-size \s * : \s * [ ^ ;] + / ,
460 `font-size: calc(${ ratioFrac } * var(--h))` ,
461 );
462 l.frac = ratioFrac;
463 });
464 if (moved)
465 console. log (
466 `[make-cinematic] lockup-b${ hRef . bi }: context RATIO-LOCKED to 0.26×hero → ${ ratioFrac }·h (${ moved } line(s) re-sized)` ,
467 );
468 }
469 const byPlane = {};
470 flat. forEach (( l ) => {
471 (byPlane[l.plane] = byPlane[l.plane] || []). push (l);
472 });
473 // make sure a lockup whose block has NO context lines still exists in the loop
474 for ( const hRef of heroRefs) if ( ! byPlane[hRef.plane]) byPlane[hRef.plane] = [];
475 for ( const [ pk , ls ] of Object. entries (byPlane)) {
476 const isLockup = / ^ lockup-b/ . test (pk);
477 const budget = isLockup ? H : planeBudgetPx (pk); // a lockup is ONE composition — never paginate inside it
478 const hRef = isLockup ? heroRefs. find (( h ) => h.plane === pk) : null ;
479 const heroH = hRef ? ( fracOfLine (hRef.ln.css || "" ) || 0.24 ) * H * 1.12 + 12 : 0 ;
480 // ORBIT estimate: where do the hero's left/right edges land inside the (frame-wide,
481 // centered) lockup plane — kicker hangs off the left edge, tail off the right
482 let orbit = null ;
483 if (hRef) {
484 const disp = String (hRef.ln.text || (hRef.ln.words || []). join ( " " ));
485 const fpx = ( fracOfLine (hRef.ln.css || "" ) || 0.24 ) * H ;
486 const estW = Math. min (
487 W * 0.94 ,
488 Math. max (fpx * 1.2 , disp. replace ( / \s / g , "" ). length * fpx * 0.6 ),
489 );
490 const planeCss0 = (planes[pk] && planes[pk].css) || "" ;
491 const _pl = ((planeCss0. match ( /left: \s * ( [\d.] + )%/ ) || [ 0 , 0 ])[ 1 ] * W ) / 100 ;
492 const pw = ((planeCss0. match ( /width: \s * ( [\d.] + )%/ ) || [ 0 , 100 ])[ 1 ] * W ) / 100 ;
493 const heroLeft = Math. max ( 0 , (pw - estW) / 2 );
494 // side-aware: anchor context to the CLEANER side of THIS hero's window — the
495 // kicker/tail must land on scene, not on a large/moving subject
496 let side = (sz && sz.subject && sz.subject.clearerSide) || "left" ;
497 try {
498 const hin = Math. min ( ... hRef.ln._w. map (( w ) => w.start));
499 const win = (sz.windows || []). find (( w2 ) => hin >= w2.in - 0.3 && hin <= w2.out + 0.3 );
500 if (win && win.clearerSide) side = win.clearerSide;
501 } catch {}
502 orbit = {
503 left: Math. round (heroLeft),
504 right: Math. round (Math. max ( 0 , pw - heroLeft - estW)),
505 maxW: Math. round (Math. min (estW * 0.75 , pw * 0.55 )),
506 side,
507 };
508 }
509 const pages = [];
510 let page = [],
511 slot = 0 ;
512 for ( const l of ls) {
513 // the HERO occupies its slot between pre- and post-context, in spoken order
514 if (hRef && hRef.slotPx == null && l.lockupPos === "post" ) {
515 hRef.slotPx = Math. round (slot);
516 slot += heroH;
517 }
518 const lineH = l.frac * H * 1.32 + Math. max ( 12 , Math. round (l.frac * H * 0.38 )); // ink overflows the 1.0 line-box (+shadows) — breathe in proportion to the type
519 const prev = page[page. length - 1 ];
520 const pauseGap = prev ? l.first - prev.lastEnd : 0 ;
521 const boundary = prev && l.bi !== prev.bi;
522 // a block carrying a COLUMN-PLACED apex is one composition — never split it on
523 // budget (the oversized hero legitimately exceeds the column's page height)
524 const unbreakable = columnApexBis. has (l.bi) && prev && prev.bi === l.bi;
525 if (
526 page. length &&
527 ! unbreakable &&
528 (slot + lineH > budget || (boundary && l.b.flip === true ) || (boundary && pauseGap >= 0.6 ))
529 ) {
530 pages. push (page);
531 page = [];
532 slot = 0 ;
533 }
534 l.slotPx = Math. round (slot);
535 if (isLockup) l._orbit = orbit;
536 slot += lineH;
537 page. push (l);
538 }
539 if (hRef && hRef.slotPx == null ) {
540 hRef.slotPx = Math. round (slot);
541 } // hero is the block's last line
542 if (page. length ) pages. push (page);
543 pages. forEach (( pg , pi ) => {
544 const nextFirst = pi + 1 < pages. length ? pages[pi + 1 ][ 0 ].first : null ;
545 const maxEnd = Math. max ( ... pg. map (( l ) => l.lastEnd));
546 let out =
547 nextFirst != null
548 ? Math. max (maxEnd + 0.01 , Math. min ( DUR - 0.05 , nextFirst - 0.28 ))
549 : Math. min ( DUR - 0.05 , maxEnd + 1.2 );
550 // THE LOCKUP OWNS THE FRAME: a sidebar page must not linger into an apex
551 // lockup's window (its kicker/tail can land in the same zone → text-on-text)
552 if ( ! isLockup)
553 for ( const hRef2 of heroRefs) {
554 const lkIn = bw[hRef2.bi].first - 0.18 ;
555 if (lkIn > maxEnd - 0.2 && lkIn < out)
556 out = Math. max (maxEnd + 0.01 , Math. min (out, lkIn - 0.28 ));
557 }
558 pg. forEach (( l , li ) => {
559 l.pageIdx = pi;
560 if ( ! isLockup)
561 for ( const hRef2 of heroRefs) {
562 const lkOut = bw[hRef2.bi].out;
563 const lkIn2 = bw[hRef2.bi].first - 0.18 ;
564 const enter0 = Math. max ( 0 , l.first - LEAD );
565 if (enter0 > lkIn2 && enter0 < lkOut)
566 l._delayIn = + Math. min (l.first, Math. max (enter0, lkOut - 0.2 )). toFixed ( 3 );
567 }
568 // a LOCKUP fades as ONE unit: context lines exit with the hero (block window),
569 // never linger on page timing after the hero has left
570 l.out = l.lockupPos ? + Math. min (out, bw[l.bi].out). toFixed ( 3 ) : + out. toFixed ( 3 );
571 l.in = l._delayIn != null ? l._delayIn : + Math. max ( 0 , l.first - LEAD ). toFixed ( 3 );
572 // first line of a NEW page: don't enter before the old page has begun clearing
573 if (li === 0 && pi > 0 )
574 l.in = + Math. min (l.first, Math. max (l.first - LEAD , pages[pi - 1 ][ 0 ].out - 0.02 )). toFixed (
575 3 ,
576 );
577 });
578 });
579 }
580 let gid = 0 ;
581 for ( const l of flat) {
582 l.gid = l.isMinorHero ? `h-${ l . bi }` : `b${ l . bi }-l${ gid ++ }` ;
583 // depth model: lockup CONTEXT embeds (bg) WITH the hero — the orbit anchors it
584 // beside the head/shoulder so it reads on the scene, one shared depth; narration
585 // columns embed too unless the DNA declares bodyLayer:"fg" (loud's announce-style).
586 // Minor heroes ride their column in FRONT. Per-line layer overrides any default.
587 // ORBIT depth: lockup context lands BESIDE the subject (kicker/tail at the hero's
588 // edges) — so it EMBEDS (bg) like the hero; the whole composition shares one depth.
589 // fg remains for: minor heroes (column emphasis), a DNA's bodyLayer (loud announces),
590 // scene verdict FG (globalFg), and explicit per-line "layer" overrides.
591 const defLayer = l.isMinorHero ? "fg" : dnaBodyLayer || "bg" ;
592 groups. push ({
593 id: l.gid,
594 plane: l.plane,
595 layer: globalFg ? "fg" : l.ln.layer || l.b.layer || defLayer,
596 ... (l.isMinorHero ? { hero: true , ... (columnApexBis. has (l.bi) ? {} : { minor: true }) } : {}),
597 tone: l.isMinorHero ? tones.hero || "present" : l.ln.tone || tones.default || "soft" ,
598 allow_overlap: true ,
599 // a MINOR hero holds only to the end of ITS thought (+a breath) — lingering with
600 // the page through a long pause leaves it co-visible with the next apex
601 in: l.in,
602 out: l.isMinorHero ? + Math. min (l.out, bw[l.bi].out + 0.3 ). toFixed ( 3 ) : l.out,
603 // carry the PLANE's text-align onto each slotted line (absolute children don't
604 // reliably inherit through the template's .cap centering — authored "right edge
605 // hugs the silhouette" was silently centered)
606 css:
607 (l.lockupPos && l._orbit
608 ? (l.ln.anchor || (l.lockupPos === "pre" ? l._orbit.side : l._orbit.side)) === "left"
609 ? `position:absolute;top:${ l . slotPx }px;left:${ l . _orbit . left }px;right:auto;max-width:${ l . _orbit . maxW }px;text-align:left; `
610 : `position:absolute;top:${ l . slotPx }px;right:${ l . _orbit . right }px;left:auto;max-width:${ l . _orbit . maxW }px;text-align:right; `
611 : `position:absolute;left:0;right:0;top:${ l . slotPx }px; ` +
612 (() => {
613 const pc =
614 ( C .planes &&
615 C .planes[l.plane] &&
616 ( typeof C .planes[l.plane] === "string"
617 ? C .planes[l.plane]
618 : C .planes[l.plane].css)) ||
619 "" ;
620 const ta = pc. match ( /text-align \s * : \s * (left | right | center)/ );
621 return ta ? `text-align:${ ta [ 1 ] }; ` : "" ;
622 })()) +
623 ensureFontSize (l.ln.css, "font-size: calc(0.05 * var(--h)); font-weight: 600;" ) +
624 (l.lockupPos ? lockupComp : planeComp[l.plane] || "" ),
625 words: l.ln._w. map (( w , wi2 ) => ({
626 // minor/column heroes honor the display-form override too (single-word lines)
627 text: l.isMinorHero && l.ln.text && wi2 === 0 && l.ln._w. length === 1 ? l.ln.text : w.text,
628 start: w.start,
629 end: Math. min (w.end, DUR - 0.05 ),
630 ti: w.ti,
631 })),
632 });
633 }
634 for ( const hRef of heroRefs) {
635 const { bi , ln } = hRef;
636 const w0 = ln._w[ 0 ];
637 groups. push ({
638 id: `h-${ bi }` ,
639 hero: true ,
640 plane: hRef.plane,
641 layer: ln.layer || (heroFeasible ? "bg" : "fg" ),
642 tone: tones.hero || "present" ,
643 allow_overlap: true ,
644 in: + Math. max ( 0 , w0.start - 0.02 ). toFixed ( 3 ),
645 out: + bw[bi].out. toFixed ( 3 ),
646 css:
647 `position:absolute;left:0;right:0;top:${ hRef . slotPx || 0 }px; text-align:center; ` +
648 ensureFontSize (
649 ln.css,
650 "font-size: calc(0.24 * var(--h)); font-weight: 900; white-space: nowrap;" ,
651 ), // case/tracking come from the DNA's hero treatment
652 words: ln._w. map (( w , i ) => ({
653 text: i === 0 && ln.text ? ln.text : w.text,
654 start: w.start,
655 end: Math. min (w.end, DUR - 0.05 ),
656 ti: w.ti,
657 })),
658 });
659 if ( ! heroFeasible)
660 console. log (
661 "[make-cinematic] no hero band ≤62% predicted occlusion → hero rendered in FRONT (last resort)" ,
662 );
663 }
664 // scarcity spacing: heroes must breathe apart (≥ a beat between windows)
665 const heroGs = groups. filter (( g ) => g.hero === true ). sort (( a , b ) => a.in - b.in);
666 for ( let i = 1 ; i < heroGs. length ; i ++ ) {
667 const gap = heroGs[i].in - heroGs[i - 1 ].out;
668 if (gap < 0.6 )
669 console. log (
670 `[make-cinematic] ⚠ heroes "${ heroGs [ i - 1 ]. words [ 0 ]. text }" and "${ heroGs [ i ]. words [ 0 ]. text }" are only ${ gap . toFixed ( 2 ) }s apart — peaks need a beat of air between them (consider demoting one to an emphasis line)` ,
671 );
672 }
673 // proximity: narration planes should HUG the silhouette (the embed reads as in-scene
674 // only when typography interacts with the subject) — warn on far-parked planes.
675 if (sz && sz.subject) {
676 for ( const [ pk2 , pv ] of Object. entries ( C .planes || {})) {
677 if ( / ^ lockup/ . test (pk2) || pk2 === "hero" || pv == null ) continue ;
678 const css2 = typeof pv === "string" ? pv : pv.css || "" ;
679 const lm = css2. match ( /left \s * : \s * ( [\d.] + )%/ ),
680 wm = css2. match ( /width \s * : \s * ( [\d.] + )%/ );
681 if ( ! lm || ! wm) continue ;
682 const pl = + lm[ 1 ],
683 pr = + lm[ 1 ] + + wm[ 1 ];
684 const gap =
685 pr <= sz.subject.colMinPct
686 ? sz.subject.colMinPct - pr
687 : pl >= sz.subject.colMaxPct
688 ? pl - sz.subject.colMaxPct
689 : 0 ;
690 if (gap > 12 )
691 console. log (
692 `[make-cinematic] ⚠ plane "${ pk2 }" sits ${ gap . toFixed ( 0 ) }% away from the silhouette — embeds read in-scene when text HUGS the subject (use safe-zones zones.hugLeft/hugRight, gap 2–6%)` ,
693 );
694 }
695 }
696 const plan = {
697 mode: "template" ,
698 dna: C .dna || ( C .template ? undefined : "cream" ), // canonical: name a DNA; default cream
699 template: C .template || "cinematic-cream" ,
700 compiled_by: "make-cinematic.cjs" ,
701 width: W ,
702 height: H ,
703 fps: FPS ,
704 duration: DUR ,
705 ... (globalFg && ( ! heroRefs. length || ! heroFeasible) ? { caption_layer: "fg" } : {}), // mixed narration-fg + hero-bg → per-group layers (hybrid render)
706 planes: Object. fromEntries (Object. entries (planes). map (([ k , v ]) => [k, { css: v.css }])),
707 groups,
708 };
709 fs. writeFileSync (path. join (project, "plan.json" ), JSON . stringify (plan, null , 2 ));
710 console. log (
711 `[make-cinematic] ${ blocks . length } block(s) → ${ groups . length } group(s)` +
712 `${ heroRefs . length ? `, ${ heroRefs . length } hero(es): ${ heroRefs . map (( h ) => `"${ h . ln . text || h . ln . words [ 0 ] }"` ). join ( " · " ) } (each holds to its block end, lockup-composed)` : ""}` +
713 `${ globalFg ? ", caption_layer=fg (safe-zones verdict)" : ""}, canvas ${ DUR }s` ,
714 );
715 // straight into the existing template compiler
716 const cp2 = require ( "child_process" );
717 let r = cp2. spawnSync ( "node" , [path. join (__dirname, "make-composition.cjs" ), project], {
718 stdio: "inherit" ,
719 });
720 if ((r.status || 0 ) !== 0 ) process. exit (r.status);
721
722 // ── RE-SLOT pass (real measurement). Compile-time slots assume each line renders as
723 // ONE visual line; a narrow plane WRAPS long lines and the next slot overlaps the
724 // wrapped tail. Measure every page's REAL text heights in Chromium and re-stack.
725 {
726 const textBoxH = ( c ) => {
727 const ws = (c.words || []). filter (( w ) => (w.opacity ?? 1 ) > 0.05 && w.w > 0 );
728 if ( ! ws. length ) return c.cap_bbox ? c.cap_bbox.h : 0 ;
729 return Math. max ( ... ws. map (( w ) => w.y + w.h)) - Math. min ( ... ws. map (( w ) => w.y));
730 };
731 const pagesByKey = {};
732 for ( const l of flat) {
733 const key = l.plane + "|" + (l.pageIdx || 0 );
734 (pagesByKey[key] = pagesByKey[key] || { t: + (l.out - 0.1 ). toFixed ( 2 ), lines: [] }).lines. push (
735 l,
736 );
737 }
738 // the HERO is a real member of its lockup's stack — re-slot must move it (and the
739 // post-context below it) from MEASURED heights, or a wrapped pre-line / raised hero
740 // font reopens the very overlap this pass exists to prevent
741 for ( const hRef of heroRefs) {
742 const key = hRef.plane + "|0" ;
743 const hg2 = groups. find (( g2 ) => g2.id === `h-${ hRef . bi }` );
744 if ( ! hg2) continue ;
745 (pagesByKey[key] = pagesByKey[key] || {
746 t: + (hg2.out - 0.1 ). toFixed ( 2 ),
747 lines: [],
748 }).lines. push ({
749 gid: hg2.id,
750 slotPx: hRef.slotPx || 0 ,
751 frac: fracOfLine (hg2.css) || 0.24 ,
752 _hero: true ,
753 });
754 }
755 const times = [ ...new Set (Object. values (pagesByKey). map (( pg ) => pg.t))];
756 if (times. length ) {
757 cp2. spawnSync (
758 "node" ,
759 [path. join (__dirname, "measure-layout.cjs" ), project, ... times. map (String)],
760 { stdio: "ignore" , timeout: 120000 },
761 );
762 let lay = null ;
763 try {
764 lay = JSON . parse (fs. readFileSync (path. join (project, "_layout.json" ), "utf8" ));
765 } catch {}
766 if (lay && lay.samples) {
767 let changes = 0 ;
768 for ( const pg of Object. values (pagesByKey)) {
769 const sample = lay.samples. reduce (( a , b ) =>
770 Math. abs (b.t - pg.t) < Math. abs (a.t - pg.t) ? b : a,
771 );
772 if ( ! sample || Math. abs (sample.t - pg.t) > 0.3 ) continue ;
773 const ordered = [ ... pg.lines]. sort (( a , b ) => a.slotPx - b.slotPx);
774 let top = 0 ;
775 for ( const l of ordered) {
776 const cap = (sample.caps || []). find (( c ) => c.id === l.gid);
777 const realH = cap
778 ? (cap.cap_bbox && cap.cap_bbox.h) || textBoxH (cap)
779 : l.frac * H * 1.25 ;
780 if (Math. abs (top - l.slotPx) > 3 ) {
781 const g = groups. find (( g2 ) => g2.id === l.gid);
782 g.css = g.css. replace ( /top: \s * - ? [\d.] + px/ , "top:" + Math. round (top) + "px" );
783 if (l._hero) {
784 const hr = heroRefs. find (( h ) => `h-${ h . bi }` === l.gid);
785 if (hr) hr.slotPx = Math. round (top);
786 }
787 l.slotPx = Math. round (top);
788 changes ++ ;
789 }
790 top +=
791 (realH || l.frac * H * 1.25 ) + Math. max ( 12 , Math. round ((l.frac || 0.05 ) * H * 0.38 )); // box is lh1.12 — contains its own ink
792 }
793 }
794 if (changes) {
795 console. log (
796 `[make-cinematic] re-slot: ${ changes } line(s) re-stacked from MEASURED heights (wrapped lines accounted)` ,
797 );
798 fs. writeFileSync (path. join (project, "plan.json" ), JSON . stringify (plan, null , 2 ));
799 cp2. spawnSync ( "node" , [path. join (__dirname, "make-composition.cjs" ), project], {
800 stdio: "ignore" ,
801 });
802 try {
803 fs. unlinkSync (path. join (project, "_layout.json" ));
804 } catch {}
805 }
806 }
807 }
808 }
809
810 // ── HERO post-pass (real measurement, up to 2 re-emits) — runs PER HERO ─────
811 // (a) SIZE: a timid hero is the #1 quality kill. If the authored font leaves >25%
812 // of the usable width unused, raise it to the width-fit maximum (DNA sizeRange cap).
813 // (b) CLEARANCE vs OTHER planes: the hero band must not overlap caps outside its own
814 // lockup (its own context is composed around it by the slot layout). On a hit,
815 // shift the whole LOCKUP plane vertically.
816 // (c) LOCKUP RE-STACK: a size change moves the hero's height — restack the lockup's
817 // slots from measured heights so post-context stays bonded BELOW the hero.
818 // HERO words never wrap — a mid-word break ("Watermelo|n") is wrong in every case,
819 // and author css REPLACES the default (which silently lost white-space:nowrap).
820 // Forced here so no authoring path can drop it; centered nowrap text overflows a
821 // narrow plane SYMMETRICALLY, which also keeps width-fit honest (it measures the
822 // true single-line width instead of a wrapped row).
823 // narration lines whipped away by a mid-sentence page-flip are the same disease
824 // in mild form — warn (a deliberate quick beat is legal, so no die)
825 for ( const g of groups)
826 if ( ! g.hero && g.words && g.words. length && g.out - g.in < 0.6 ) {
827 console. log (
828 `[make-cinematic] ⚠ line "${ g . words
829 . map (( w ) => w . text )
830 . join ( " " )
831 . slice (
832 0 ,
833 28 ,
834 ) }" visible only ${ ( g . out - g . in ). toFixed ( 2 ) }s (page-flip) — merge micro-lines or give the plane more height so the page holds` ,
835 );
836 }
837 // MIN DWELL — a peak on screen <0.5s is unreadable ("stars" shipped at 0.47s and
838 // the user couldn't read it). Merge the block with the next (one thought), promote
839 // a word with room, or demote to an emphasized line. <0.8s gets a loud warning.
840 for ( const g of groups)
841 if (g.hero) {
842 const dwell = g.out - g.in;
843 if (dwell < 0.5 )
844 die (
845 `hero "${ ( g . words [ 0 ] || {}). text }" is on screen only ${ dwell . toFixed ( 2 ) }s (<0.5s readability floor) — merge its block with the next, promote a word with more room, or demote it to an emphasized (non-hero) line.` ,
846 );
847 if (dwell < 0.8 )
848 console. log (
849 `[make-cinematic] ⚠ hero "${ ( g . words [ 0 ] || {}). text }" dwell ${ dwell . toFixed ( 2 ) }s is tight (<0.8s) — consider merging blocks so the peak can breathe` ,
850 );
851 }
852 {
853 let nwChanged = false ;
854 for ( const g of groups)
855 if (g.hero && ! /white-space \s * : \s * nowrap/ . test (g.css || "" )) {
856 g.css = (g.css || "" ) + " white-space: nowrap;" ;
857 nwChanged = true ;
858 }
859 if (nwChanged) {
860 fs. writeFileSync (path. join (project, "plan.json" ), JSON . stringify (plan, null , 2 ));
861 require ( "child_process" ). spawnSync (
862 "node" ,
863 [path. join (__dirname, "make-composition.cjs" ), project],
864 { stdio: "ignore" },
865 );
866 }
867 }
868 const fracOf = ( css ) => {
869 const m = (css || "" ). match ( /font-size \s * : \s * calc \( \s * ( [\d.] + ) \s * \* \s * var \( --h \) / );
870 return m ? + m[ 1 ] : null ;
871 };
872 const setFrac = ( css , f ) =>
873 (css || "" ). replace (
874 /font-size \s * : \s * [ ^ ;] + / ,
875 "font-size: calc(" + f. toFixed ( 3 ) + " * var(--h))" ,
876 );
877 const textBox = ( c ) => {
878 const ws = (c.words || []). filter (( w ) => (w.opacity ?? 1 ) > 0.05 && w.w > 0 );
879 if ( ! ws. length ) return c.cap_bbox;
880 const x0 = Math. min ( ... ws. map (( w ) => w.x)),
881 x1 = Math. max ( ... ws. map (( w ) => w.x + w.w));
882 const y0 = Math. min ( ... ws. map (( w ) => w.y)),
883 y1 = Math. max ( ... ws. map (( w ) => w.y + w.h));
884 return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
885 };
886 let dnaSizeCap = 0.34 ,
887 dnaHeroCase = "none" ;
888 try {
889 const dnaLib2 = require ( "./lib-dna.cjs" );
890 const dn = plan.dna || dnaLib2. LEGACY [plan.template];
891 if (dn) {
892 const dj = dnaLib2. load (dn);
893 if (dj.hero && dj.hero.sizeRange) dnaSizeCap = dj.hero.sizeRange[ 1 ];
894 if (dj.hero) dnaHeroCase = dj.hero.case || "none" ;
895 }
896 } catch {}
897 // the APEX (largest authored hero) gets the width-fit raise; minor heroes keep their
898 // authored size — inter-hero hierarchy is a deliberate choice, not timidity
899 const apexFrac = Math. max (
900 ... heroRefs. map (( h ) => fracOf ((groups. find (( g ) => g.id === `h-${ h . bi }` ) || {}).css) || 0 ),
901 0 ,
902 );
903 for ( const hRef of heroRefs) {
904 const heroG = groups. find (( g ) => g.id === `h-${ hRef . bi }` );
905 if ( ! heroG) continue ;
906 const isApex = ( fracOf (heroG.css) || 0 ) >= apexFrac - 1e-6 ;
907 const lockupMates = groups. filter (( g ) => g.plane === hRef.plane && g.id !== heroG.id);
908 // hero size changed → context follows the 0.26 poster ratio, and its orbit
909 // max-width scales WITH the font (a stale max-width wraps re-locked context
910 // into multi-row towers that blow the stack past the frame)
911 const relockMates = ( hf ) => {
912 const rf = + Math. min ( 0.085 , Math. max ( 0.05 , hf * 0.26 )). toFixed ( 4 );
913 for ( const m of lockupMates) {
914 const mf = fracOf (m.css);
915 if ( ! mf || Math. abs (mf - rf) <= 0.002 ) continue ;
916 const scale = rf / mf;
917 m.css = setFrac (m.css, rf). replace (
918 /max-width: \s * ( [\d.] + )px/ ,
919 ( s2 , px ) => "max-width:" + Math. round ( + px * scale) + "px" ,
920 );
921 }
922 };
923 const measure = () => {
924 const midT = ((heroG.in + Math. min (heroG.out, heroG.in + 1.0 )) / 2 ). toFixed ( 2 );
925 const endT = Math. max ( + midT + 0.05 , heroG.out - 0.12 ). toFixed ( 2 );
926 cp2. spawnSync (
927 "node" ,
928 [path. join (__dirname, "measure-layout.cjs" ), project, String (midT), String (endT)],
929 { stdio: "ignore" , timeout: 90000 },
930 );
931 try {
932 const ss = JSON . parse (fs. readFileSync (path. join (project, "_layout.json" ), "utf8" )).samples;
933 const near = ( t ) =>
934 ss. reduce (( a , b ) => (Math. abs (b.t - t) < Math. abs ((a ? a.t : 1e9 ) - t) ? b : a), null );
935 return { caps: ( near ( + midT) || {}).caps || [], capsEnd: ( near ( + endT) || {}).caps || [] };
936 } catch {
937 return null ;
938 }
939 };
940 let didRaise = false ;
941 for ( let pass = 0 ; pass < 5 ; pass ++ ) {
942 const m0 = measure ();
943 if ( ! m0) break ;
944 const caps = m0.caps,
945 capsEnd = m0.capsEnd && m0.capsEnd. length ? m0.capsEnd : m0.caps;
946 const hc0 = caps. find (( c ) => c.id === heroG.id);
947 if ( ! hc0 || ! hc0.cap_bbox) break ;
948 const hc = { ... hc0, cap_bbox: textBox (hc0) };
949 const mateIds = new Set (lockupMates. map (( m ) => m.id));
950 const others = caps
951 . filter (
952 ( c ) =>
953 c.id !== heroG.id &&
954 ! mateIds. has (c.id) &&
955 ! /-glow $ / . test (c.id || "" ) &&
956 c.cap_bbox &&
957 c.opacity > 0.05 ,
958 )
959 . map (( c ) => ({ ... c, cap_bbox: textBox (c) }));
960 const maxW = W * 0.94 ;
961 const f0 = fracOf (heroG.css);
962 let changed = false ;
963 if (f0 && hc.cap_bbox.w > 0 ) {
964 if (hc.cap_bbox.w > maxW) {
965 const nf = Math. max ( 0.06 , (f0 * maxW) / hc.cap_bbox.w);
966 console. log (
967 `[make-cinematic] hero "${ heroG . words [ 0 ]. text }" too WIDE ${ Math . round ( hc . cap_bbox . w ) }px → ${ f0 }h→${ nf . toFixed ( 3 ) }h` ,
968 );
969 heroG.css = setFrac (heroG.css, nf);
970 changed = true ;
971 } else if (hc.cap_bbox.w >= maxW * 0.88 ) {
972 if (pass === 0 )
973 console. log (
974 `[make-cinematic] hero "${ heroG . words [ 0 ]. text }" at width CEILING (${ Math . round ( hc . cap_bbox . w ) }/${ Math . round ( maxW ) }px) — cannot enlarge.` ,
975 );
976 } else if ( ! didRaise && hc.cap_bbox.w < maxW * 0.88 && isApex) {
977 // NO DEAD ZONE: anything under 88% of usable width is raised toward a 93%
978 // fill (the old <75% trigger let 75–88%-wide heroes stay merely "decent")
979 const fit = (f0 * (maxW * 0.93 )) / hc.cap_bbox.w;
980 const nf = Math. min (dnaSizeCap, fit);
981 if (nf > f0 * 1.05 || fit > dnaSizeCap * 1.02 ) {
982 if (nf > f0 * 1.001 )
983 console. log (
984 `[make-cinematic] hero "${ heroG . words [ 0 ]. text }" TIMID (${ Math . round ( hc . cap_bbox . w ) }px of ${ Math . round ( maxW ) }px usable) → ${ f0 }h→${ nf . toFixed ( 3 ) }h (width-fit max)` ,
985 );
986 // SHORT-WORD FILL: height cap bound before the width target → fill with
987 // TRACKING (film-title craft — HER, DUNE), ≤0.32em so it stays a word.
988 const wAtCap = (hc.cap_bbox.w * nf) / f0;
989 const dispT = String (heroG.text || (heroG.words || []). map (( w ) => w.text). join ( " " ));
990 // never letterspace lowercase (tracked lowercase falls apart — caps only)
991 const capsy = dnaHeroCase === "uppercase" || dispT === dispT. toUpperCase ();
992 if (fit > dnaSizeCap && wAtCap < maxW * 0.85 && capsy) {
993 const disp = dispT;
994 // letter-spacing adds a gap after EVERY char (N gaps) and the centering
995 // text-indent adds one more — budget N+1 gaps or the word leaves the frame
996 const gaps = disp. length + 1 ;
997 const tr = + Math. min ( 0.32 , (maxW * 0.88 - wAtCap) / (gaps * nf * H )). toFixed ( 3 );
998 if (tr >= 0.04 ) {
999 heroG.css =
1000 heroG.css
1001 . replace ( /letter-spacing \s * : [ ^ ;] + ; ? \s * / g , "" )
1002 . replace ( /text-indent \s * : [ ^ ;] + ; ? \s * / g , "" ) +
1003 (heroG.css. trim (). endsWith ( ";" ) || ! heroG.css. trim () ? "" : ";" ) +
1004 ` letter-spacing:${ tr }em; text-indent:${ tr }em;` ;
1005 console. log (
1006 `[make-cinematic] hero "${ disp }" short word at height cap (${ Math . round ( wAtCap ) }px of ${ Math . round ( maxW ) }px) → tracked +${ tr }em to fill` ,
1007 );
1008 }
1009 }
1010 heroG.css = setFrac (heroG.css, nf);
1011 changed = true ;
1012 didRaise = true ;
1013 relockMates (nf);
1014 }
1015 }
1016 }
1017 const hbx = hc.cap_bbox;
1018 // colliders OUTSIDE the lockup only — the lockup composes around the hero by design
1019 const hits = others. filter (( o ) => {
1020 const b = o.cap_bbox;
1021 const ix = Math. min (hbx.x + hbx.w, b.x + b.w) - Math. max (hbx.x, b.x);
1022 const iy = Math. min (hbx.y + hbx.h, b.y + b.h) - Math. max (hbx.y, b.y);
1023 return ix > 8 && iy > 8 ;
1024 });
1025 if (hits. length ) {
1026 const maxBottom = Math. max ( ... hits. map (( o ) => o.cap_bbox.y + o.cap_bbox.h));
1027 const minTop = Math. min ( ... hits. map (( o ) => o.cap_bbox.y));
1028 const downTo = ((maxBottom + 14 ) / H ) * 100 ;
1029 const upTo = Math. max ( 2 , ((minTop - hbx.h - 14 ) / H ) * 100 );
1030 const planeCss = plan.planes[hRef.plane].css;
1031 const curTop = + (planeCss. match ( /top: \s * ( [\d.] + )%/ ) || [ 0 , 30 ])[ 1 ];
1032 const newTop = downTo + (hbx.h / H ) * 100 < 96 ? downTo : upTo;
1033 plan.planes[hRef.plane].css = planeCss. replace (
1034 /top: \s * [\d.] + %/ ,
1035 "top: " + newTop. toFixed ( 1 ) + "%" ,
1036 );
1037 console. log (
1038 `[make-cinematic] hero "${ heroG . words [ 0 ]. text }" OVERLAPS ${ hits . map (( o ) => o . id ). join ( "," ) } — lockup top ${ curTop }% → ${ newTop . toFixed ( 1 ) }%` ,
1039 );
1040 changed = true ;
1041 }
1042 // ORBIT REFINE: anchor kicker/tail to the MEASURED hero edges (estimate → real)
1043 if (lockupMates. length && hc.cap_bbox && hc.cap_bbox.w > 0 ) {
1044 const planeCss2 = (plan.planes[hRef.plane] && plan.planes[hRef.plane].css) || "" ;
1045 const pl2 = ((planeCss2. match ( /left: \s * ( [\d.] + )%/ ) || [ 0 , 0 ])[ 1 ] * W ) / 100 ;
1046 const relL = Math. max ( 0 , Math. round (hc.cap_bbox.x - pl2));
1047 const pw2 = ((planeCss2. match ( /width: \s * ( [\d.] + )%/ ) || [ 0 , 100 ])[ 1 ] * W ) / 100 ;
1048 const relR = Math. max ( 0 , Math. round (pw2 - (hc.cap_bbox.x - pl2) - hc.cap_bbox.w));
1049 for ( const m of lockupMates) {
1050 if ( /left: \s * \d + px;right:auto/ . test (m.css)) {
1051 const nc = m.css. replace ( /left: \s * \d + px/ , "left:" + relL + "px" );
1052 if (nc !== m.css) {
1053 m.css = nc;
1054 changed = true ;
1055 }
1056 } else if ( /right: \s * \d + px;left:auto/ . test (m.css)) {
1057 const nc = m.css. replace ( /right: \s * \d + px/ , "right:" + relR + "px" );
1058 if (nc !== m.css) {
1059 m.css = nc;
1060 changed = true ;
1061 }
1062 }
1063 }
1064 }
1065 // LOCKUP RE-STACK from measured heights — runs EVERY pass for lockups (orbit
1066 // max-widths wrap context lines, so the re-slot's estimates can leave the tail
1067 // displaced or the whole composition off frame)
1068 if (lockupMates. length ) {
1069 const caps2 = capsEnd; // lockup-END sample: every context line is revealed there
1070 // TOWER CHECK — a context line wrapped to 3+ rows means its orbit max-width is
1071 // too narrow for the (re-locked) font; widen toward the plane before stacking,
1072 // or the tower blows the stack past the frame and the walk-back eats the hero
1073 for ( const m of lockupMates) {
1074 const cm = caps2. find (( x ) => x.id === m.id);
1075 const frm = fracOf (m.css) || 0.05 ;
1076 const mwM = m.css. match ( /max-width: \s * ( [\d.] + )px/ );
1077 if ( ! cm || ! cm.cap_bbox || ! mwM) continue ;
1078 const rows = Math. round (cm.cap_bbox.h / (frm * H * 1.12 ));
1079 if (rows >= 3 ) {
1080 const planeCssW2 = (plan.planes[hRef.plane] && plan.planes[hRef.plane].css) || "" ;
1081 const pwX = W * ( + (planeCssW2. match ( /width: \s * ( [\d.] + )%/ ) || [ 0 , 92 ])[ 1 ] / 100 );
1082 const nw = Math. round (pwX * 0.62 );
1083 if (nw > + mwM[ 1 ] + 10 ) {
1084 m.css = m.css. replace ( /max-width: \s * [\d.] + px/ , "max-width:" + nw + "px" );
1085 console. log (
1086 `[make-cinematic] context "${ (( m . words || [])[ 0 ] || {}). text }…" wrapped to ${ rows } rows — orbit max-width ${ Math . round ( + mwM [ 1 ]) }→${ nw }px (towers break the stack)` ,
1087 );
1088 changed = true ;
1089 }
1090 }
1091 }
1092 const entries = [heroG, ... lockupMates]
1093 . map (( g ) => {
1094 const c = caps2. find (( x ) => x.id === g.id);
1095 const curTop = + (g.css. match ( /top: \s * (- ? [\d.] + )px/ ) || [ 0 , 0 ])[ 1 ];
1096 const fr = fracOf (g.css) || 0.05 ;
1097 // SLOT HEIGHT = the LAYOUT box (cap_bbox), not the glyph box: words reveal
1098 // over time, so at the hero-mid sample a context line's later words are
1099 // still opacity-0 — glyph-box measured a wrapped line one row short and the
1100 // next line slotted INTO its wrap band (real text-on-text shipped).
1101 const measuredH = c && c.cap_bbox ? c.cap_bbox.h : null ;
1102 const h2 = g.id === heroG.id && didRaise ? fr * H * 1.12 : measuredH || fr * H * 1.25 ;
1103 return { g, curTop, h: h2 };
1104 })
1105 . sort (( a , b ) => a.curTop - b.curTop);
1106 let top = 0 ;
1107 for ( const e of entries) {
1108 if (Math. abs (top - e.curTop) > 3 ) {
1109 e.g.css = e.g.css. replace ( /top: \s * - ? [\d.] + px/ , "top:" + Math. round (top) + "px" );
1110 changed = true ;
1111 }
1112 top += e.h + Math. max ( 12 , Math. round (( fracOf (e.g.css) || 0.05 ) * H * 0.38 )); // box is lh1.12 — contains its own ink
1113 }
1114 // RE-CLAMP the plane after MEASURED growth — keep the whole composition on frame
1115 const planeCss3 = plan.planes[hRef.plane].css;
1116 const curTopPct = + (planeCss3. match ( /top: \s * ( [\d.] + )%/ ) || [ 0 , 30 ])[ 1 ];
1117 const maxTopPct = Math. max ( 2 , (( H * 0.97 - top) / H ) * 100 );
1118 if (curTopPct > maxTopPct + 0.3 ) {
1119 plan.planes[hRef.plane].css = planeCss3. replace (
1120 /top: \s * [\d.] + %/ ,
1121 "top: " + maxTopPct. toFixed ( 1 ) + "%" ,
1122 );
1123 console. log (
1124 `[make-cinematic] lockup measured ${ Math . round ( top ) }px tall — plane top ${ curTopPct }% → ${ maxTopPct . toFixed ( 1 ) }% (kept on frame)` ,
1125 );
1126 changed = true ;
1127 }
1128 // even at the 2% ceiling the stack can exceed the frame (TIMID raise + ratio
1129 // context on a tall lockup) — walk the hero back 8% and re-lock until it fits
1130 if (top > H * 0.95 && maxTopPct <= 2.01 ) {
1131 const f0c = fracOf (heroG.css);
1132 if (f0c && f0c > 0.14 ) {
1133 // decisive step: stack height scales ~linearly with the hero (context is
1134 // ratio-locked to it), so aim straight at a 93% fill instead of nibbling
1135 const nf2 = + Math. max ( 0.14 , Math. min (f0c * 0.92 , (f0c * ( H * 0.93 )) / top)). toFixed ( 3 );
1136 console. log (
1137 `[make-cinematic] lockup stack ${ Math . round ( top ) }px exceeds the frame even at top 2% — hero ${ f0c }h → ${ nf2 }h (fit)` ,
1138 );
1139 heroG.css = setFrac (heroG.css, nf2);
1140 relockMates (nf2);
1141 didRaise = true ;
1142 changed = true ;
1143 }
1144 }
1145 }
1146 if ( ! changed) break ;
1147 fs. writeFileSync (path. join (project, "plan.json" ), JSON . stringify (plan, null , 2 ));
1148 r = cp2. spawnSync ( "node" , [path. join (__dirname, "make-composition.cjs" ), project], {
1149 stdio: "ignore" ,
1150 });
1151 try {
1152 fs. unlinkSync (path. join (project, "_layout.json" ));
1153 } catch {}
1154 }
1155 }
1156 // MINOR HERO measure pass — minors keep their authored size (hierarchy is a choice)
1157 // but never ship broken: wider than the frame's usable width shrinks to fit, and a
1158 // column overflow gets a loud note (centered spill is legal on a clear side only).
1159 {
1160 const minors = groups. filter (( g ) => g.hero && ! heroRefs. some (( h ) => `h-${ h . bi }` === g.id));
1161 let minorChanged = false ;
1162 for ( const mg of minors) {
1163 const midT = ((mg.in + Math. min (mg.out, mg.in + 1.0 )) / 2 ). toFixed ( 2 );
1164 cp2. spawnSync ( "node" , [path. join (__dirname, "measure-layout.cjs" ), project, String (midT)], {
1165 stdio: "ignore" ,
1166 timeout: 60000 ,
1167 });
1168 let mc = null ;
1169 try {
1170 mc = (
1171 JSON . parse (fs. readFileSync (path. join (project, "_layout.json" ), "utf8" )).samples[ 0 ].caps ||
1172 []
1173 ). find (( c ) => c.id === mg.id);
1174 } catch {}
1175 if ( ! mc || ! mc.cap_bbox) continue ;
1176 const bb = textBox (mc),
1177 f0 = fracOf (mg.css);
1178 if (f0 && bb.w > W * 0.94 ) {
1179 const nf = Math. max ( 0.06 , + ((f0 * ( W * 0.94 )) / bb.w). toFixed ( 3 ));
1180 console. log (
1181 `[make-cinematic] minor hero "${ ( mg . words [ 0 ] || {}). text }" too WIDE ${ Math . round ( bb . w ) }px → ${ f0 }h→${ nf . toFixed ( 3 ) }h (shrink-to-fit)` ,
1182 );
1183 mg.css = setFrac (mg.css, nf);
1184 minorChanged = true ;
1185 } else {
1186 const planeCssM = (plan.planes[mg.plane] && plan.planes[mg.plane].css) || "" ;
1187 const pwPx = W * ( + (planeCssM. match ( /width: \s * ( [\d.] + )%/ ) || [ 0 , 100 ])[ 1 ] / 100 );
1188 if (bb.w > pwPx * 1.05 )
1189 console. log (
1190 `[make-cinematic] note: minor hero "${ ( mg . words [ 0 ] || {}). text }" (${ Math . round ( bb . w ) }px) spills its ${ Math . round ( pwPx ) }px column — fine on a clear side; reduce size if it crosses the subject` ,
1191 );
1192 }
1193 try {
1194 fs. unlinkSync (path. join (project, "_layout.json" ));
1195 } catch {}
1196 }
1197 if (minorChanged) {
1198 fs. writeFileSync (path. join (project, "plan.json" ), JSON . stringify (plan, null , 2 ));
1199 cp2. spawnSync ( "node" , [path. join (__dirname, "make-composition.cjs" ), project], {
1200 stdio: "ignore" ,
1201 });
1202 }
1203 }
1204 process. exit ( 0 );
1205 }
1206 main ();