项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

1206 行
56 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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();