85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
1206 lines
56 KiB
JavaScript
1206 lines
56 KiB
JavaScript
#!/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 <project-dir>
|
||
*
|
||
* 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 <project-dir>");
|
||
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();
|