#!/usr/bin/env node /* * make-cinematic.cjs — compile cinematic.json → plan.json (then make-composition.cjs). * CINEMATIC MODE, raised to BLOCK level. The agent authors thought-BLOCKS — lines of * words, which plane each block stacks in, at most one promoted HERO word — and the * compiler lowers that into the classic plan.json with everything deterministic * generated, so the Cinematic failure modes seen in the wild become impossible: * - READING ORDER by construction: lines stack in declaration order inside a flex * plane (spoken order = visual order; a hero can never sit below later words) * - timings from the transcript BY SEQUENCE (no hand-copied times; duplicates by position) * - accumulate-within-block / page-flip-between-blocks rhythm generated: * lines of a block share the block's out (they accumulate, then clear together * when the next block's first word lands); the NEXT block enters fresh * - hero hand-off: the promoted word is lifted OUT of its line, enters as the hero * (its own plane, BIG) when spoken, holds to the END of its block, exits cleanly * - fg fallback: safe-zones verdict "fg" → caption_layer:"fg" automatically * * node make-cinematic.cjs * * cinematic.json schema: * { * "template": "cinematic-cream", * "width": 1920, "height": 1080, "fps": 30, * "planes": { // layout REGIONS — place them using safe-zones.json * "narr": "top: 14%; left: 4%; width: 26%;", // string or {css} * "hero": null // null/absent → auto from safe-zones heroAnchor * }, * "blocks": [ * { "plane": "narr", "lines": [ * { "words": ["You","need","to"], "css": "font-size: calc(0.05*var(--h)); font-weight:600;" }, * { "words": ["judge","us","by","the"], "css": "font-size: calc(0.062*var(--h)); font-weight:700;" }, * { "words": ["actions"], "hero": true, * "text": "ACTIONS", "css": "font-size: calc(0.24*var(--h)); font-weight:900; text-transform:uppercase;" }, * { "words": ["that","we","take."], "css": "font-size: calc(0.05*var(--h));" }, * // per-line escape hatch: "layer":"fg" lifts a line ABOVE the subject matte * // (use when the occlusion gate reports a context line eaten by the subject) * { "words": ["over","the","subject"], "layer": "fg" } * ] }, * { "plane": "narr", "lines": [ ... next thought ... ] } * ], * "tones": { "default": "soft", "hero": "present" } // optional * } * Rules: words must match the transcript verbatim in spoken order across all blocks. * Mark at most ONE line "hero": true per clip (scarcity); its words form the hero * (usually a single word; "text" overrides the display form). */ const path = require("path"); const fs = require("fs"); const norm = (s) => String(s == null ? "" : s) .toLowerCase() .replace(/[^a-z0-9']/g, ""); const LOOKAHEAD = 40; // Authored css WITHOUT a font-size renders at browser-default ~16px (an // invisible ribbon) and every gate still passes — observed in the cold-start // E2E. Guarantee the default is present whenever the author omitted it. function ensureFontSize(css, defaultDecl) { if (!css) return defaultDecl; return /font-size\s*:/.test(css) ? css : defaultDecl + " " + css; } function die(m) { console.error(`[make-cinematic] ${m}`); process.exit(1); } function main() { const project = path.resolve(process.argv[2] || ""); if (!process.argv[2]) die("usage: make-cinematic.cjs "); const cj = path.join(project, "cinematic.json"); const tj = path.join(project, "transcript.json"); if (!fs.existsSync(cj)) die(`missing ${cj} — author it first (schema in this header)`); if (!fs.existsSync(tj)) die(`missing ${tj} — run prepare.sh first`); const C = JSON.parse(fs.readFileSync(cj, "utf8")); const tr = (JSON.parse(fs.readFileSync(tj, "utf8")).words || []).filter( (w) => w && "start" in w && "end" in w, ); if (!tr.length) die("transcript has no word timings"); // safe-zones: fg verdict + heroAnchor default let sz = null; try { sz = JSON.parse(fs.readFileSync(path.join(project, "safe-zones.json"), "utf8")); } catch {} // author override first (borderline scenes: coverage near the line, agitated subject), // else the safe-zones verdict. // narration legibility follows the verdict; the HERO is judged separately — it WANTS // occlusion (~30–55% is the product). fg for the hero is the LAST resort: only when no // height band achieves ≤62% predicted occlusion (safe-zones heroBands). An explicit // author caption_layer:"fg" still forces everything front. const authorFg = C.caption_layer === "fg"; const narrFg = authorFg || (sz && sz.recommendation === "fg"); const hb = sz && sz.heroBands; const heroFeasible = authorFg ? false : hb ? hb.feasible : !narrFg; const globalFg = narrFg; // narration layer (name kept for the lowering below) const W = C.width || 1920, H = C.height || 1080, FPS = C.fps || 24; const blocks = C.blocks || []; if (!blocks.length) die("blocks is empty"); // ── sequence-match every word across blocks/lines ────────────────────────── let p = 0; const missing = []; for (const b of blocks) for (const ln of b.lines || []) { ln._w = (ln.words || []).map((text) => { const t = norm(text); let f = -1; for (let j = p; j < Math.min(tr.length, p + LOOKAHEAD); j++) if (norm(tr[j].text) === t) { f = j; break; } if (f < 0) { missing.push(text); return { text, start: null, end: null }; } p = f + 1; return { text, start: tr[f].start, end: tr[f].end, ti: f }; }); } if (missing.length) die( `words not in transcript (in order): ${missing.join(" ")} — lines must match the transcript verbatim`, ); // COMPLETENESS GATE — verbatim is a hard rule, so enforce it at compile time: the // sequence matcher's lookahead can silently SKIP transcript words the author forgot // (a real case shipped missing "in our newly released model."). Every transcript word // must be consumed; the only legitimate drops are filler (um/uh/stutters), declared in // cinematic.json "drops": ["um", ...]. { const consumed = new Set(); for (const b of blocks) for (const ln of b.lines || []) for (const w of ln._w) if (w.ti != null) consumed.add(w.ti); const dropOk = new Set((C.drops || []).map(norm)); const FILLER = new Set(["um", "uh", "er", "ah", "hmm", "mm"]); const skipped = tr .map((w, i) => ({ w, i })) .filter( ({ w, i }) => !consumed.has(i) && !dropOk.has(norm(w.text)) && !FILLER.has(norm(w.text)), ); if (skipped.length) die( `VERBATIM VIOLATION — ${skipped.length} transcript word(s) never authored (the matcher skipped them): ` + `${skipped.map(({ w }) => `"${w.text}"`).join(" ")} — add them to a line, or declare true filler in "drops": []`, ); } // text override + multiple words double-renders (override replaces word 1, words // 2..n still draw) — caught cold by a clean-room agent; illegal for ANY hero line. for (const b of blocks) for (const ln of b.lines || []) { if (ln.hero && ln.text && (ln.words || []).length > 1) die( `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.`, ); } // ── block windows: in = first word −lead; out = page-flip = next block's first word −0.28 (or +1.2 hold) ── const LEAD = 0.18; const bw = blocks.map((b) => { const ws = (b.lines || []).flatMap((l) => l._w); return { first: Math.min(...ws.map((w) => w.start)), last: Math.max(...ws.map((w) => w.end)) }; }); let srcDur = null; try { const cp = require("child_process"); for (const f of ["source.mp4"].concat( fs.readdirSync(project).filter((x) => /\.(mp4|mov|webm)$/i.test(x)), )) { const fp = path.join(project, f); if (!fs.existsSync(fp)) continue; const d = parseFloat( cp.execFileSync( "ffprobe", [ "-v", "error", "-show_entries", "format=duration", "-of", "default=nokey=1:noprint_wrappers=1", fp, ], { encoding: "utf8" }, ), ); if (d > 0) { srcDur = d; break; } } } catch {} const DUR = +(srcDur || bw[bw.length - 1].last + 0.6).toFixed(3); // ── SLOT layout + paging. Lines get FIXED absolute positions (no flex reflow — the // "lower line jumps up when the upper one clears" bug is impossible by construction). // Lines accumulate down the plane like a poem; the plane PAGE-FLIPS (all visible lines // clear together, the next line restarts at the top) only when (a) the plane is full, // (b) a block sets "flip": true, or (c) a block boundary coincides with a real speech // pause (≥0.6s). Block windows (bw) are kept only for the hero's hold. for (let i = 0; i < bw.length; i++) { bw[i].in = Math.max(0, bw[i].first - LEAD); bw[i].out = Math.min(DUR - 0.05, Math.min(bw[i].last, DUR - 0.05) + 0.9); // a block's hold (incl. a LOCKUP's) yields to the NEXT block's entrance — the same // page-flip semantics columns already have; floor: its own words must finish if (i + 1 < bw.length) bw[i].out = Math.max( Math.min(bw[i].last, DUR - 0.05) + 0.15, Math.min(bw[i].out, bw[i + 1].first - 0.28), ); } const fracOfLine = (css) => { const m = (css || "").match(/font-size\s*:\s*calc\(\s*([\d.]+)\s*\*\s*var\(--h\)/); return m ? +m[1] : 0.05; }; const planeBudgetPx = (pk) => { const css = (C.planes && C.planes[pk] && (typeof C.planes[pk] === "string" ? C.planes[pk] : C.planes[pk].css)) || ""; const m = css.match(/height\s*:\s*([\d.]+)%/); return (m ? +m[1] / 100 : 0.3) * H; }; // ── planes: agent's + auto LOCKUP planes (one per hero block) ─────────────── // The LOCKUP is the layout answer to "the sentence reads broken around the hero": // a hero block's lines do NOT scatter into the narration column — kicker, HERO and // tail compose as ONE bonded ORBIT (context anchored at the hero's edges, diagonal // read). DEPTH: context EMBEDS (bg) with the hero — one shared depth; per-line // `layer:"fg"` is the escape hatch when the matte eats a context line (gate tells you). const planes = {}; for (const [k, v] of Object.entries(C.planes || {})) { if (v == null) continue; let css = typeof v === "string" ? v : v.css || ""; if (!/text-align\s*:/.test(css)) css += " text-align:center;"; // slot children are absolute; only alignment carries planes[k] = { css }; } // multiple heroes allowed — scarcity is per BLOCK (≤1 per thought), not per clip. // The LARGEST authored hero is the APEX: it gets the full lockup embed (centered on // the subject, matte showcase). Smaller heroes are MINOR peaks: they stay IN their // column's flow as oversized emphasis lines (fg, hero motion at reduced amplitude) — // not every beat needs the matte showcase; that's what keeps the apex an event. const allHeroBlocks = []; blocks.forEach((b, bi) => { const hs = (b.lines || []).filter((l) => l.hero === true); if (hs.length > 1) die( `block ${bi} has ${hs.length} hero lines — at most ONE hero per block (scarcity = per beat)`, ); if (hs.length === 1) allHeroBlocks.push({ b, bi, ln: hs[0], frac: fracOfLine(hs[0].css) || 0.24 }); }); const apexFracAll = Math.max(...allHeroBlocks.map((h) => h.frac), 0); // placement: "column" keeps even an APEX in its column's flow (the hero bursts the // column instead of crossing the subject) — the sanctioned alternative to the // subject-anchored lockup. Author it per hero line: { "hero": true, "placement": "column" }. const heroBlocks = allHeroBlocks.filter( (h) => h.frac >= apexFracAll - 1e-6 && (h.ln.placement || "subject") !== "column", ); const minorHeroBis = new Set( allHeroBlocks .filter((h) => h.frac < apexFracAll - 1e-6 || (h.ln.placement || "subject") === "column") .map((h) => h.bi), ); const columnApexBis = new Set( allHeroBlocks .filter((h) => h.frac >= apexFracAll - 1e-6 && (h.ln.placement || "subject") === "column") .map((h) => h.bi), ); if (minorHeroBis.size) console.log( `[make-cinematic] ${minorHeroBis.size} minor hero(es) stay in the column flow (apex gets the lockup embed)`, ); const lockupName = (bi) => `lockup-b${bi}`; const lockupTemplate = C.planes && C.planes.lockup ? typeof C.planes.lockup === "string" ? C.planes.lockup : C.planes.lockup.css : null; delete planes.lockup; // template only — not a real plane for (const hRef of heroBlocks) { const a = sz && sz.heroAnchor && sz.heroAnchor.plane; // band pick: target ~40% occlusion AND a band the DNA's text can survive on // (skip bright bands when luma data exists) — heroAnchor.yPct is the fallback let heroTop = a ? a.yPct : 30; if (hb && hb.profile && hb.profile.length) { const ok = hb.profile.filter( (b2) => b2.occPct >= 12 && b2.occPct <= 62 && (b2.bgLuma == null || b2.bgLuma <= 165), ); const pool = ok.length ? ok : hb.profile; const best = pool.reduce((x, y) => Math.abs(y.occPct - 40) < Math.abs(x.occPct - 40) ? y : x, ); heroTop = +(best.topPct + 6.5).toFixed(1); } const heroFrac = (hRef.ln.css || "").match(/calc\(\s*([\d.]+)\s*\*\s*var\(--h\)/); // the lockup is taller than the hero alone (context above+below) — anchor so the // HERO band (not the plane top) lands on the target, then clamp so the WHOLE // lockup (pre + hero + post) stays on frame const heroIdx2 = (hRef.b.lines || []).indexOf(hRef.ln); const preH = (hRef.b.lines || []) .filter((l, i) => l !== hRef.ln && i < heroIdx2) .reduce( (s, l) => s + (fracOfLine(l.css) * 100 * 1.32 + Math.max(1.6, fracOfLine(l.css) * 100 * 0.38)), 0, ); const postH = (hRef.b.lines || []) .filter((l, i) => l !== hRef.ln && i > heroIdx2) .reduce( (s, l) => s + (fracOfLine(l.css) * 100 * 1.32 + Math.max(1.6, fracOfLine(l.css) * 100 * 0.38)), 0, ); const heroHpct = (heroFrac ? +heroFrac[1] : 0.24) * 100 * 1.12; const totalH = preH + heroHpct + postH; let top = +Math.max(2, Math.min(96 - totalH, heroTop - heroHpct / 2 - preH)).toFixed(1); const css = lockupTemplate ? lockupTemplate.replace(/top:\s*[\d.]+%/, `top: ${top}%`) : a ? `top: ${top}%; left: ${a.xPct}%; width: ${a.wPct}%; text-align: center;` : `top: ${top}%; left: 6%; width: 88%; text-align: center;`; planes[lockupName(hRef.bi)] = { css }; hRef.plane = lockupName(hRef.bi); } // legacy alias: an author-defined "hero" plane positions the FIRST lockup if (planes.hero) { if (heroBlocks.length) { planes[lockupName(heroBlocks[0].bi)] = planes.hero; } delete planes.hero; } // ── PER-PLANE LEGIBILITY COMPENSATION ──────────────────────────────────── // The DNA fixes hue + blend; the SCENE decides whether that survives. Match each // plane to its best-overlapping measured zone and ladder the treatment: // luma > 150 → opaque (blend:normal) + strong tight scrim — screen/blend washes out // luma 95–150 → keep the DNA blend, strengthen the glyph scrim const planeComp = {}; if (sz && sz.zones) { const zlist = Object.entries(sz.zones).filter(([, z]) => z && z.meanLuma != null); for (const [pk, pv] of Object.entries(C.planes || {})) { const css0 = typeof pv === "string" ? pv : (pv || {}).css || ""; const lm = css0.match(/left:\s*([\d.]+)%/), wm = css0.match(/width:\s*([\d.]+)%/); if (!lm || !wm) continue; const a0 = +lm[1], a1 = a0 + +wm[1]; let best = null, bestOv = 0; for (const [, z] of zlist) { const ov = Math.min(a1, z.xPct + z.wPct) - Math.max(a0, z.xPct); if (ov > bestOv) { bestOv = ov; best = z; } } if (!best) continue; // gate on the time PEAK too — a dark wall swept by a bright moving object // (handheld drift, screens) averages "clean" but peaks hot, and text there // washes out exactly when the bright thing passes (real cold-start case) const pkL = best.peakLuma != null ? best.peakLuma : best.meanLuma; if (best.meanLuma > 150 || pkL > 175) planeComp[pk] = " mix-blend-mode: normal; text-shadow: 0 1px 6px rgba(0,0,0,.65), 0 2px 16px rgba(0,0,0,.4);"; else if (best.meanLuma > 95 || pkL > 135) planeComp[pk] = " text-shadow: 0 1px 5px rgba(0,0,0,.6), 0 2px 14px rgba(0,0,0,.35);"; if (planeComp[pk]) console.log( `[make-cinematic] plane "${pk}" zone luma ${best.meanLuma} (peak ${pkL}) → legibility compensation (${best.meanLuma > 150 || pkL > 175 ? "opaque+scrim" : "scrim"})`, ); } } // lockup context shares the hero band's luma const bandLuma = sz && sz.heroAnchor && sz.heroAnchor.bandLuma; const lockupComp = bandLuma != null && bandLuma > 150 ? " mix-blend-mode: normal; text-shadow: 0 1px 6px rgba(0,0,0,.65), 0 2px 16px rgba(0,0,0,.4);" : bandLuma != null && bandLuma > 95 ? " text-shadow: 0 1px 5px rgba(0,0,0,.6), 0 2px 14px rgba(0,0,0,.35);" : ""; // ── lower to classic plan.json groups (slot layout + per-plane paging) ───── const tones = C.tones || {}; let dnaBodyLayer = null; try { const dnaLib0 = require("./lib-dna.cjs"); const dn0 = C.dna || (C.template ? dnaLib0.LEGACY[C.template] : "cream"); if (dn0) { const d0 = dnaLib0.load(dn0); dnaBodyLayer = d0.bodyLayer || null; // category lock (symmetric): column is every classic DNA's home today, // so this only fires if a future DNA declares a different home. if (d0.deliveries && d0.deliveries.home && d0.deliveries.home !== "column") console.warn( `[make-cinematic] WARN: DNA "${dn0}" declares home="${d0.deliveries.home}" — cinematic use is cross-category and unvalidated.`, ); } } catch (e) { if (e.message && e.message.includes("does not support")) throw e; } const groups = []; const heroRefs = heroBlocks; // [{b, bi, ln, plane}] const isHeroBlock = new Set(heroBlocks.map((h) => h.bi)); const flat = []; blocks.forEach((b, bi) => { const heroIdx = isHeroBlock.has(bi) ? (b.lines || []).findIndex((l) => l.hero === true) : -1; (b.lines || []).forEach((ln, li) => { if (ln.hero === true && isHeroBlock.has(bi)) return; // APEX heroes slot into their lockup below flat.push({ b, bi, ln, plane: heroIdx >= 0 ? lockupName(bi) : b.plane, lockupPos: heroIdx >= 0 ? (li < heroIdx ? "pre" : "post") : null, isMinorHero: ln.hero === true && minorHeroBis.has(bi), // minor peak rides its column first: Math.min(...ln._w.map((w) => w.start)), lastEnd: Math.min(Math.max(...ln._w.map((w) => w.end)), DUR - 0.05), frac: fracOfLine(ln.css), }); }); }); // size sanity: narration below 0.05·h is whisper-tier (36px at 720p) — warn loudly { const tiny = flat.filter((l) => !l.isMinorHero && l.frac > 0 && l.frac < 0.05); if (tiny.length) console.log( `[make-cinematic] ⚠ ${tiny.length} narration line(s) authored below 0.05·h (whisper tier) — body narration wants 0.055–0.07·h: ${tiny .slice(0, 3) .map((l) => `"${l.ln.words.slice(0, 3).join(" ")}…"`) .join(" ")}`, ); } // RATIO LOCK — poster lockup typography: context is sized FROM the hero, the way a // title system locks its eyebrow/tagline (kicker & tail ≈ 0.26× the hero height, // clamped to [0.05, 0.085]·h). Hero dominance is structural (~4× height + weight // contrast), so the old mass-rule downscale is gone — whisper-small context was the // disease, not the cure. Too MUCH context is a line-count problem, never a size one. for (const hRef of heroRefs) { const ctx = flat.filter((l) => l.bi === hRef.bi); if (!ctx.length) continue; const heroFracV = fracOfLine(hRef.ln.css || "") || 0.24; const ratioFrac = +Math.min(0.085, Math.max(0.05, heroFracV * 0.26)).toFixed(4); if (ctx.length > 3) console.log( `[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)`, ); const moved = ctx.filter((l) => Math.abs(l.frac - ratioFrac) > 0.002).length; ctx.forEach((l) => { l.ln.css = (l.ln.css || "").replace( /font-size\s*:\s*[^;]+/, `font-size: calc(${ratioFrac} * var(--h))`, ); l.frac = ratioFrac; }); if (moved) console.log( `[make-cinematic] lockup-b${hRef.bi}: context RATIO-LOCKED to 0.26×hero → ${ratioFrac}·h (${moved} line(s) re-sized)`, ); } const byPlane = {}; flat.forEach((l) => { (byPlane[l.plane] = byPlane[l.plane] || []).push(l); }); // make sure a lockup whose block has NO context lines still exists in the loop for (const hRef of heroRefs) if (!byPlane[hRef.plane]) byPlane[hRef.plane] = []; for (const [pk, ls] of Object.entries(byPlane)) { const isLockup = /^lockup-b/.test(pk); const budget = isLockup ? H : planeBudgetPx(pk); // a lockup is ONE composition — never paginate inside it const hRef = isLockup ? heroRefs.find((h) => h.plane === pk) : null; const heroH = hRef ? (fracOfLine(hRef.ln.css || "") || 0.24) * H * 1.12 + 12 : 0; // ORBIT estimate: where do the hero's left/right edges land inside the (frame-wide, // centered) lockup plane — kicker hangs off the left edge, tail off the right let orbit = null; if (hRef) { const disp = String(hRef.ln.text || (hRef.ln.words || []).join(" ")); const fpx = (fracOfLine(hRef.ln.css || "") || 0.24) * H; const estW = Math.min( W * 0.94, Math.max(fpx * 1.2, disp.replace(/\s/g, "").length * fpx * 0.6), ); const planeCss0 = (planes[pk] && planes[pk].css) || ""; const _pl = ((planeCss0.match(/left:\s*([\d.]+)%/) || [0, 0])[1] * W) / 100; const pw = ((planeCss0.match(/width:\s*([\d.]+)%/) || [0, 100])[1] * W) / 100; const heroLeft = Math.max(0, (pw - estW) / 2); // side-aware: anchor context to the CLEANER side of THIS hero's window — the // kicker/tail must land on scene, not on a large/moving subject let side = (sz && sz.subject && sz.subject.clearerSide) || "left"; try { const hin = Math.min(...hRef.ln._w.map((w) => w.start)); const win = (sz.windows || []).find((w2) => hin >= w2.in - 0.3 && hin <= w2.out + 0.3); if (win && win.clearerSide) side = win.clearerSide; } catch {} orbit = { left: Math.round(heroLeft), right: Math.round(Math.max(0, pw - heroLeft - estW)), maxW: Math.round(Math.min(estW * 0.75, pw * 0.55)), side, }; } const pages = []; let page = [], slot = 0; for (const l of ls) { // the HERO occupies its slot between pre- and post-context, in spoken order if (hRef && hRef.slotPx == null && l.lockupPos === "post") { hRef.slotPx = Math.round(slot); slot += heroH; } 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 const prev = page[page.length - 1]; const pauseGap = prev ? l.first - prev.lastEnd : 0; const boundary = prev && l.bi !== prev.bi; // a block carrying a COLUMN-PLACED apex is one composition — never split it on // budget (the oversized hero legitimately exceeds the column's page height) const unbreakable = columnApexBis.has(l.bi) && prev && prev.bi === l.bi; if ( page.length && !unbreakable && (slot + lineH > budget || (boundary && l.b.flip === true) || (boundary && pauseGap >= 0.6)) ) { pages.push(page); page = []; slot = 0; } l.slotPx = Math.round(slot); if (isLockup) l._orbit = orbit; slot += lineH; page.push(l); } if (hRef && hRef.slotPx == null) { hRef.slotPx = Math.round(slot); } // hero is the block's last line if (page.length) pages.push(page); pages.forEach((pg, pi) => { const nextFirst = pi + 1 < pages.length ? pages[pi + 1][0].first : null; const maxEnd = Math.max(...pg.map((l) => l.lastEnd)); let out = nextFirst != null ? Math.max(maxEnd + 0.01, Math.min(DUR - 0.05, nextFirst - 0.28)) : Math.min(DUR - 0.05, maxEnd + 1.2); // THE LOCKUP OWNS THE FRAME: a sidebar page must not linger into an apex // lockup's window (its kicker/tail can land in the same zone → text-on-text) if (!isLockup) for (const hRef2 of heroRefs) { const lkIn = bw[hRef2.bi].first - 0.18; if (lkIn > maxEnd - 0.2 && lkIn < out) out = Math.max(maxEnd + 0.01, Math.min(out, lkIn - 0.28)); } pg.forEach((l, li) => { l.pageIdx = pi; if (!isLockup) for (const hRef2 of heroRefs) { const lkOut = bw[hRef2.bi].out; const lkIn2 = bw[hRef2.bi].first - 0.18; const enter0 = Math.max(0, l.first - LEAD); if (enter0 > lkIn2 && enter0 < lkOut) l._delayIn = +Math.min(l.first, Math.max(enter0, lkOut - 0.2)).toFixed(3); } // a LOCKUP fades as ONE unit: context lines exit with the hero (block window), // never linger on page timing after the hero has left l.out = l.lockupPos ? +Math.min(out, bw[l.bi].out).toFixed(3) : +out.toFixed(3); l.in = l._delayIn != null ? l._delayIn : +Math.max(0, l.first - LEAD).toFixed(3); // first line of a NEW page: don't enter before the old page has begun clearing if (li === 0 && pi > 0) l.in = +Math.min(l.first, Math.max(l.first - LEAD, pages[pi - 1][0].out - 0.02)).toFixed( 3, ); }); }); } let gid = 0; for (const l of flat) { l.gid = l.isMinorHero ? `h-${l.bi}` : `b${l.bi}-l${gid++}`; // depth model: lockup CONTEXT embeds (bg) WITH the hero — the orbit anchors it // beside the head/shoulder so it reads on the scene, one shared depth; narration // columns embed too unless the DNA declares bodyLayer:"fg" (loud's announce-style). // Minor heroes ride their column in FRONT. Per-line layer overrides any default. // ORBIT depth: lockup context lands BESIDE the subject (kicker/tail at the hero's // edges) — so it EMBEDS (bg) like the hero; the whole composition shares one depth. // fg remains for: minor heroes (column emphasis), a DNA's bodyLayer (loud announces), // scene verdict FG (globalFg), and explicit per-line "layer" overrides. const defLayer = l.isMinorHero ? "fg" : dnaBodyLayer || "bg"; groups.push({ id: l.gid, plane: l.plane, layer: globalFg ? "fg" : l.ln.layer || l.b.layer || defLayer, ...(l.isMinorHero ? { hero: true, ...(columnApexBis.has(l.bi) ? {} : { minor: true }) } : {}), tone: l.isMinorHero ? tones.hero || "present" : l.ln.tone || tones.default || "soft", allow_overlap: true, // a MINOR hero holds only to the end of ITS thought (+a breath) — lingering with // the page through a long pause leaves it co-visible with the next apex in: l.in, out: l.isMinorHero ? +Math.min(l.out, bw[l.bi].out + 0.3).toFixed(3) : l.out, // carry the PLANE's text-align onto each slotted line (absolute children don't // reliably inherit through the template's .cap centering — authored "right edge // hugs the silhouette" was silently centered) css: (l.lockupPos && l._orbit ? (l.ln.anchor || (l.lockupPos === "pre" ? l._orbit.side : l._orbit.side)) === "left" ? `position:absolute;top:${l.slotPx}px;left:${l._orbit.left}px;right:auto;max-width:${l._orbit.maxW}px;text-align:left; ` : `position:absolute;top:${l.slotPx}px;right:${l._orbit.right}px;left:auto;max-width:${l._orbit.maxW}px;text-align:right; ` : `position:absolute;left:0;right:0;top:${l.slotPx}px; ` + (() => { const pc = (C.planes && C.planes[l.plane] && (typeof C.planes[l.plane] === "string" ? C.planes[l.plane] : C.planes[l.plane].css)) || ""; const ta = pc.match(/text-align\s*:\s*(left|right|center)/); return ta ? `text-align:${ta[1]}; ` : ""; })()) + ensureFontSize(l.ln.css, "font-size: calc(0.05 * var(--h)); font-weight: 600;") + (l.lockupPos ? lockupComp : planeComp[l.plane] || ""), words: l.ln._w.map((w, wi2) => ({ // minor/column heroes honor the display-form override too (single-word lines) text: l.isMinorHero && l.ln.text && wi2 === 0 && l.ln._w.length === 1 ? l.ln.text : w.text, start: w.start, end: Math.min(w.end, DUR - 0.05), ti: w.ti, })), }); } for (const hRef of heroRefs) { const { bi, ln } = hRef; const w0 = ln._w[0]; groups.push({ id: `h-${bi}`, hero: true, plane: hRef.plane, layer: ln.layer || (heroFeasible ? "bg" : "fg"), tone: tones.hero || "present", allow_overlap: true, in: +Math.max(0, w0.start - 0.02).toFixed(3), out: +bw[bi].out.toFixed(3), css: `position:absolute;left:0;right:0;top:${hRef.slotPx || 0}px; text-align:center; ` + ensureFontSize( ln.css, "font-size: calc(0.24 * var(--h)); font-weight: 900; white-space: nowrap;", ), // case/tracking come from the DNA's hero treatment words: ln._w.map((w, i) => ({ text: i === 0 && ln.text ? ln.text : w.text, start: w.start, end: Math.min(w.end, DUR - 0.05), ti: w.ti, })), }); if (!heroFeasible) console.log( "[make-cinematic] no hero band ≤62% predicted occlusion → hero rendered in FRONT (last resort)", ); } // scarcity spacing: heroes must breathe apart (≥ a beat between windows) const heroGs = groups.filter((g) => g.hero === true).sort((a, b) => a.in - b.in); for (let i = 1; i < heroGs.length; i++) { const gap = heroGs[i].in - heroGs[i - 1].out; if (gap < 0.6) console.log( `[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)`, ); } // proximity: narration planes should HUG the silhouette (the embed reads as in-scene // only when typography interacts with the subject) — warn on far-parked planes. if (sz && sz.subject) { for (const [pk2, pv] of Object.entries(C.planes || {})) { if (/^lockup/.test(pk2) || pk2 === "hero" || pv == null) continue; const css2 = typeof pv === "string" ? pv : pv.css || ""; const lm = css2.match(/left\s*:\s*([\d.]+)%/), wm = css2.match(/width\s*:\s*([\d.]+)%/); if (!lm || !wm) continue; const pl = +lm[1], pr = +lm[1] + +wm[1]; const gap = pr <= sz.subject.colMinPct ? sz.subject.colMinPct - pr : pl >= sz.subject.colMaxPct ? pl - sz.subject.colMaxPct : 0; if (gap > 12) console.log( `[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%)`, ); } } const plan = { mode: "template", dna: C.dna || (C.template ? undefined : "cream"), // canonical: name a DNA; default cream template: C.template || "cinematic-cream", compiled_by: "make-cinematic.cjs", width: W, height: H, fps: FPS, duration: DUR, ...(globalFg && (!heroRefs.length || !heroFeasible) ? { caption_layer: "fg" } : {}), // mixed narration-fg + hero-bg → per-group layers (hybrid render) planes: Object.fromEntries(Object.entries(planes).map(([k, v]) => [k, { css: v.css }])), groups, }; fs.writeFileSync(path.join(project, "plan.json"), JSON.stringify(plan, null, 2)); console.log( `[make-cinematic] ${blocks.length} block(s) → ${groups.length} group(s)` + `${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)` : ""}` + `${globalFg ? ", caption_layer=fg (safe-zones verdict)" : ""}, canvas ${DUR}s`, ); // straight into the existing template compiler const cp2 = require("child_process"); let r = cp2.spawnSync("node", [path.join(__dirname, "make-composition.cjs"), project], { stdio: "inherit", }); if ((r.status || 0) !== 0) process.exit(r.status); // ── RE-SLOT pass (real measurement). Compile-time slots assume each line renders as // ONE visual line; a narrow plane WRAPS long lines and the next slot overlaps the // wrapped tail. Measure every page's REAL text heights in Chromium and re-stack. { const textBoxH = (c) => { const ws = (c.words || []).filter((w) => (w.opacity ?? 1) > 0.05 && w.w > 0); if (!ws.length) return c.cap_bbox ? c.cap_bbox.h : 0; return Math.max(...ws.map((w) => w.y + w.h)) - Math.min(...ws.map((w) => w.y)); }; const pagesByKey = {}; for (const l of flat) { const key = l.plane + "|" + (l.pageIdx || 0); (pagesByKey[key] = pagesByKey[key] || { t: +(l.out - 0.1).toFixed(2), lines: [] }).lines.push( l, ); } // the HERO is a real member of its lockup's stack — re-slot must move it (and the // post-context below it) from MEASURED heights, or a wrapped pre-line / raised hero // font reopens the very overlap this pass exists to prevent for (const hRef of heroRefs) { const key = hRef.plane + "|0"; const hg2 = groups.find((g2) => g2.id === `h-${hRef.bi}`); if (!hg2) continue; (pagesByKey[key] = pagesByKey[key] || { t: +(hg2.out - 0.1).toFixed(2), lines: [], }).lines.push({ gid: hg2.id, slotPx: hRef.slotPx || 0, frac: fracOfLine(hg2.css) || 0.24, _hero: true, }); } const times = [...new Set(Object.values(pagesByKey).map((pg) => pg.t))]; if (times.length) { cp2.spawnSync( "node", [path.join(__dirname, "measure-layout.cjs"), project, ...times.map(String)], { stdio: "ignore", timeout: 120000 }, ); let lay = null; try { lay = JSON.parse(fs.readFileSync(path.join(project, "_layout.json"), "utf8")); } catch {} if (lay && lay.samples) { let changes = 0; for (const pg of Object.values(pagesByKey)) { const sample = lay.samples.reduce((a, b) => Math.abs(b.t - pg.t) < Math.abs(a.t - pg.t) ? b : a, ); if (!sample || Math.abs(sample.t - pg.t) > 0.3) continue; const ordered = [...pg.lines].sort((a, b) => a.slotPx - b.slotPx); let top = 0; for (const l of ordered) { const cap = (sample.caps || []).find((c) => c.id === l.gid); const realH = cap ? (cap.cap_bbox && cap.cap_bbox.h) || textBoxH(cap) : l.frac * H * 1.25; if (Math.abs(top - l.slotPx) > 3) { const g = groups.find((g2) => g2.id === l.gid); g.css = g.css.replace(/top:\s*-?[\d.]+px/, "top:" + Math.round(top) + "px"); if (l._hero) { const hr = heroRefs.find((h) => `h-${h.bi}` === l.gid); if (hr) hr.slotPx = Math.round(top); } l.slotPx = Math.round(top); changes++; } top += (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 } } if (changes) { console.log( `[make-cinematic] re-slot: ${changes} line(s) re-stacked from MEASURED heights (wrapped lines accounted)`, ); fs.writeFileSync(path.join(project, "plan.json"), JSON.stringify(plan, null, 2)); cp2.spawnSync("node", [path.join(__dirname, "make-composition.cjs"), project], { stdio: "ignore", }); try { fs.unlinkSync(path.join(project, "_layout.json")); } catch {} } } } } // ── HERO post-pass (real measurement, up to 2 re-emits) — runs PER HERO ───── // (a) SIZE: a timid hero is the #1 quality kill. If the authored font leaves >25% // of the usable width unused, raise it to the width-fit maximum (DNA sizeRange cap). // (b) CLEARANCE vs OTHER planes: the hero band must not overlap caps outside its own // lockup (its own context is composed around it by the slot layout). On a hit, // shift the whole LOCKUP plane vertically. // (c) LOCKUP RE-STACK: a size change moves the hero's height — restack the lockup's // slots from measured heights so post-context stays bonded BELOW the hero. // HERO words never wrap — a mid-word break ("Watermelo|n") is wrong in every case, // and author css REPLACES the default (which silently lost white-space:nowrap). // Forced here so no authoring path can drop it; centered nowrap text overflows a // narrow plane SYMMETRICALLY, which also keeps width-fit honest (it measures the // true single-line width instead of a wrapped row). // narration lines whipped away by a mid-sentence page-flip are the same disease // in mild form — warn (a deliberate quick beat is legal, so no die) for (const g of groups) if (!g.hero && g.words && g.words.length && g.out - g.in < 0.6) { console.log( `[make-cinematic] ⚠ line "${g.words .map((w) => w.text) .join(" ") .slice( 0, 28, )}" visible only ${(g.out - g.in).toFixed(2)}s (page-flip) — merge micro-lines or give the plane more height so the page holds`, ); } // MIN DWELL — a peak on screen <0.5s is unreadable ("stars" shipped at 0.47s and // the user couldn't read it). Merge the block with the next (one thought), promote // a word with room, or demote to an emphasized line. <0.8s gets a loud warning. for (const g of groups) if (g.hero) { const dwell = g.out - g.in; if (dwell < 0.5) die( `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.`, ); if (dwell < 0.8) console.log( `[make-cinematic] ⚠ hero "${(g.words[0] || {}).text}" dwell ${dwell.toFixed(2)}s is tight (<0.8s) — consider merging blocks so the peak can breathe`, ); } { let nwChanged = false; for (const g of groups) if (g.hero && !/white-space\s*:\s*nowrap/.test(g.css || "")) { g.css = (g.css || "") + " white-space: nowrap;"; nwChanged = true; } if (nwChanged) { fs.writeFileSync(path.join(project, "plan.json"), JSON.stringify(plan, null, 2)); require("child_process").spawnSync( "node", [path.join(__dirname, "make-composition.cjs"), project], { stdio: "ignore" }, ); } } const fracOf = (css) => { const m = (css || "").match(/font-size\s*:\s*calc\(\s*([\d.]+)\s*\*\s*var\(--h\)/); return m ? +m[1] : null; }; const setFrac = (css, f) => (css || "").replace( /font-size\s*:\s*[^;]+/, "font-size: calc(" + f.toFixed(3) + " * var(--h))", ); const textBox = (c) => { const ws = (c.words || []).filter((w) => (w.opacity ?? 1) > 0.05 && w.w > 0); if (!ws.length) return c.cap_bbox; const x0 = Math.min(...ws.map((w) => w.x)), x1 = Math.max(...ws.map((w) => w.x + w.w)); const y0 = Math.min(...ws.map((w) => w.y)), y1 = Math.max(...ws.map((w) => w.y + w.h)); return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; }; let dnaSizeCap = 0.34, dnaHeroCase = "none"; try { const dnaLib2 = require("./lib-dna.cjs"); const dn = plan.dna || dnaLib2.LEGACY[plan.template]; if (dn) { const dj = dnaLib2.load(dn); if (dj.hero && dj.hero.sizeRange) dnaSizeCap = dj.hero.sizeRange[1]; if (dj.hero) dnaHeroCase = dj.hero.case || "none"; } } catch {} // the APEX (largest authored hero) gets the width-fit raise; minor heroes keep their // authored size — inter-hero hierarchy is a deliberate choice, not timidity const apexFrac = Math.max( ...heroRefs.map((h) => fracOf((groups.find((g) => g.id === `h-${h.bi}`) || {}).css) || 0), 0, ); for (const hRef of heroRefs) { const heroG = groups.find((g) => g.id === `h-${hRef.bi}`); if (!heroG) continue; const isApex = (fracOf(heroG.css) || 0) >= apexFrac - 1e-6; const lockupMates = groups.filter((g) => g.plane === hRef.plane && g.id !== heroG.id); // hero size changed → context follows the 0.26 poster ratio, and its orbit // max-width scales WITH the font (a stale max-width wraps re-locked context // into multi-row towers that blow the stack past the frame) const relockMates = (hf) => { const rf = +Math.min(0.085, Math.max(0.05, hf * 0.26)).toFixed(4); for (const m of lockupMates) { const mf = fracOf(m.css); if (!mf || Math.abs(mf - rf) <= 0.002) continue; const scale = rf / mf; m.css = setFrac(m.css, rf).replace( /max-width:\s*([\d.]+)px/, (s2, px) => "max-width:" + Math.round(+px * scale) + "px", ); } }; const measure = () => { const midT = ((heroG.in + Math.min(heroG.out, heroG.in + 1.0)) / 2).toFixed(2); const endT = Math.max(+midT + 0.05, heroG.out - 0.12).toFixed(2); cp2.spawnSync( "node", [path.join(__dirname, "measure-layout.cjs"), project, String(midT), String(endT)], { stdio: "ignore", timeout: 90000 }, ); try { const ss = JSON.parse(fs.readFileSync(path.join(project, "_layout.json"), "utf8")).samples; const near = (t) => ss.reduce((a, b) => (Math.abs(b.t - t) < Math.abs((a ? a.t : 1e9) - t) ? b : a), null); return { caps: (near(+midT) || {}).caps || [], capsEnd: (near(+endT) || {}).caps || [] }; } catch { return null; } }; let didRaise = false; for (let pass = 0; pass < 5; pass++) { const m0 = measure(); if (!m0) break; const caps = m0.caps, capsEnd = m0.capsEnd && m0.capsEnd.length ? m0.capsEnd : m0.caps; const hc0 = caps.find((c) => c.id === heroG.id); if (!hc0 || !hc0.cap_bbox) break; const hc = { ...hc0, cap_bbox: textBox(hc0) }; const mateIds = new Set(lockupMates.map((m) => m.id)); const others = caps .filter( (c) => c.id !== heroG.id && !mateIds.has(c.id) && !/-glow$/.test(c.id || "") && c.cap_bbox && c.opacity > 0.05, ) .map((c) => ({ ...c, cap_bbox: textBox(c) })); const maxW = W * 0.94; const f0 = fracOf(heroG.css); let changed = false; if (f0 && hc.cap_bbox.w > 0) { if (hc.cap_bbox.w > maxW) { const nf = Math.max(0.06, (f0 * maxW) / hc.cap_bbox.w); console.log( `[make-cinematic] hero "${heroG.words[0].text}" too WIDE ${Math.round(hc.cap_bbox.w)}px → ${f0}h→${nf.toFixed(3)}h`, ); heroG.css = setFrac(heroG.css, nf); changed = true; } else if (hc.cap_bbox.w >= maxW * 0.88) { if (pass === 0) console.log( `[make-cinematic] hero "${heroG.words[0].text}" at width CEILING (${Math.round(hc.cap_bbox.w)}/${Math.round(maxW)}px) — cannot enlarge.`, ); } else if (!didRaise && hc.cap_bbox.w < maxW * 0.88 && isApex) { // NO DEAD ZONE: anything under 88% of usable width is raised toward a 93% // fill (the old <75% trigger let 75–88%-wide heroes stay merely "decent") const fit = (f0 * (maxW * 0.93)) / hc.cap_bbox.w; const nf = Math.min(dnaSizeCap, fit); if (nf > f0 * 1.05 || fit > dnaSizeCap * 1.02) { if (nf > f0 * 1.001) console.log( `[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)`, ); // SHORT-WORD FILL: height cap bound before the width target → fill with // TRACKING (film-title craft — HER, DUNE), ≤0.32em so it stays a word. const wAtCap = (hc.cap_bbox.w * nf) / f0; const dispT = String(heroG.text || (heroG.words || []).map((w) => w.text).join(" ")); // never letterspace lowercase (tracked lowercase falls apart — caps only) const capsy = dnaHeroCase === "uppercase" || dispT === dispT.toUpperCase(); if (fit > dnaSizeCap && wAtCap < maxW * 0.85 && capsy) { const disp = dispT; // letter-spacing adds a gap after EVERY char (N gaps) and the centering // text-indent adds one more — budget N+1 gaps or the word leaves the frame const gaps = disp.length + 1; const tr = +Math.min(0.32, (maxW * 0.88 - wAtCap) / (gaps * nf * H)).toFixed(3); if (tr >= 0.04) { heroG.css = heroG.css .replace(/letter-spacing\s*:[^;]+;?\s*/g, "") .replace(/text-indent\s*:[^;]+;?\s*/g, "") + (heroG.css.trim().endsWith(";") || !heroG.css.trim() ? "" : ";") + ` letter-spacing:${tr}em; text-indent:${tr}em;`; console.log( `[make-cinematic] hero "${disp}" short word at height cap (${Math.round(wAtCap)}px of ${Math.round(maxW)}px) → tracked +${tr}em to fill`, ); } } heroG.css = setFrac(heroG.css, nf); changed = true; didRaise = true; relockMates(nf); } } } const hbx = hc.cap_bbox; // colliders OUTSIDE the lockup only — the lockup composes around the hero by design const hits = others.filter((o) => { const b = o.cap_bbox; const ix = Math.min(hbx.x + hbx.w, b.x + b.w) - Math.max(hbx.x, b.x); const iy = Math.min(hbx.y + hbx.h, b.y + b.h) - Math.max(hbx.y, b.y); return ix > 8 && iy > 8; }); if (hits.length) { const maxBottom = Math.max(...hits.map((o) => o.cap_bbox.y + o.cap_bbox.h)); const minTop = Math.min(...hits.map((o) => o.cap_bbox.y)); const downTo = ((maxBottom + 14) / H) * 100; const upTo = Math.max(2, ((minTop - hbx.h - 14) / H) * 100); const planeCss = plan.planes[hRef.plane].css; const curTop = +(planeCss.match(/top:\s*([\d.]+)%/) || [0, 30])[1]; const newTop = downTo + (hbx.h / H) * 100 < 96 ? downTo : upTo; plan.planes[hRef.plane].css = planeCss.replace( /top:\s*[\d.]+%/, "top: " + newTop.toFixed(1) + "%", ); console.log( `[make-cinematic] hero "${heroG.words[0].text}" OVERLAPS ${hits.map((o) => o.id).join(",")} — lockup top ${curTop}% → ${newTop.toFixed(1)}%`, ); changed = true; } // ORBIT REFINE: anchor kicker/tail to the MEASURED hero edges (estimate → real) if (lockupMates.length && hc.cap_bbox && hc.cap_bbox.w > 0) { const planeCss2 = (plan.planes[hRef.plane] && plan.planes[hRef.plane].css) || ""; const pl2 = ((planeCss2.match(/left:\s*([\d.]+)%/) || [0, 0])[1] * W) / 100; const relL = Math.max(0, Math.round(hc.cap_bbox.x - pl2)); const pw2 = ((planeCss2.match(/width:\s*([\d.]+)%/) || [0, 100])[1] * W) / 100; const relR = Math.max(0, Math.round(pw2 - (hc.cap_bbox.x - pl2) - hc.cap_bbox.w)); for (const m of lockupMates) { if (/left:\s*\d+px;right:auto/.test(m.css)) { const nc = m.css.replace(/left:\s*\d+px/, "left:" + relL + "px"); if (nc !== m.css) { m.css = nc; changed = true; } } else if (/right:\s*\d+px;left:auto/.test(m.css)) { const nc = m.css.replace(/right:\s*\d+px/, "right:" + relR + "px"); if (nc !== m.css) { m.css = nc; changed = true; } } } } // LOCKUP RE-STACK from measured heights — runs EVERY pass for lockups (orbit // max-widths wrap context lines, so the re-slot's estimates can leave the tail // displaced or the whole composition off frame) if (lockupMates.length) { const caps2 = capsEnd; // lockup-END sample: every context line is revealed there // TOWER CHECK — a context line wrapped to 3+ rows means its orbit max-width is // too narrow for the (re-locked) font; widen toward the plane before stacking, // or the tower blows the stack past the frame and the walk-back eats the hero for (const m of lockupMates) { const cm = caps2.find((x) => x.id === m.id); const frm = fracOf(m.css) || 0.05; const mwM = m.css.match(/max-width:\s*([\d.]+)px/); if (!cm || !cm.cap_bbox || !mwM) continue; const rows = Math.round(cm.cap_bbox.h / (frm * H * 1.12)); if (rows >= 3) { const planeCssW2 = (plan.planes[hRef.plane] && plan.planes[hRef.plane].css) || ""; const pwX = W * (+(planeCssW2.match(/width:\s*([\d.]+)%/) || [0, 92])[1] / 100); const nw = Math.round(pwX * 0.62); if (nw > +mwM[1] + 10) { m.css = m.css.replace(/max-width:\s*[\d.]+px/, "max-width:" + nw + "px"); console.log( `[make-cinematic] context "${((m.words || [])[0] || {}).text}…" wrapped to ${rows} rows — orbit max-width ${Math.round(+mwM[1])}→${nw}px (towers break the stack)`, ); changed = true; } } } const entries = [heroG, ...lockupMates] .map((g) => { const c = caps2.find((x) => x.id === g.id); const curTop = +(g.css.match(/top:\s*(-?[\d.]+)px/) || [0, 0])[1]; const fr = fracOf(g.css) || 0.05; // SLOT HEIGHT = the LAYOUT box (cap_bbox), not the glyph box: words reveal // over time, so at the hero-mid sample a context line's later words are // still opacity-0 — glyph-box measured a wrapped line one row short and the // next line slotted INTO its wrap band (real text-on-text shipped). const measuredH = c && c.cap_bbox ? c.cap_bbox.h : null; const h2 = g.id === heroG.id && didRaise ? fr * H * 1.12 : measuredH || fr * H * 1.25; return { g, curTop, h: h2 }; }) .sort((a, b) => a.curTop - b.curTop); let top = 0; for (const e of entries) { if (Math.abs(top - e.curTop) > 3) { e.g.css = e.g.css.replace(/top:\s*-?[\d.]+px/, "top:" + Math.round(top) + "px"); changed = true; } 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 } // RE-CLAMP the plane after MEASURED growth — keep the whole composition on frame const planeCss3 = plan.planes[hRef.plane].css; const curTopPct = +(planeCss3.match(/top:\s*([\d.]+)%/) || [0, 30])[1]; const maxTopPct = Math.max(2, ((H * 0.97 - top) / H) * 100); if (curTopPct > maxTopPct + 0.3) { plan.planes[hRef.plane].css = planeCss3.replace( /top:\s*[\d.]+%/, "top: " + maxTopPct.toFixed(1) + "%", ); console.log( `[make-cinematic] lockup measured ${Math.round(top)}px tall — plane top ${curTopPct}% → ${maxTopPct.toFixed(1)}% (kept on frame)`, ); changed = true; } // even at the 2% ceiling the stack can exceed the frame (TIMID raise + ratio // context on a tall lockup) — walk the hero back 8% and re-lock until it fits if (top > H * 0.95 && maxTopPct <= 2.01) { const f0c = fracOf(heroG.css); if (f0c && f0c > 0.14) { // decisive step: stack height scales ~linearly with the hero (context is // ratio-locked to it), so aim straight at a 93% fill instead of nibbling const nf2 = +Math.max(0.14, Math.min(f0c * 0.92, (f0c * (H * 0.93)) / top)).toFixed(3); console.log( `[make-cinematic] lockup stack ${Math.round(top)}px exceeds the frame even at top 2% — hero ${f0c}h → ${nf2}h (fit)`, ); heroG.css = setFrac(heroG.css, nf2); relockMates(nf2); didRaise = true; changed = true; } } } if (!changed) break; fs.writeFileSync(path.join(project, "plan.json"), JSON.stringify(plan, null, 2)); r = cp2.spawnSync("node", [path.join(__dirname, "make-composition.cjs"), project], { stdio: "ignore", }); try { fs.unlinkSync(path.join(project, "_layout.json")); } catch {} } } // MINOR HERO measure pass — minors keep their authored size (hierarchy is a choice) // but never ship broken: wider than the frame's usable width shrinks to fit, and a // column overflow gets a loud note (centered spill is legal on a clear side only). { const minors = groups.filter((g) => g.hero && !heroRefs.some((h) => `h-${h.bi}` === g.id)); let minorChanged = false; for (const mg of minors) { const midT = ((mg.in + Math.min(mg.out, mg.in + 1.0)) / 2).toFixed(2); cp2.spawnSync("node", [path.join(__dirname, "measure-layout.cjs"), project, String(midT)], { stdio: "ignore", timeout: 60000, }); let mc = null; try { mc = ( JSON.parse(fs.readFileSync(path.join(project, "_layout.json"), "utf8")).samples[0].caps || [] ).find((c) => c.id === mg.id); } catch {} if (!mc || !mc.cap_bbox) continue; const bb = textBox(mc), f0 = fracOf(mg.css); if (f0 && bb.w > W * 0.94) { const nf = Math.max(0.06, +((f0 * (W * 0.94)) / bb.w).toFixed(3)); console.log( `[make-cinematic] minor hero "${(mg.words[0] || {}).text}" too WIDE ${Math.round(bb.w)}px → ${f0}h→${nf.toFixed(3)}h (shrink-to-fit)`, ); mg.css = setFrac(mg.css, nf); minorChanged = true; } else { const planeCssM = (plan.planes[mg.plane] && plan.planes[mg.plane].css) || ""; const pwPx = W * (+(planeCssM.match(/width:\s*([\d.]+)%/) || [0, 100])[1] / 100); if (bb.w > pwPx * 1.05) console.log( `[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`, ); } try { fs.unlinkSync(path.join(project, "_layout.json")); } catch {} } if (minorChanged) { fs.writeFileSync(path.join(project, "plan.json"), JSON.stringify(plan, null, 2)); cp2.spawnSync("node", [path.join(__dirname, "make-composition.cjs"), project], { stdio: "ignore", }); } } process.exit(0); } main();