chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/*
* audio-envelope.cjs — extract a per-50ms RMS loudness envelope from the source audio.
*
* node audio-envelope.cjs <project-dir> → <project>/envelope.json
*
* { hop: 0.05, rms: [linear 0..1 per hop] }
*
* Used by lib-dna.cjs to couple the hero's entrance amplitude to how hard the word was
* actually SPOKEN (percentile of the hero window's loudness within the clip). ffmpeg
* astats over fixed windows — deterministic, no network, no Python.
*/
const path = require("path");
const fs = require("fs");
const cp = require("child_process");
const HOP = 0.05;
function findSource(project) {
for (const c of ["source.mp4"].concat(
fs
.readdirSync(project)
.filter(
(f) =>
/\.(mp4|mov|webm|mkv|m4v|wav|m4a|mp3)$/i.test(f) &&
!/^(final|bg_plus_caps|fg_caps|rail|index)/.test(f),
),
)) {
const p = path.join(project, c);
if (fs.existsSync(p)) return p;
}
return null;
}
function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: audio-envelope.cjs <project-dir>");
process.exit(1);
}
const src = findSource(project);
if (!src) {
console.error("[envelope] no source media found");
process.exit(2);
}
// asetnsamples groups audio into fixed windows; astats prints RMS per window
const sr = 16000,
n = Math.round(sr * HOP);
let out;
try {
// resample INSIDE the filter chain — an output-option -ar applies after the
// filtergraph, which would make each window n/<input-rate> seconds instead of HOP
out = cp.execFileSync(
"ffmpeg",
[
"-v",
"info",
"-i",
src,
"-map",
"a:0",
"-af",
`aresample=${sr},aformat=channel_layouts=mono,asetnsamples=n=${n}:p=0,astats=metadata=1:reset=1,ametadata=mode=print:key=lavfi.astats.Overall.RMS_level:file=-`,
"-f",
"null",
"-",
],
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] },
);
} catch (e) {
// ffmpeg writes the metadata to stdout before exiting; some containers still exit 0 — re-throw only if nothing parsed
out = (e.stdout || "") + "";
if (!out.includes("RMS_level")) {
console.error(`[envelope] ffmpeg failed: ${e.message}`);
process.exit(2);
}
}
const rmsDb = [];
for (const line of String(out).split("\n")) {
const m = line.match(/lavfi\.astats\.Overall\.RMS_level=(-?[\d.]+|-inf)/);
if (m) rmsDb.push(m[1] === "-inf" ? -90 : Math.max(-90, parseFloat(m[1])));
}
if (!rmsDb.length) {
console.error("[envelope] no RMS windows parsed");
process.exit(2);
}
const rms = rmsDb.map((db) => +Math.pow(10, db / 20).toFixed(5)); // linear 0..1
fs.writeFileSync(path.join(project, "envelope.json"), JSON.stringify({ hop: HOP, rms }));
const peakAt = rms.indexOf(Math.max(...rms)) * HOP;
console.log(
`[envelope] ${rms.length} windows @ ${HOP}s → envelope.json (loudest beat ~${peakAt.toFixed(2)}s)`,
);
}
main();
@@ -0,0 +1,250 @@
#!/usr/bin/env node
/* check-occlusion.cjs — pixel-perfect occlusion gate (Node port of check-occlusion-v2.py).
* Runs measure-layout.cjs (real Chromium DOM rects), reads the subject-matte alpha via sharp,
* computes per-word / per-cap occlusion. No Python.
* node check-occlusion.cjs <project-dir> [--strict] [--word-fail F] [--word-warn F] [--cap-fail F] [--remeasure]
*/
const path = require("path");
const fs = require("fs");
const os = require("os");
const cp = require("child_process");
function hfResolve(pkg) {
const roots = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "..", "..", ".."),
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
for (const root of roots) {
const cands = [path.join(root, "node_modules", pkg)];
const bun = path.join(root, "node_modules", ".bun");
try {
if (fs.existsSync(bun))
for (const d of fs.readdirSync(bun))
if (d.startsWith(pkg + "@")) cands.push(path.join(bun, d, "node_modules", pkg));
} catch {}
for (const c of cands) {
try {
if (fs.existsSync(c)) return require(c);
} catch {}
}
}
console.error(`[v2] cannot find ${pkg} — set HYPERFRAMES_ROOT`);
process.exit(3);
}
const sharp = hfResolve("sharp");
function ensureLayoutMeasured(project, force) {
const lp = path.join(project, "_layout.json"),
idx = path.join(project, "index.html");
let stale = force || !fs.existsSync(lp);
if (!stale && fs.existsSync(idx) && fs.statSync(idx).mtimeMs > fs.statSync(lp).mtimeMs)
stale = true;
if (stale)
cp.execFileSync("node", [path.join(__dirname, "measure-layout.cjs"), project], {
stdio: "inherit",
});
return JSON.parse(fs.readFileSync(lp, "utf8"));
}
async function loadAlphaMask(png) {
if (!fs.existsSync(png)) return null;
const { data, info } = await sharp(png)
.ensureAlpha()
.extractChannel(3)
.raw()
.toBuffer({ resolveWithObject: true });
return { data, W: info.width, H: info.height };
}
function occlusionForRect(m, x, y, w, h) {
if (!m) return 0;
const x0 = Math.max(0, Math.round(x)),
y0 = Math.max(0, Math.round(y));
const x1 = Math.min(m.W, Math.round(x + w)),
y1 = Math.min(m.H, Math.round(y + h));
if (x1 <= x0 || y1 <= y0) return 0;
let cnt = 0,
tot = 0;
for (let yy = y0; yy < y1; yy++) {
const row = yy * m.W;
for (let xx = x0; xx < x1; xx++) {
tot++;
if (m.data[row + xx] > 128) cnt++;
}
}
return tot ? cnt / tot : 0;
}
function argf(name, d) {
const i = process.argv.indexOf(name);
return i >= 0 ? parseFloat(process.argv[i + 1]) : d;
}
async function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: check-occlusion.cjs <project-dir> [--strict]");
process.exit(1);
}
const strict = process.argv.includes("--strict");
const wordFail = argf("--word-fail", 0.65),
wordWarn = argf("--word-warn", 0.35),
capFail = argf("--cap-fail", 0.5);
const layout = ensureLayoutMeasured(project, process.argv.includes("--remeasure"));
const framesDir = path.join(project, "frames_fg");
if (!fs.existsSync(framesDir)) {
console.error(`[v2] missing ${framesDir}`);
process.exit(2);
}
const planPath = path.join(project, "plan.json");
const plan = fs.existsSync(planPath) ? JSON.parse(fs.readFileSync(planPath, "utf8")) : {};
const planLayer = plan.caption_layer || "bg";
// Hero groups (the ONE big promoted word) are SUPPOSED to sit ON the subject — for them
// occlusion is a TARGET (~3055%), not "minimize". Collect their ids for the advisory below.
const heroIds = new Set();
const heroIn = {};
for (const g of plan.groups || [])
if (g && (g.hero === true || /^(hero|crown)$/i.test(g.plane || ""))) {
heroIds.add(g.id);
heroIn[g.id] = g.in;
}
if (plan.crown_group && plan.crown_group.id) {
heroIds.add(plan.crown_group.id);
heroIn[plan.crown_group.id] = plan.crown_group.in;
}
const M = 2; // frame-edge tolerance (px) — matches check-overflow.cjs
const frameW = layout.width,
frameH = layout.height;
const capStats = {};
for (const sample of layout.samples) {
const png = path.join(framesDir, `f_${String(sample.frame_idx).padStart(4, "0")}.png`);
const mask = await loadAlphaMask(png);
if (!mask) continue;
for (const cap of sample.caps) {
const entry = (capStats[cap.id] ||= { layer: cap.layer || planLayer, samples: [] });
const wordsData = [];
for (const w of cap.words || []) {
if ((w.opacity ?? 1) < 0.3) continue;
wordsData.push({ text: w.text, occlusion: occlusionForRect(mask, w.x, w.y, w.w, w.h) });
// Frame-edge overflow — clipped SETTLED text is always wrong; a hero's first
// 0.5s is its entrance TRANSIENT (slam over-scale, streak fly-in pass through
// off-frame states by design) — judge overflow on the hold, not mid-flight.
if (heroIds.has(cap.id) && heroIn[cap.id] != null && sample.t < heroIn[cap.id] + 0.5)
continue;
const off = {
left: Math.max(0, Math.round(-w.x - M)),
right: Math.max(0, Math.round(w.x + w.w - frameW - M)),
top: Math.max(0, Math.round(-w.y - M)),
bottom: Math.max(0, Math.round(w.y + w.h - frameH - M)),
};
const score = off.left + off.right + off.top + off.bottom;
if (score > 0 && (!entry.overflow || score > entry.overflow.score))
entry.overflow = { text: w.text, off, score, t: sample.t };
}
const capOccl = occlusionForRect(
mask,
cap.cap_bbox.x,
cap.cap_bbox.y,
cap.cap_bbox.w,
cap.cap_bbox.h,
);
entry.samples.push({
t: sample.t,
cap_occl: capOccl,
cap_bbox: cap.cap_bbox,
words: wordsData,
});
}
}
const failures = [];
console.log(
`[v2] ${path.basename(project)} word-fail≥${(wordFail * 100).toFixed(0)}% cap-fail≥${(capFail * 100).toFixed(0)}%`,
);
for (const [gid, entry] of Object.entries(capStats)) {
// Frame-edge overflow applies to every layer (fg too). The skill allows the
// climax a few-px graze on the first/last letter, so only a clear glyph clip
// (>8px past an edge) is a hard FAIL; a sub-glyph graze is a WARN.
if (entry.overflow) {
const o = entry.overflow;
const maxOff = Math.max(o.off.left, o.off.right, o.off.top, o.off.bottom);
const sides = Object.entries(o.off)
.filter(([, v]) => v > 0)
.map(([s, v]) => `${s} ${v}px`)
.join(", ");
const hard = maxOff >= 8;
console.log(
` ${gid} [overflow${hard ? "" : "-warn"}] "${o.text}" off-frame: ${sides} (@${o.t}s)` +
(hard ? " — cropped text is always wrong" : " (graze — within climax tolerance)"),
);
if (hard && !failures.includes(gid)) failures.push(gid);
}
if (entry.layer === "fg") {
console.log(` ${gid} fg (occlusion skipped — fg renders above matte)`);
continue;
}
const capOccls = entry.samples.map((s) => s.cap_occl);
const avgCap = capOccls.length ? capOccls.reduce((a, b) => a + b, 0) / capOccls.length : 0;
const peakCap = capOccls.length ? Math.max(...capOccls) : 0;
const wordPeaks = {};
for (const s of entry.samples)
for (const wd of s.words)
wordPeaks[wd.text] = Math.max(wordPeaks[wd.text] || 0, wd.occlusion);
const oblit = Object.entries(wordPeaks).filter(([, p]) => p >= wordFail);
const warn = Object.entries(wordPeaks).filter(([, p]) => p >= wordWarn && p < wordFail);
let status = "OK";
// HERO caps WANT occlusion (~3055% is the embed); the generic cap threshold would
// fail a working hero. Heroes fail only past the feasibility ceiling (68%).
const capLimit = heroIds.has(gid) ? Math.max(capFail, 0.68) : capFail;
if (oblit.length || peakCap >= capLimit) {
status = "FAIL";
failures.push(gid);
} else if (warn.length) status = "WARN";
let s = "";
if (oblit.length)
s =
oblit
.slice(0, 5)
.map(([t, p]) => `${t}(${(p * 100).toFixed(0)}%)`)
.join(" ") + (oblit.length > 5 ? ` …+${oblit.length - 5}` : "");
else if (warn.length)
s =
"[warn] " +
warn
.slice(0, 3)
.map(([t, p]) => `${t}(${(p * 100).toFixed(0)}%)`)
.join(" ");
console.log(
` ${gid} ${entry.layer} avg ${(avgCap * 100).toFixed(0)}% peak ${(peakCap * 100).toFixed(0)}% ${status} ${s}`,
);
// HERO target-occlusion advisory (not a failure): a hero should sit ON the subject
// (~3055%). If it barely grazes, it reads as a small floating word, not an embed.
if (heroIds.has(gid) && peakCap < 0.15) {
// METRIC HONESTY: this advisory uses CAP-AREA occlusion, which saturates ~15%
// for a width-filled hero over a narrow subject (the 3055% figure elsewhere is
// the safe-zones BAND metric — different denominator). If the hero already owns
// the width, "center it + make it BIG" is unactionable — stay quiet.
const widest = Math.max(...entry.samples.map((sm) => (sm.cap_bbox && sm.cap_bbox.w) || 0), 0);
if (widest >= frameW * 0.8) {
console.log(
` ${gid} [hero-ok] peak ${(peakCap * 100).toFixed(0)}% cap-area — width-saturated hero over a narrow subject; cap-area can't reach the band target (this is the geometry, not a layout fault).`,
);
} else {
console.log(
` ${gid} [hero-weak] peak ${(peakCap * 100).toFixed(0)}% — hero barely crosses the subject; it should sit ON the subject (~3055% by the safe-zones BAND metric = the embed effect). Center it (safe-zones heroAnchor) + make it BIG; don't park it in a clean margin.`,
);
}
}
}
if (failures.length) {
const uniq = [...new Set(failures)];
console.error(`\n[v2] ${uniq.length} cap(s) FAIL: ${uniq.join(", ")}`);
console.error(
" → occlusion: set that cap's layer to fg, OR shrink/reposition; overflow: shrink/reposition (fg won't help)",
);
}
process.exit(strict && failures.length ? 2 : 0);
}
main().catch((e) => {
console.error("[v2]", e.message);
process.exit(1);
});
@@ -0,0 +1,194 @@
#!/usr/bin/env node
/**
* check-overflow.cjs — mode-agnostic frame-overflow WARNING for custom mode.
*
* Template mode has check-occlusion.cjs (which also flags frame-edge overflow),
* but custom mode runs no gates. This is the cheap safety net: it loads the
* rendered index.html, seeks the GSAP timeline across the clip, and flags ANY
* visible text element (regardless of class) whose box leaves the canvas — i.e.
* captions that fall off-frame (the bug we otherwise only catch by eye).
*
* WARNING ONLY — never fails the build (custom designs may bleed intentionally).
* Exit 0 if it ran (with or without findings); exit 3 if it couldn't run.
*
* Usage: node check-overflow.cjs <project-dir>
*/
const path = require("path");
const fs = require("fs");
const os = require("os");
const HF_ROOTS = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "../../.."),
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
let puppeteer = null;
for (const root of HF_ROOTS) {
const cands = [path.join(root, "node_modules", "puppeteer")];
const bunDir = path.join(root, "node_modules", ".bun");
try {
if (fs.existsSync(bunDir)) {
for (const d of fs.readdirSync(bunDir))
if (d.startsWith("puppeteer@"))
cands.push(path.join(bunDir, d, "node_modules", "puppeteer"));
}
} catch {
/* ignore */
}
for (const p of cands) {
try {
if (fs.existsSync(p)) {
puppeteer = require(p);
break;
}
} catch {}
}
if (puppeteer) break;
}
if (!puppeteer) {
console.error("[overflow] puppeteer not found");
process.exit(3);
}
async function main() {
const projectDir = process.argv[2];
const htmlName = process.argv[3] || "index.html"; // Standard mode passes "rail.html" to gate the rail too
const indexPath = path.resolve(projectDir, htmlName);
if (!fs.existsSync(indexPath)) {
console.error(`[overflow] missing ${indexPath}`);
process.exit(2);
}
const html = fs.readFileSync(indexPath, "utf8");
const num = (re, d) => {
const m = html.match(re);
return m ? parseFloat(m[1]) : d;
};
const W = num(/data-width="([0-9.]+)"/, 1920);
const H = num(/data-height="([0-9.]+)"/, 1080);
const DUR = num(/data-duration="([0-9.]+)"/, 8);
const exe =
process.platform === "darwin"
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
: "/usr/bin/google-chrome";
const browser = await puppeteer.launch({
headless: "new",
executablePath: fs.existsSync(exe) ? exe : undefined,
args: [
"--disable-web-security",
"--allow-file-access-from-files",
`--window-size=${W},${H}`,
"--disable-dev-shm-usage",
],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: Math.round(W), height: Math.round(H), deviceScaleFactor: 1 });
const waitTL = async () => {
const t0 = Date.now();
while (Date.now() - t0 < 12000) {
if (await page.evaluate(() => !!(window.__timelines && window.__timelines.main)))
return true;
await new Promise((r) => setTimeout(r, 200));
}
return false;
};
await page.goto(`file://${indexPath}`, { waitUntil: "load", timeout: 20000 });
let hasTL = await waitTL();
if (!hasTL) {
// GSAP loads from CDN — a blip leaves no timeline; retry once
await page.reload({ waitUntil: "load", timeout: 20000 }).catch(() => {});
hasTL = await waitTL();
}
if (!hasTL) {
// NEVER claim "ok" when we couldn't actually evaluate the animated layout.
console.error(
"[overflow] ⚠ timeline did not register (GSAP CDN blocked?) — overflow check " +
"INCONCLUSIVE; eyeball the render for off-frame captions.",
);
await browser.close();
process.exit(0);
}
await page.evaluate(async () => {
try {
await document.fonts.ready;
} catch {}
});
const times = Array.from({ length: 9 }, (_, i) => +((DUR * i) / 8).toFixed(2));
const found = new Map(); // key text → worst offense
for (const t of times) {
await page.evaluate((t) => {
window.__timelines.main.seek(t);
void document.body.offsetHeight;
}, t);
await new Promise((r) => setTimeout(r, 25));
const offenders = await page.evaluate(
(W, H) => {
const M = 2; // tolerance px
const root = document.querySelector("#stage") || document.body;
const out = [];
for (const el of root.querySelectorAll("*")) {
if (el.tagName === "VIDEO" || el.tagName === "AUDIO") continue;
const own = [...el.childNodes]
.filter((n) => n.nodeType === 3)
.map((n) => n.textContent.trim())
.join(" ")
.trim();
if (!own) continue;
const cs = getComputedStyle(el);
if (
cs.display === "none" ||
cs.visibility === "hidden" ||
parseFloat(cs.opacity) < 0.06
)
continue;
const b = el.getBoundingClientRect();
if (b.width === 0 || b.height === 0) continue;
const off = {
left: Math.max(0, Math.round(-b.left - M)),
right: Math.max(0, Math.round(b.right - W - M)),
top: Math.max(0, Math.round(-b.top - M)),
bottom: Math.max(0, Math.round(b.bottom - H - M)),
};
if (off.left || off.right || off.top || off.bottom)
out.push({ text: own.slice(0, 42), off });
}
return out;
},
W,
H,
);
for (const o of offenders) {
const sides = Object.entries(o.off)
.filter(([, v]) => v > 0)
.map(([s, v]) => `${s} ${v}px`)
.join(", ");
const prev = found.get(o.text);
const score = Object.values(o.off).reduce((a, b) => a + b, 0);
if (!prev || score > prev.score) found.set(o.text, { sides, score, t });
}
}
if (found.size === 0) {
console.log(`[overflow] ok — no caption text leaves the ${W}x${H} frame`);
} else {
console.error(
`[overflow] ⚠ ${found.size} caption(s) leave the frame (custom mode — WARNING only, not blocking):`,
);
for (const [text, info] of found)
console.error(` "${text}" → off-frame: ${info.sides} (@${info.t}s)`);
console.error(
`[overflow] if unintentional, reposition/resize; if it's deliberate bleed, ignore.`,
);
}
} finally {
await browser.close();
}
}
main().catch((e) => {
console.error("[overflow]", e.message);
process.exit(3);
});
@@ -0,0 +1,233 @@
#!/usr/bin/env node
/*
* check-rail-climax.cjs — enforce the Rail ↔ climax hand-off (PIPELINE.md).
*
* node check-rail-climax.cjs <project-dir>
*
* The embed climax is a PROMOTED word — lifted out of the rail into the hero
* layer. It must NEVER also be revealed in the rail. This gate loads index.html
* (the climax) and rail.html (the verbatim rail) in headless Chromium, finds the
* climax's on-screen window + its word(s), then checks whether the rail reveals
* any of those same words DURING that window. If it does, the promoted word is
* duplicated on screen → exit 2 (the render aborts). Standard mode only.
*
* Conservative by design: only a CONFIRMED duplicate fails. Anything we can't
* determine (no rail.html, no puppeteer, timeline never registers, no climax)
* exits 0 — infra problems never block a render.
*/
const path = require("path");
const fs = require("fs");
const os = require("os");
const HF_ROOTS = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "../../.."),
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
function findInBun(root, pkg, sub) {
const cands = [path.join(root, "node_modules", pkg)];
const bunDir = path.join(root, "node_modules", ".bun");
try {
if (fs.existsSync(bunDir))
for (const d of fs.readdirSync(bunDir))
if (d.startsWith(pkg + "@")) cands.push(path.join(bunDir, d, "node_modules", pkg));
} catch {
/* ignore */
}
for (const c of cands) {
const p = sub ? path.join(c, sub) : c;
if (fs.existsSync(p)) return p;
}
return null;
}
let puppeteer = null,
gsapSource = null;
for (const root of HF_ROOTS) {
if (!puppeteer) {
const p = findInBun(root, "puppeteer");
if (p) {
try {
puppeteer = require(p);
} catch {}
}
}
if (!gsapSource) {
const g = findInBun(root, "gsap", path.join("dist", "gsap.min.js"));
if (g) gsapSource = fs.readFileSync(g, "utf8");
}
}
const norm = (s) =>
String(s)
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
const toks = (s) =>
String(s)
.split(/\s+/)
.map(norm)
.filter((t) => t.length >= 2);
function ok(msg) {
if (msg) console.log(msg);
process.exit(0);
} // can't-determine / pass → never blocks
function fail(msg) {
console.error(msg);
process.exit(2);
}
async function newPage(browser, W, H) {
const page = await browser.newPage();
await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 });
if (gsapSource) {
await page.evaluateOnNewDocument(gsapSource);
await page.setRequestInterception(true);
page.on("request", (req) => {
const u = req.url();
if (req.resourceType() === "script" && /gsap/i.test(u) && /^https?:/i.test(u)) req.abort();
else req.continue();
});
}
return page;
}
async function load(page, file) {
await page.goto(`file://${file}`, { waitUntil: "load", timeout: 15000 });
const start = Date.now();
while (Date.now() - start < 12000) {
if (await page.evaluate(() => !!(window.__timelines && window.__timelines.main))) {
try {
await page.evaluate(async () => {
await document.fonts.ready;
});
} catch {}
return true;
}
await new Promise((r) => setTimeout(r, 150));
}
return false;
}
// visible text of a selector at time t, by seeking the page's main timeline.
// Visibility must be EFFECTIVE (own opacity × every ancestor's): a rail line that
// fades out as a CONTAINER leaves its word spans at opacity:1 — checking only the
// span's own style false-positives on text that is actually invisible.
async function visibleAt(page, t, selector, childSel) {
return page.evaluate(
(t, selector, childSel) => {
const tl = window.__timelines.main;
tl.seek(t);
void document.body.offsetHeight;
const eff = (el) => {
let o = 1,
n = el;
while (n && n.nodeType === 1) {
const cs = getComputedStyle(n);
if (cs.display === "none" || cs.visibility === "hidden") return 0;
o *= +cs.opacity;
n = n.parentElement;
}
return o;
};
const out = [];
for (const el of document.querySelectorAll(selector)) {
if (eff(el) < 0.05) continue;
const kids = childSel ? [...el.querySelectorAll(childSel)] : [];
if (kids.length) {
for (const k of kids) {
if (eff(k) > 0.05) out.push(k.textContent);
}
} else if ((el.textContent || "").trim()) out.push(el.textContent);
}
return out.join(" ").trim();
},
t,
selector,
childSel,
);
}
async function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) ok("[rail-climax] usage: check-rail-climax.cjs <project-dir>");
const indexPath = path.join(project, "index.html");
const railPath = path.join(project, "rail.html");
if (!fs.existsSync(railPath) || !fs.existsSync(indexPath))
ok("[rail-climax] no rail.html+index.html — not Standard, skipping");
if (!puppeteer) ok("[rail-climax] puppeteer unavailable — skipping (set HYPERFRAMES_ROOT)");
const exe =
process.platform === "darwin"
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
: "/usr/bin/google-chrome";
let browser;
try {
browser = await puppeteer.launch({
headless: "new",
executablePath: fs.existsSync(exe) ? exe : undefined,
args: ["--disable-web-security", "--allow-file-access-from-files", "--disable-dev-shm-usage"],
});
} catch (e) {
ok(`[rail-climax] could not launch Chromium — skipping (${e.message})`);
}
try {
// index.html → climax window + climax word tokens
const ip = await newPage(browser, 1920, 1080);
if (!(await load(ip, indexPath)))
ok("[rail-climax] index timeline never registered — skipping");
const dur =
+(await ip.evaluate(() => {
const r = document.querySelector("#root") || document.querySelector("[data-duration]");
return (r && r.dataset && r.dataset.duration) || 0;
})) || 30;
const STEP = 0.1;
const climaxTokens = new Set();
let winIn = null,
winOut = null;
for (let t = 0; t <= dur + 0.001; t += STEP) {
const txt = await visibleAt(ip, +t.toFixed(2), ".climax", "span,.w");
if (txt) {
toks(txt).forEach((w) => climaxTokens.add(w));
if (winIn === null) winIn = t;
winOut = t;
}
}
if (!climaxTokens.size || winIn === null)
ok("[rail-climax] no visible climax found in index.html — skipping");
// rail.html → words visible during [winIn, winOut]
const rp = await newPage(browser, 1920, 1080);
if (!(await load(rp, railPath))) ok("[rail-climax] rail timeline never registered — skipping");
const hits = new Map(); // token -> first t seen
for (let t = Math.max(0, winIn - STEP); t <= winOut + 0.001; t += STEP) {
const railToks = new Set(toks(await visibleAt(rp, +t.toFixed(2), ".w", null)));
for (const w of climaxTokens) if (railToks.has(w) && !hits.has(w)) hits.set(w, +t.toFixed(2));
}
if (hits.size) {
const list = [...hits.entries()]
.map(([w, t]) => `"${w}" (rail shows it at t=${t}s)`)
.join(", ");
fail(
`[rail-climax] ✗ DUPLICATE PROMOTED WORD: the climax word(s) ${[...climaxTokens].map((w) => `"${w}"`).join(", ")} ` +
`are on screen during the climax window [${winIn.toFixed(2)}${winOut.toFixed(2)}s], ` +
`and the rail ALSO reveals: ${list}.\n` +
` The promoted word must be handed off, never duplicated — see PIPELINE.md "Rail ↔ climax hand-off":\n` +
` freeze the rail before the promoted word, let the climax carry it, hold the climax across the rail's\n` +
` page-flip, and exit it at the end of the thought. The rail must never reveal the promoted word.`,
);
}
console.log(
`[rail-climax] ✓ ok — promoted word(s) ${[...climaxTokens].map((w) => `"${w}"`).join(", ")} not duplicated in the rail (window ${winIn.toFixed(2)}${winOut.toFixed(2)}s)`,
);
} finally {
await Promise.race([browser.close().catch(() => {}), new Promise((r) => setTimeout(r, 8000))]);
}
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(`[rail-climax] (skipped — ${e.message})`);
process.exit(0);
});
@@ -0,0 +1,173 @@
#!/usr/bin/env node
/* check-timing.cjs — verify plan.json word timings vs transcript.json (1:1 port of check-timing.py).
* node check-timing.cjs <project-dir> [--strict]
*/
const path = require("path");
const fs = require("fs");
const DRIFT_TOL = 0.08;
const CREATIVE_SUBS = {
"15%": ["fifteen", "percent"],
"1/3": ["one", "third"],
"2x": ["two", "times"],
};
const norm = (s) =>
String(s)
.replace(/^[ .,?!"']+|[ .,?!"']+$/g, "")
.toLowerCase();
const splitPacked = (t) =>
String(t)
.split(/<br>|\s+/)
.filter(Boolean);
function estimateVerticalBand(css, words, fw, fh) {
const top = css.match(/top:\s*(-?[\d.]+)%/);
if (!top) return null;
const topPct = parseFloat(top[1]);
const sizeM = css.match(/font-size:\s*calc\(\s*([\d.]+)\s*\*\s*var\(--h\)\s*\)/);
const fontPx = sizeM ? parseFloat(sizeM[1]) * fh : 0.05 * fh;
const lhM = css.match(/line-height:\s*([\d.]+)/);
const lh = lhM ? parseFloat(lhM[1]) : 1.1;
const charW = /uppercase/i.test(css) ? 0.72 : 0.6;
let lineCount = 1,
cur = 0;
const spaceW = fontPx * 0.3;
for (const w of words) {
const parts = String(w.text).split(/<br>/);
parts.forEach((part, i) => {
const pw = part.length * fontPx * charW;
if (i > 0) {
lineCount++;
cur = pw;
} else {
const test = cur + (cur > 0 ? spaceW : 0) + pw;
if (test > fw && cur > 0) {
lineCount++;
cur = pw;
} else cur = test;
}
});
}
return [topPct, topPct + (100 * (lineCount * fontPx * lh)) / fh];
}
function check(project, strict) {
const plan = JSON.parse(fs.readFileSync(path.join(project, "plan.json"), "utf8"));
const t = JSON.parse(fs.readFileSync(path.join(project, "transcript.json"), "utf8"));
const seq = (t.words || [])
.filter((w) => w.type === "word")
.map((w) => [norm(w.text), w.start, w.end]);
const issues = [];
const fw = plan.width || 720,
fh = plan.height || 1290;
const groups = plan.groups || [];
const bands = groups.map((g) =>
g.plane
? [g.id || "?", g.in, g.out, null, true]
: [
g.id || "?",
g.in,
g.out,
estimateVerticalBand(g.css || "", g.words || [], fw, fh),
g.allow_overlap || false,
],
);
for (let i = 0; i < bands.length; i++) {
const [gi, ai, bi, bbi, oki] = bands[i];
if (!bbi) continue;
for (let j = i + 1; j < bands.length; j++) {
const [gj, aj, bj, bbj, okj] = bands[j];
if (!bbj || oki || okj) continue;
if (Math.min(bi, bj) - Math.max(ai, aj) <= 0.05) continue;
if (Math.min(bbi[1], bbj[1]) - Math.max(bbi[0], bbj[0]) > 2.0)
issues.push(
`[${gi}${gj}] groups overlap in time (${Math.max(ai, aj).toFixed(2)}${Math.min(bi, bj).toFixed(2)}s) AND vertically (band ${Math.max(bbi[0], bbj[0]).toFixed(0)}%${Math.min(bbi[1], bbj[1]).toFixed(0)}%) — reposition one or add "allow_overlap": true if deliberate.`,
);
}
}
let ti = 0;
for (const g of groups) {
const gid = g.id || "?",
gin = g.in,
gout = g.out;
const ws = (g.words || []).map((w) => w.start),
we = (g.words || []).map((w) => w.end);
if (ws.length && gin != null) {
const e = Math.min(...ws);
if (e < gin - 0.01)
issues.push(
`[${gid}] group.in=${gin.toFixed(2)} but earliest word starts at ${e.toFixed(2)} — word delayed by ${(gin - e >= 0 ? "+" : "") + (gin - e).toFixed(2)}s. Lower group.in.`,
);
}
if (we.length && gout != null) {
const l = Math.max(...we);
if (l > gout + 0.01)
issues.push(
`[${gid}] group.out=${gout.toFixed(2)} but latest word ends at ${l.toFixed(2)} — word clipped. Raise group.out.`,
);
}
for (const w of g.words || []) {
// Exact pairing: a compiler that KNOWS the transcript index emits `ti` —
// duplicate words then pair by POSITION, not text-search (which grabs the
// first occurrence and reports a phantom drift). Sanity-check the text; on
// mismatch fall back to the text matcher below.
if (
Number.isInteger(w.ti) &&
seq[w.ti] &&
seq[w.ti][0] === norm(splitPacked(w.text)[0] || "")
) {
const drift = w.start - seq[w.ti][1];
if (Math.abs(drift) > DRIFT_TOL)
issues.push(
`[${gid}] '${norm(w.text)}': plan=${w.start.toFixed(3)} transcript=${seq[w.ti][1].toFixed(3)} drift ${(drift >= 0 ? "+" : "") + drift.toFixed(3)}s`,
);
ti = w.ti + 1;
continue;
}
const parts = splitPacked(w.text).map(norm);
parts.forEach((part, pi) => {
if (!part) return;
if (part in CREATIVE_SUBS) {
for (const sub of CREATIVE_SUBS[part]) {
const j = seq.findIndex((s, k) => k >= ti && s[0] === sub);
if (j >= 0) ti = j + 1;
}
return;
}
let found = seq.findIndex((s, k) => k >= ti && s[0] === part);
if (found < 0) found = seq.findIndex((s) => s[0] === part);
if (found < 0) {
issues.push(`[${gid}] '${part}': NOT IN TRANSCRIPT (plan start=${w.start.toFixed(3)})`);
return;
}
const ts = seq[found][1];
ti = found + 1;
if (pi === 0) {
const drift = w.start - ts;
if (Math.abs(drift) > DRIFT_TOL)
issues.push(
`[${gid}] '${part}': plan=${w.start.toFixed(3)} transcript=${ts.toFixed(3)} drift ${(drift >= 0 ? "+" : "") + drift.toFixed(3)}s`,
);
} else
issues.push(
`[${gid}] '${part}' packed with '${parts[0]}': transcript=${ts.toFixed(3)} but entry has no distinct timing — split into separate word entries`,
);
});
}
}
const name = path.basename(project);
if (!issues.length) {
console.log(`${name}: timing OK ✓ (${seq.length} transcript words)`);
return 0;
}
console.log(`${name}: ${issues.length} timing issue(s):`);
for (const i of issues) console.log(` ${i}`);
return strict ? 1 : 0;
}
const args = process.argv.slice(2).filter((a) => a !== "--strict");
if (!args.length) {
console.error("usage: check-timing.cjs <project-dir> [--strict]");
process.exit(2);
}
process.exit(check(path.resolve(args[0]), process.argv.includes("--strict")));
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/*
* fill-timings.cjs — fill plan.json group/word times DIRECTLY from transcript.json,
* by SEQUENCE (Cinematic mode). Kills the timing-drift + duplicate-word class of
* occlusion/timing-gate re-tries: the agent only decides the GROUPING (which words form
* each group, in spoken order); times come from the transcript by position, so the
* second "and"/"actions" matches the right occurrence (text-matching pairs the wrong one).
*
* node fill-timings.cjs <project-dir>
*
* Reads <project>/plan.json (groups[].words[].text, in spoken order) + transcript.json.
* Writes <project>/plan.json with each word's start/end and each group's in/out filled.
* A group word that can't be matched keeps whatever time it had + is reported (so a typo
* or an added word degrades gracefully instead of silently mis-timing).
*/
const path = require("path");
const fs = require("fs");
const norm = (s) =>
String(s == null ? "" : s)
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
const LOOKAHEAD = 40; // how far past the pointer to search for a group word (skips dropped fillers)
function fillGroup(g, tw, state) {
const matched = [];
const unmatched = [];
for (const w of g.words || []) {
const target = norm(w.text);
if (!target) {
unmatched.push(w.text);
continue;
}
let found = -1;
for (let j = state.p; j < Math.min(tw.length, state.p + LOOKAHEAD); j++) {
if (norm(tw[j].text) === target) {
found = j;
break;
}
}
if (found >= 0) {
w.start = tw[found].start;
w.end = tw[found].end;
matched.push(w);
state.p = found + 1;
} else {
unmatched.push(w.text);
}
}
if (matched.length) {
const firstStart = +matched[0].start.toFixed(3);
const lastEnd = +matched[matched.length - 1].end.toFixed(3);
// WORD start/end are filled above (deterministic). For the group's DISPLAY window we
// only GUARANTEE it brackets the words (never clip a word) — we do NOT tighten it:
// a deliberately later `out` is the apex HOLD / sentence ACCUMULATION the author set,
// and an earlier `in` is a pre-entry. Preserve both; clamp only if they'd clip.
g.in = g.in == null ? firstStart : Math.min(g.in, firstStart);
g.out = g.out == null ? lastEnd : Math.max(g.out, lastEnd);
}
return { matched: matched.length, unmatched };
}
function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: fill-timings.cjs <project-dir>");
process.exit(1);
}
const planPath = path.join(project, "plan.json");
const trPath = path.join(project, "transcript.json");
let plan, trWordsRaw;
try {
plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
} catch {
console.error(`[fill-timings] no plan.json (Cinematic only) — skipping`);
process.exit(0);
}
try {
trWordsRaw = JSON.parse(fs.readFileSync(trPath, "utf8")).words || [];
} catch {
console.error(`[fill-timings] no transcript.json — skipping`);
process.exit(0);
}
const tw = trWordsRaw.filter((w) => w && "start" in w && "end" in w);
if (!tw.length) {
console.error(`[fill-timings] transcript has no word timings — skipping`);
process.exit(0);
}
const state = { p: 0 };
let totalMatched = 0,
totalUnmatched = 0;
const groups = plan.groups || [];
// crown_group (if any) is spoken last in the standard templates; process groups in order, crown after.
const ordered = [...groups];
for (const g of ordered) {
const r = fillGroup(g, tw, state);
totalMatched += r.matched;
totalUnmatched += r.unmatched.length;
if (r.unmatched.length)
console.error(
`[fill-timings] ⚠ ${g.id || "(group)"}: ${r.unmatched.length} word(s) not found in transcript from here: ${r.unmatched.join(" ")}`,
);
}
if (plan.crown_group) {
const r = fillGroup(plan.crown_group, tw, state);
totalMatched += r.matched;
totalUnmatched += r.unmatched.length;
if (r.unmatched.length)
console.error(`[fill-timings] ⚠ crown_group: ${r.unmatched.join(" ")} not found`);
}
fs.writeFileSync(planPath, JSON.stringify(plan, null, 2));
console.log(
`[fill-timings] filled ${totalMatched} word time(s) from transcript by sequence` +
(totalUnmatched
? `; ${totalUnmatched} unmatched (kept prior time — check those words)`
: `; all matched ✓`),
);
console.log(
`[fill-timings] group windows now: ${ordered.map((g) => `${g.id || "?"}[${g.in}-${g.out}]`).join(" ")}`,
);
}
main();
@@ -0,0 +1,158 @@
#!/usr/bin/env node
/*
* fit-fonts.cjs — shrink any plan.json caption group whose single line would overflow
* its box, BEFORE render (Cinematic mode). Kills the "font too big → off-frame → shrink
* → re-render" overflow round. Estimates rendered width from character count × a
* per-family advance × the group's font-size, compares to the group's box width (its
* plane width, else the frame), and only ever SHRINKS to fit. Approximate by design —
* it prevents gross overflow; check-overflow.cjs remains the exact terminal check.
*
* node fit-fonts.cjs <project-dir>
*/
const path = require("path");
const fs = require("fs");
// rough per-family advance (em per char, caps-ish). Condensed faces are narrow; pixel
// faces wide. Default is conservative. (Estimate only — gate confirms.)
const ADV = {
anton: 0.44,
oswald: 0.45,
teko: 0.4,
"saira stencil one": 0.46,
"bebas neue": 0.4,
"press start 2p": 1.05,
vt323: 0.52,
monoton: 0.62,
"special elite": 0.55,
"jetbrains mono": 0.6,
"space mono": 0.6,
"ibm plex mono": 0.6,
"source code pro": 0.6,
"archivo black": 0.62,
bangers: 0.5,
"bodoni moda": 0.5,
"playfair display": 0.52,
cinzel: 0.62,
"cormorant garamond": 0.45,
fredoka: 0.55,
"baloo 2": 0.55,
"permanent marker": 0.55,
caveat: 0.4,
orbitron: 0.66,
sora: 0.54,
"space grotesk": 0.54,
};
const DEFAULT_ADV = 0.56;
const cssVal = (css, prop) => {
const m = String(css || "").match(new RegExp(prop + "\\s*:\\s*([^;}]+)", "i"));
return m ? m[1].trim() : null;
};
function familyOf(css) {
const v = cssVal(css, "font-family");
if (!v) return "inter";
return v
.split(",")[0]
.trim()
.replace(/^['"]|['"]$/g, "")
.toLowerCase();
}
function sizePxOf(css, H) {
const v = cssVal(css, "font-size");
if (!v) return null;
let m;
if ((m = v.match(/calc\(\s*([\d.]+)\s*\*\s*var\(--h\)\s*\)/i))) return parseFloat(m[1]) * H;
if ((m = v.match(/([\d.]+)\s*cqh/i))) return (parseFloat(m[1]) / 100) * H;
if ((m = v.match(/([\d.]+)\s*vh/i))) return (parseFloat(m[1]) / 100) * H;
if ((m = v.match(/([\d.]+)\s*px/i))) return parseFloat(m[1]);
return null;
}
function planeWidthPx(plan, plane, W) {
const p = plan.planes && plan.planes[plane];
const css = p && (typeof p === "string" ? p : p.css);
const v = cssVal(css, "width");
if (!v) return null;
let m;
if ((m = v.match(/([\d.]+)\s*%/))) return (parseFloat(m[1]) / 100) * W;
if ((m = v.match(/([\d.]+)\s*px/))) return parseFloat(m[1]);
return null;
}
function setFontSize(css, fracOfH) {
const decl = `font-size: calc(${fracOfH.toFixed(3)} * var(--h))`;
if (/font-size\s*:/i.test(css || "")) return String(css).replace(/font-size\s*:\s*[^;}]+/i, decl);
return (css ? css.replace(/\s*$/, "").replace(/;?\s*$/, "; ") : "") + decl + ";";
}
function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: fit-fonts.cjs <project-dir>");
process.exit(1);
}
const planPath = path.join(project, "plan.json");
let plan;
try {
plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
} catch {
console.error("[fit-fonts] no plan.json (Cinematic only) — skipping");
process.exit(0);
}
const W = plan.width || 1920,
H = plan.height || 1080;
const groups = (plan.groups || []).concat(plan.crown_group ? [plan.crown_group] : []);
const MARGIN = 0.92; // leave 8% breathing room inside the box
const HERO_MIN_FRAC = 0.18; // a hero below ~0.18·h isn't "big" — keep it punchy, widen its box instead
let changed = 0;
for (const g of groups) {
const text = (g.words || []).map((w) => w.text).join(" ");
const nChars = text.replace(/\s/g, "").length + (g.words || []).length * 0.5; // glyphs + ~half-space gaps
if (nChars < 1) continue;
const fam = familyOf(g.css);
let adv = ADV[fam] ?? DEFAULT_ADV;
if (/text-transform\s*:\s*uppercase/i.test(g.css || "") || text === text.toUpperCase())
adv *= 1.05;
const sizePx = sizePxOf(g.css, H);
if (!sizePx) continue; // no explicit size → template default handles it
const boxW = (g.plane ? planeWidthPx(plan, g.plane, W) : null) || W;
const usable = boxW * MARGIN;
// A WRAPPING group only overflows if a SINGLE word is wider than the box (the rest
// wraps). A NOWRAP group must fit its whole line. So don't shrink wrapping narration
// down to one line — only force the whole line for nowrap; else just the longest word.
const nowrap = /white-space\s*:\s*nowrap/i.test(g.css || "");
const longestWord = (g.words || []).reduce(
(m, w) => Math.max(m, String(w.text || "").replace(/\s/g, "").length),
0,
);
const constrainChars = nowrap ? nChars : longestWord;
if (constrainChars < 1) continue;
const estW = constrainChars * adv * sizePx;
if (estW > usable) {
const newPx = usable / (constrainChars * adv);
let frac = Math.max(0.02, newPx / H);
const isHero = g.hero === true || /^(hero|crown)$/i.test(g.plane || "");
if (isHero && frac < HERO_MIN_FRAC) {
// The hero must stay BIG. If it won't fit its box while staying punchy, the BOX is
// too narrow (the hero should span the subject) — keep it at the floor + flag it,
// rather than silently shrinking the peak into a small word.
frac = HERO_MIN_FRAC;
g.css = setFontSize(g.css, frac);
changed++;
console.log(
`[fit-fonts] ⚠ ${g.id || "(group)"} HERO "${text.slice(0, 28)}" won't fit box ${Math.round(usable)}px while staying big → KEPT at floor ${frac}·h. WIDEN the hero plane (it should span the subject, centered), not shrink the peak.`,
);
continue;
}
g.css = setFontSize(g.css, frac);
changed++;
console.log(
`[fit-fonts] ${g.id || "(group)"}: "${text.slice(0, 28)}" (${nowrap ? "nowrap line" : "longest word"}) est ${Math.round(estW)}px > box ${Math.round(usable)}px → shrink to ${frac.toFixed(3)}·h (${Math.round(newPx)}px)`,
);
}
}
if (changed) {
fs.writeFileSync(planPath, JSON.stringify(plan, null, 2));
console.log(`[fit-fonts] shrank ${changed} group(s) to fit; wrote plan.json`);
} else console.log("[fit-fonts] all groups fit their box — no change");
}
main();
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""gen-stroke-path.py — generalized draw-on path generator.
Lays out ANY word in a single-line (pen-path) SVG font — Hershey/EMS — and emits
one continuous-dash-revealable path `d`. The glyph data IS the pen path, so
stroke-order reveal is exact by construction for any text, no per-word tuning.
Usage: gen-stroke-path.py <font.svg> <text> <target_width_px> <baseline_y> <x0>
Prints: the path `d` string + layout info on stderr.
"""
import re, sys
font_path, text, target_w, baseline_y, x0 = (
sys.argv[1], sys.argv[2], float(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5]))
svg = open(font_path).read()
glyphs = {}
for m in re.finditer(r'<glyph\s+unicode="(.)"[^>]*?horiz-adv-x="([\d.]+)"(?:[^>]*?d="([^"]*)")?', svg):
ch, adv, d = m.group(1), float(m.group(2)), m.group(3) or ""
glyphs[ch] = (adv, d)
# default advance for space
space_adv = glyphs.get(" ", (300, ""))[0]
# total advance width in font units
total = 0.0
for ch in text:
total += glyphs.get(ch, (space_adv, ""))[0]
s = target_w / total # scale font-units → px
out = []
cursor = 0.0
NUM = re.compile(r"[-\d.]+")
for ch in text:
adv, d = glyphs.get(ch, (space_adv, ""))
if d:
# polylines only (M/L): transform every coordinate pair
toks = re.findall(r"([ML])\s+([-\d.]+)\s+([-\d.]+)", d)
for cmd, xs, ys in toks:
x = x0 + (cursor + float(xs)) * s
y = baseline_y - float(ys) * s # SVG-font y is UP; flip
out.append(f"{cmd} {x:.1f} {y:.1f}")
cursor += adv
print(" ".join(out))
print(f"[gen] chars={len(text)} scale={s:.3f} width={total*s:.0f}px "
f"subpaths={sum(1 for o in out if o.startswith('M'))}", file=sys.stderr)
@@ -0,0 +1,169 @@
#!/usr/bin/env node
/*
* inject-fonts.cjs — inline the @font-face blocks each HTML actually uses.
*
* node inject-fonts.cjs <project-dir> [file.html ...]
*
* hyperframes' renderer only auto-supplies its ~18 CANONICAL_FONTS. Every other
* family (Anton, Bangers, VT323, Press Start 2P, …) falls back to a generic font
* unless the HTML ships a local @font-face — even when it "looks fine" locally,
* because that only works when the font happens to be installed as a system font.
* On a clean/offline/CI machine it silently degrades. (hyperframes' own font
* linter flags exactly this: font_family_without_font_face.)
*
* This step reads the bundled font library (modes/standard/fonts/fonts.css —
* base64 woff2, no sub-resources), finds which of those families the HTML uses,
* and inlines just those @font-face blocks into a <style id="hf-embedded-fonts">.
* Idempotent (re-running replaces the block). Families already declared in the
* HTML, hyperframes-canonical families, and CSS generics are left untouched.
*
* Runs BEFORE the layout gates + render so measure-layout and the capture both
* see the real glyph metrics, never a fallback.
*/
const fs = require("fs");
const path = require("path");
const SKILL_ROOT = path.resolve(__dirname, "..");
const FONTS_CSS = path.join(SKILL_ROOT, "modes", "standard", "fonts", "fonts.css");
const MARKER = "hf-embedded-fonts";
const GENERIC = new Set([
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-serif",
"ui-sans-serif",
"ui-monospace",
"ui-rounded",
"math",
"emoji",
"fangsong",
"inherit",
"initial",
"unset",
"revert",
"revert-layer",
]);
// Parse fonts.css → Map<lowercased family, [ @font-face block strings ]>.
function loadFontLibrary() {
let css;
try {
css = fs.readFileSync(FONTS_CSS, "utf8");
} catch {
console.error(
`[inject-fonts] missing ${FONTS_CSS} — run modes/standard/fonts/build-fonts-css.cjs`,
);
process.exit(2);
}
const lib = new Map();
// base64 data-URIs contain no "}", so [^}]* safely bounds a block.
const blockRe = /@font-face\s*\{[^}]*\}/gi;
const famRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i;
let m;
while ((m = blockRe.exec(css)) !== null) {
const fam = m[0].match(famRe);
if (!fam) continue;
const key = fam[2].trim().toLowerCase();
if (!lib.has(key)) lib.set(key, []);
lib.get(key).push(m[0]);
}
return lib;
}
// Families referenced anywhere in the HTML: CSS `font-family:` (in <style> or
// inline style="") and the data-font-family attribute hyperframes also honors.
function usedFamilies(html) {
const out = new Set();
const add = (stack) => {
for (const part of String(stack).split(",")) {
const name = part
.trim()
.replace(/^['"]|['"]$/g, "")
.trim()
.toLowerCase();
if (name && !GENERIC.has(name) && !/^var\(/.test(name)) out.add(name);
}
};
// strip existing @font-face so we don't re-list the family it declares
const body = html.replace(/@font-face\s*\{[^}]*\}/gi, "");
for (const mm of body.matchAll(/font-family\s*:\s*([^;}{]+)/gi)) add(mm[1]);
for (const mm of html.matchAll(/data-font-family\s*=\s*["']([^"']+)["']/gi)) add(mm[1]);
return out;
}
// Families the HTML ALREADY declares via @font-face — leave those alone.
function declaredFamilies(html) {
const out = new Set();
const famRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i;
for (const mm of html.matchAll(/@font-face\s*\{[^}]*\}/gi)) {
const f = mm[0].match(famRe);
if (f) out.add(f[2].trim().toLowerCase());
}
return out;
}
function injectInto(file, lib) {
let html;
try {
html = fs.readFileSync(file, "utf8");
} catch {
return null;
}
// drop any previous injection so this is idempotent
html = html.replace(new RegExp(`\\s*<style id="${MARKER}">[\\s\\S]*?</style>`, "i"), "");
const used = usedFamilies(html);
const already = declaredFamilies(html);
const blocks = [];
const inlined = [];
for (const fam of used) {
if (already.has(fam)) continue;
const lb = lib.get(fam);
if (!lb) continue; // canonical / system / unknown — not ours to supply
blocks.push(...lb);
inlined.push(fam);
}
if (!blocks.length) {
fs.writeFileSync(file, html);
return { file: path.basename(file), inlined: [] };
}
const style = `<style id="${MARKER}">\n/* auto-embedded by inject-fonts.cjs — deterministic template fonts */\n${blocks.join("\n")}\n</style>`;
// Prefer right after <head...>; fall back to before </head>, then <html>, then prepend.
if (/<head[^>]*>/i.test(html)) html = html.replace(/<head[^>]*>/i, (m0) => `${m0}\n${style}`);
else if (/<\/head>/i.test(html)) html = html.replace(/<\/head>/i, `${style}\n</head>`);
else if (/<html[^>]*>/i.test(html))
html = html.replace(/<html[^>]*>/i, (m0) => `${m0}\n${style}`);
else html = style + "\n" + html;
fs.writeFileSync(file, html);
return { file: path.basename(file), inlined };
}
function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: inject-fonts.cjs <project-dir> [file.html ...]");
process.exit(1);
}
const lib = loadFontLibrary();
let files = process.argv.slice(3);
if (!files.length) files = ["index.html", "rail.html", "index_fg.html"];
files = files.map((f) => (path.isAbsolute(f) ? f : path.join(project, f)));
let touched = 0;
for (const f of files) {
const r = injectInto(f, lib);
if (r == null) continue;
if (r.inlined.length) {
console.log(`[inject-fonts] ${r.file}: embedded ${r.inlined.join(", ")}`);
touched++;
} else console.log(`[inject-fonts] ${r.file}: no non-canonical fonts to embed`);
}
if (!touched)
console.log(`[inject-fonts] nothing embedded (all fonts canonical/system or already declared)`);
}
main();
@@ -0,0 +1,201 @@
/*
* lib-dna.cjs — DNA registry loader + scene-token resolver.
*
* A DNA is a complete visual language (type, palette logic, motion grammar, hero
* orchestration) that PARAMETERIZES per scene instead of shipping fixed look files.
* The resolver folds in safe-zones.json's v2 scene measurements (palette / optics /
* lighting) and envelope.json's loudness so the compiled tokens fit THIS clip:
*
* accent — scene-sampled when the DNA says "scene" (clamped readable)
* text shadow — contact shadow cast along the measured light direction
* depth match — embed text blur follows the scene's depth-of-field
* hero amp — entrance amplitude coupled to the spoken word's loudness (RMS)
*
* Everything is read from files written by deterministic scripts — no randomness.
*/
const path = require("path");
const fs = require("fs");
const SKILL_ROOT = path.resolve(__dirname, "..");
const DNA_DIR = path.join(SKILL_ROOT, "dna");
// legacy template names → DNA (back-compat for existing plan.json files)
const LEGACY = { "cinematic-cream": "cream" };
function list() {
try {
return fs
.readdirSync(DNA_DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(/\.json$/, ""))
.sort();
} catch {
return [];
}
}
function load(name) {
const p = path.join(DNA_DIR, `${name}.json`);
if (!fs.existsSync(p)) {
throw new Error(`unknown DNA "${name}". Available: ${list().join(", ")}`);
}
return JSON.parse(fs.readFileSync(p, "utf8"));
}
function readJson(p) {
try {
return JSON.parse(fs.readFileSync(p, "utf8"));
} catch {
return null;
}
}
// ── audio impact: where does this window's loudness rank within the clip? ──────
// envelope.json = { hop, rms: [linear 0..1 per hop] } (audio-envelope.cjs)
function heroImpact(project, t0, t1) {
const env = readJson(path.join(project, "envelope.json"));
if (!env || !Array.isArray(env.rms) || !env.rms.length) return 0.6; // neutral default
const hop = env.hop || 0.05;
const i0 = Math.max(0, Math.floor(t0 / hop)),
i1 = Math.min(env.rms.length, Math.ceil(t1 / hop));
if (i1 <= i0) return 0.6;
const span = i1 - i0;
const mean = (arr, a, b) => {
let s = 0;
for (let i = a; i < b; i++) s += arr[i];
return s / (b - a);
};
const target = mean(env.rms, i0, i1);
// percentile of this window vs all same-length windows across the clip
let below = 0,
total = 0;
for (let s = 0; s + span <= env.rms.length; s += Math.max(1, Math.floor(span / 2))) {
total++;
if (mean(env.rms, s, s + span) <= target) below++;
}
return total ? below / total : 0.6;
}
// ── scene-aware token resolution ───────────────────────────────────────────────
function resolveTokens(dna, project, opts) {
const sz = readJson(path.join(project, "safe-zones.json")) || {};
const palette = sz.palette || {};
const optics = sz.optics || {};
const lighting = sz.lighting || {};
// accent: "scene" → sampled suggestion, else literal.
// accentMode "counter" (loud registers): the accent must FIGHT the scene's
// temperature, not harmonize with it — a sampled warm sienna inside a tungsten room
// camouflages (the warm-on-warm failure a blind review caught across three DNAs).
// Counter-pole = rotate the sampled hue 180° and push saturation/lightness hot.
let accent =
dna.palette.accent === "scene"
? palette.accentSuggestion || dna.palette.accent_fallback || "#e3c06a"
: dna.palette.accent;
if (
dna.palette.accentMode === "counter" &&
dna.palette.accent === "scene" &&
/^#([0-9a-f]{6})$/i.test(accent || "")
) {
const n = parseInt(accent.slice(1), 16);
let r = (n >> 16) / 255,
g2 = ((n >> 8) & 255) / 255,
b = (n & 255) / 255;
const mx = Math.max(r, g2, b),
mn = Math.min(r, g2, b),
d = mx - mn;
let h = 0;
if (d > 0) {
h = mx === r ? ((g2 - b) / d) % 6 : mx === g2 ? (b - r) / d + 2 : (r - g2) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
h = (h + 180) % 360;
const s = 0.85,
v = 0.92; // hot, acid, readable
const c = v * s,
x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
m = v - c;
const [r2, g3, b2] =
h < 60
? [c, x, 0]
: h < 120
? [x, c, 0]
: h < 180
? [0, c, x]
: h < 240
? [0, x, c]
: h < 300
? [x, 0, c]
: [c, 0, x];
const f = (q) =>
Math.round((q + m) * 255)
.toString(16)
.padStart(2, "0");
accent = `#${f(r2)}${f(g3)}${f(b2)}`;
}
const dark = dna.palette.scheme === "dark-on-light";
// contact shadow along the measured light direction + a soft ambient.
// light-on-dark also gets the warm bloom layer when the DNA asks for it.
let shadowParts = [];
if (dna.optics && dna.optics.contactShadow) {
const sd = lighting.shadow || { dx: 0, dy: 3 };
shadowParts.push(`${sd.dx}px ${sd.dy + 1}px 10px rgba(0,0,0,${dark ? 0.22 : 0.32})`);
}
shadowParts.push(dark ? "0 1px 14px rgba(0,0,0,0.10)" : "0 2px 22px rgba(0,0,0,0.28)");
if (dna.optics && dna.optics.warmBloom) shadowParts.push("0 0 26px rgba(255,214,160,0.18)");
const textShadow = shadowParts.join(", ");
// depth match: embed (bg) text picks up the scene's bokeh; fg/rail stay sharp
const blurPx = (dna.optics && dna.optics.depthMatch && optics.suggestedTextBlurPx) || 0;
const baseFilter = dna.filter && dna.filter !== "none" ? dna.filter : "";
const filterBg = `${baseFilter}${blurPx ? ` blur(${blurPx}px)` : ""}`.trim() || "none";
const filterFg = baseFilter || "none";
// hero orchestration + loudness coupling — one config PER hero (scarcity is per
// beat/block, not per clip; each hero's amplitude follows ITS spoken loudness)
const heroGroups = (opts && (opts.heroGroups || (opts.heroGroup ? [opts.heroGroup] : []))) || [];
const heroes = heroGroups.map((hg) => {
const impact = heroImpact(project, hg.in, Math.min(hg.in + 0.7, hg.out));
const amp = +((0.75 + impact * 0.5) * (hg.minor ? 0.7 : 1)).toFixed(3); // 0.75…1.25, minors damped
return {
// spread the DNA's full hero block (entrance/perLetter/glow/breathe/ripple/loom/
// letterBlur/sheen/echoes/… — new fx fields flow through with no plumbing),
// then the per-hero computed values
...dna.hero,
entrance: dna.hero.entrance || "emergence",
dimOthers: dna.hero.dimOthers != null ? dna.hero.dimOthers : 0.45,
id: hg.id,
minor: hg.minor === true,
in: hg.in,
out: hg.out,
amp,
heroColor: dna.palette.heroColor === "accent" ? accent : null,
};
});
const hero = heroes[0] || null; // back-compat single accessor
return {
fontFamily: dna.font.family,
capColor: dna.palette.cap_color,
blend: dna.palette.blend,
accent,
textShadow,
filterBg,
filterFg,
blurPx,
motion: dna.motion,
hero,
heroes,
heroCss: (dna.hero && dna.hero.css) || "",
heroCase: (dna.hero && dna.hero.case) || "none",
heroTracking: (dna.hero && dna.hero.tracking) || "0",
dnaCss: dna.css || "",
register: dna.register,
name: dna.name,
};
}
module.exports = { LEGACY, list, load, resolveTokens, heroImpact };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,404 @@
#!/usr/bin/env node
/*
* make-composition.cjs — compile plan.json → index.html (CINEMATIC MODE).
*
* DNA path (canonical): plan.dna names a dna/<name>.json → modes/cinematic/engine.html
* is compiled with scene-resolved tokens (lib-dna.cjs): scene-sampled accent, light-
* direction contact shadow, depth-match blur, hero three-act orchestration with
* RMS-coupled amplitude. plan.template="cinematic-cream" maps to dna "cream".
*
* Legacy path: plan.template naming an archived template dir still compiles 1:1
* (memory-wall / champion / portrait-header in modes/cinematic/_archive are NOT
* auto-discovered — restore them to modes/cinematic/ to use).
*
* node make-composition.cjs <project-dir>
*/
const path = require("path");
const fs = require("fs");
const cp = require("child_process");
const dnaLib = require("./lib-dna.cjs");
const SKILL_ROOT = path.resolve(__dirname, "..");
const TEMPLATES = path.join(SKILL_ROOT, "modes", "cinematic");
// Source clip duration (seconds) via ffprobe. The COMPOSITION/background length must
// follow the SOURCE, not the last caption — see the Bug-1 note in main().
function sourceDurationSec(project) {
let cands = ["source.mp4"];
try {
cands = cands.concat(
fs
.readdirSync(project)
.filter(
(f) =>
/\.(mp4|mov|webm|mkv|m4v)$/i.test(f) &&
!/^(final|bg_plus_caps|fg_caps|rail|index)/.test(f),
),
);
} catch {}
for (const c of cands) {
const p = path.isAbsolute(c) ? c : path.join(project, c);
if (!fs.existsSync(p)) continue;
try {
const out = cp
.execFileSync(
"ffprobe",
[
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nokey=1:noprint_wrappers=1",
p,
],
{ encoding: "utf8" },
)
.trim();
const d = parseFloat(out);
if (d > 0) return d;
} catch {}
}
return null;
}
function hexLum(hex) {
let h = String(hex).replace("#", "");
if (h.length === 3) h = [...h].map((c) => c + c).join("");
const r = parseInt(h.slice(0, 2), 16) / 255,
g = parseInt(h.slice(2, 4), 16) / 255,
b = parseInt(h.slice(4, 6), 16) / 255;
if ([r, g, b].some(Number.isNaN)) return 0.9;
return 0.299 * r + 0.587 * g + 0.114 * b;
}
const defaultTextShadow = (c) =>
hexLum(c) < 0.45
? "0 2px 6px rgba(0, 0, 0, 0.28)"
: "0 0 18px rgba(255, 220, 170, 0.55), 0 3px 8px rgba(0, 0, 0, 0.85)";
const defaultTextFilter = (c) =>
hexLum(c) < 0.45 ? "contrast(1.08)" : "brightness(1.1) contrast(1.05)";
function findTemplate(name) {
const p = path.join(TEMPLATES, name, "template.html");
if (fs.existsSync(p)) return p;
const avail = fs
.readdirSync(TEMPLATES)
.filter((d) => fs.existsSync(path.join(TEMPLATES, d, "template.html")))
.sort();
console.error(`[compile] unknown template: ${name}. Available: ${avail.join(", ")}`);
process.exit(1);
}
function escBr(t) {
const e = String(t)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
return e
.replace(/&lt;br&gt;/g, "<br>")
.replace(/&lt;br\/&gt;/g, "<br>")
.replace(/&lt;br \/&gt;/g, "<br>");
}
// hero words get per-letter inner spans (<span class="l">) so the engine can stagger
// letters; .w keeps wrapping the word so every gate (occlusion / overflow / layout)
// measures exactly what it measured before.
function wordSpan(w, i, perLetter) {
if (!perLetter) return `<span class="w" data-i="${i}">${escBr(w.text)}</span>`;
const t = String(w.text);
if (/<br\s*\/?>/i.test(t)) return `<span class="w" data-i="${i}">${escBr(t)}</span>`; // explicit <br> words stay whole
const letters = [...t]
.map((ch) => (ch === " " ? " " : `<span class="l">${escBr(ch)}</span>`))
.join("");
return `<span class="w" data-i="${i}">${letters}</span>`;
}
function renderCap(g, heroCfg) {
const slot = g.slot ?? g.style ?? "";
const layerAttr = g.layer ? ` data-layer="${g.layer}"` : "";
const isHero = heroCfg && g.hero === true;
const perLetter = isHero && heroCfg.perLetter;
const cls = slot ? `cap cap-${slot}` : "cap";
const spans = g.words.map((w, i) => wordSpan(w, i, perLetter)).join("\n ");
const capDiv = `<div id="${g.id}" class="${cls}"${layerAttr}>\n ${spans}\n </div>`;
if (!isHero) return capDiv;
// fx siblings render UNDER the hero (earlier sibling = beneath): glow first, then
// echoes, then the hero itself. Plain .w spans; none are in plan.groups → gates skip.
const plainSpans = g.words
.map((w, i) => `<span class="w" data-i="${i}">${escBr(w.text)}</span>`)
.join("\n ");
const parts = [];
if (heroCfg.glow)
parts.push(
`<div id="${g.id}-glow" class="${cls} hero-glow" aria-hidden="true"${layerAttr}>\n ${plainSpans}\n </div>`,
);
(heroCfg.echoes || []).forEach((ec, k) => {
parts.push(
`<div id="${g.id}-echo-${k}" class="${cls} hero-echo" aria-hidden="true"${layerAttr}>\n ${plainSpans}\n </div>`,
);
});
parts.push(capDiv);
return parts.join("\n ");
}
function buildGroupsHtml(groups, planes, heroCfg) {
if (!planes) return groups.map((g) => renderCap(g, heroCfg)).join("\n ");
const order = Object.keys(planes);
const grouped = {};
order.forEach((p) => (grouped[p] = []));
const free = [];
for (const g of groups) {
const pid = g.plane;
if (pid && pid in grouped) grouped[pid].push(g);
else free.push(g);
}
const parts = [];
for (const pid of order)
parts.push(
`<div id="plane-${pid}" class="plane plane-${pid}">\n ${grouped[pid].map((g) => renderCap(g, heroCfg)).join("\n ")}\n </div>`,
);
for (const g of free) parts.push(renderCap(g, heroCfg));
return parts.join("\n ");
}
function buildPlanesCss(planes) {
if (!planes) return "";
return Object.entries(planes)
.map(([pid, p]) => {
// Accept BOTH plane shapes: { body: { css: "..." } } and the bare-string
// { body: "..." } (matches how groups write css). Reading only p.css silently
// dropped the string form → empty CSS → planes collapsed to (0,0).
let css = (typeof p === "string" ? p : (p || {}).css || "").trim();
if (!css) return null;
if (!css.endsWith(";")) css += ";";
return ` .plane-${pid} { ${css} }`;
})
.filter(Boolean)
.join("\n");
}
function buildPerGroupCss(groups, tokens) {
return groups
.map((g) => {
const parts = [];
if (g.scale != null) parts.push(`--s: ${g.scale};`);
let css = (g.css || "").trim();
if (css) parts.push(css.endsWith(";") ? css : css + ";");
const isHero = tokens && g.hero === true;
if (isHero) {
// DNA hero treatment (case / tracking / extra css) — authored css still wins (it comes first? no: later rules win; DNA goes first so the author can override)
const dnaHero = [];
if (tokens.heroCase && tokens.heroCase !== "none")
dnaHero.push(`text-transform: ${tokens.heroCase};`);
if (tokens.heroTracking && tokens.heroTracking !== "0")
dnaHero.push(`letter-spacing: ${tokens.heroTracking};`);
if (tokens.heroCss)
dnaHero.push(tokens.heroCss.endsWith(";") ? tokens.heroCss : tokens.heroCss + ";");
if (tokens.hero && tokens.hero.heroColor) dnaHero.push(`color: ${tokens.hero.heroColor};`);
parts.unshift(...dnaHero);
}
const rules = [];
if (parts.length) rules.push(` #${g.id} { ${parts.join(" ")} }`);
// fx duplicates mirror the hero's geometry exactly (same slot/absolute CSS) so a
// flex/slot plane can't displace them; glow blur comes from .hero-glow (em-based),
// echo colors from their own rules (they must NOT inherit a transparent text fill).
if (isHero && tokens.hero) {
// word-level hero treatment (e.g. chrome's gradient clip — background-clip:text
// can't clip through composited children, so it must live ON the .w span)
if (tokens.hero.wordCss) rules.push(` #${g.id} .w { ${tokens.hero.wordCss} }`);
if (tokens.hero.glow > 0 && parts.length)
rules.push(` #${g.id}-glow { ${parts.join(" ")} }`);
(tokens.hero.echoes || []).forEach((ec, k) => {
const extra =
`color: ${ec.color}; -webkit-text-fill-color: ${ec.color}; background: none;` +
(ec.blur ? ` filter: blur(${ec.blur}px);` : "");
rules.push(` #${g.id}-echo-${k} { ${parts.join(" ")} ${extra} }`);
});
}
return rules.length ? rules.join("\n") : null;
})
.filter(Boolean)
.join("\n");
}
function buildGroupsJson(groups) {
return JSON.stringify(
groups.map((g) => ({
id: g.id,
in: g.in,
out: g.out,
tone: g.tone ?? "soft",
...(g.hero === true ? { hero: true } : {}),
...(g.minor === true ? { minor: true } : {}),
...(g.plane ? { plane: g.plane } : {}),
words: g.words.map((w) => ({ text: w.text, start: w.start, end: w.end })),
})),
null,
10,
);
}
function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: make-composition.cjs <project-dir>");
process.exit(1);
}
const planPath = path.join(project, "plan.json");
if (!fs.existsSync(planPath)) {
console.error(`[compile] missing ${planPath}`);
process.exit(1);
}
const plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
if (plan.mode === "custom") {
console.error("[compile] mode=custom — skip this script and hand-write index.html.");
process.exit(1);
}
if (plan.mode === "standard") {
console.error(
"[compile] mode=standard — this plan.json is DERIVED by make-standard.cjs; compile Standard projects with make-standard.cjs (from standard.json), not this script.",
);
process.exit(1);
}
// ── DNA resolution: plan.dna (canonical) or a legacy template name ──────────
const dnaName = plan.dna || dnaLib.LEGACY[plan.template] || null;
let dna = null,
tokens = null;
if (dnaName) {
try {
dna = dnaLib.load(dnaName);
} catch (e) {
console.error(`[compile] ${e.message}`);
process.exit(1);
}
}
const heroGroups = (plan.groups || []).filter((x) => x.hero === true);
if (dna) {
tokens = dnaLib.resolveTokens(dna, project, { heroGroups });
console.log(
`[compile] DNA "${dna.name}" (${tokens.register}) — accent ${tokens.accent}` +
`${tokens.blurPx ? ` · depth-match blur ${tokens.blurPx}px` : ""}` +
`${tokens.heroes.length ? ` · hero amp ${tokens.heroes.map((h) => h.amp).join("/")} (RMS-coupled)` : ""}`,
);
}
let src = dna
? fs.readFileSync(path.join(TEMPLATES, "engine.html"), "utf8")
: fs.readFileSync(findTemplate(plan.template), "utf8");
// Bug-1: the canvas/background length = SOURCE clip length, NOT the last-caption time.
// If plan.duration < source, the bg video ends before the matte → the tail shows only
// the foreground subject on black. Caption groups keep their own in/out (they may end
// earlier); only the composition duration follows the source.
const srcDur = sourceDurationSec(project);
const renderDur = srcDur && srcDur > 0 ? srcDur : plan.duration;
if (srcDur && Math.abs(srcDur - (plan.duration || 0)) > 0.2)
console.log(
`[compile] canvas duration → source length ${renderDur.toFixed(2)}s (plan.duration=${plan.duration}); captions keep their own times.`,
);
const plane = plan.plane || {},
header = plan.header || {},
crown = plan.crown || {};
// LOCKED DNA — the plan can NOT override colour / blend / shadow / filter. Picking a
// DNA commits to its visual identity; only layout + per-group typography stay
// agent-authored. If a scene's luminance fights the look, pick a DIFFERENT DNA
// (bright scene → "ink") — never recolour this one. (plan.cap_color / blend_mode /
// text_shadow / text_filter are intentionally ignored.)
const capColor = tokens ? tokens.capColor : "#fff5df";
const g = (o, k, d) => (o && o[k] != null ? o[k] : d);
const heroCfg =
tokens && tokens.heroes && tokens.heroes.length
? {
perLetter: tokens.heroes[0].perLetter,
glow: tokens.heroes[0].glow,
echoes: tokens.heroes[0].echoes || [],
}
: null;
const subs = {
DURATION: `${renderDur}`,
FPS: `${plan.fps ?? 24}`,
WIDTH: `${plan.width}`,
HEIGHT: `${plan.height}`,
FONT_SCALE: `${plan.font_scale ?? 1.0}`,
PLANE_TOP: `${g(plane, "top", 0)}`,
PLANE_LEFT: `${g(plane, "left", "")}`,
PLANE_RIGHT: `${g(plane, "right", "")}`,
PLANE_WIDTH: `${g(plane, "width", 0)}`,
PLANE_HEIGHT: `${g(plane, "height", 0)}`,
ROTATE_Y: `${g(plane, "rotateY", 0)}`,
ROTATE_X: `${g(plane, "rotateX", 0)}`,
HEADER_TOP: `${g(header, "top", 0)}`,
HEADER_HEIGHT: `${g(header, "height", 0)}`,
CROWN_TOP: `${g(crown, "top", plan.crown_top ?? g(plan.crown_position || {}, "top", 440))}`,
CROWN_LEFT: `${g(crown, "left", 0)}`,
CROWN_RIGHT: `${g(crown, "right", 0)}`,
CROWN_ALIGN: `${g(crown, "align", "center")}`,
CROWN_SCALE: `${g(crown, "scale", 1.0)}`,
BLEND_MODE: tokens ? tokens.blend : "screen",
CAP_COLOR: capColor,
TEXT_SHADOW: tokens ? tokens.textShadow : defaultTextShadow(capColor),
TEXT_FILTER: tokens ? tokens.filterBg : defaultTextFilter(capColor),
// engine-only placeholders (legacy templates simply don't contain them)
FONT_FAMILY: tokens ? tokens.fontFamily : "Inter",
ACCENT: tokens ? tokens.accent : "#e3c06a",
TEXT_FILTER_BG: tokens ? tokens.filterBg : defaultTextFilter(capColor),
TEXT_FILTER_FG: tokens ? tokens.filterFg : defaultTextFilter(capColor),
DNA_CSS: tokens && tokens.dnaCss ? tokens.dnaCss : "",
MOTION_JSON: JSON.stringify(
tokens
? tokens.motion
: {
soft: { y: 8, dur: 0.45, ease: "power2.out" },
present: { y: 6, scale: 1.04, dur: 0.22, ease: "power3.out" },
},
),
HEROES_JSON: tokens && tokens.heroes ? JSON.stringify(tokens.heroes) : "[]",
HERO_JSON: tokens && tokens.hero ? JSON.stringify(tokens.hero) : "null",
GROUPS_HTML: buildGroupsHtml(plan.groups, plan.planes, heroCfg),
PLANES_CSS: buildPlanesCss(plan.planes),
CUSTOM_CSS: buildPerGroupCss(plan.groups, tokens),
GROUPS_JSON: buildGroupsJson(plan.groups),
};
const cg = plan.crown_group;
if (cg) {
const layerAttr = cg.layer ? ` data-layer="${cg.layer}"` : "";
const spans = cg.words
.map((w, i) => `<span class="w" data-i="${i}">${escBr(w.text)}</span>`)
.join("\n ");
subs.CROWN_HTML = `<div id="${cg.id}" class="cap cap-crown"${layerAttr}>\n ${spans}\n </div>`;
subs.CROWN_JSON = JSON.stringify(
{
id: cg.id,
in: cg.in,
out: cg.out,
tone: cg.tone ?? "present",
words: cg.words.map((w) => ({ text: w.text, start: w.start, end: w.end })),
},
null,
10,
);
} else {
subs.CROWN_HTML = "";
subs.CROWN_JSON = "null";
}
for (const [k, v] of Object.entries(subs)) src = src.split(`{{${k}}}`).join(v);
fs.writeFileSync(path.join(project, "index.html"), src);
console.log(
`[compile] ${dna ? `dna=${dna.name} (engine)` : `template=${plan.template}`}${path.join(project, "index.html")}`,
);
// per-group FG → index_fg.html (same strategy as the Python version)
const fgGroups = plan.groups.filter((x) => x.layer === "fg");
if (fgGroups.length || (plan.crown_group || {}).layer === "fg") {
const fgCss = `
<style>
html.fg-only body { background: #000 !important; }
html.fg-only #fg-cover { position: absolute; inset: 0; background: #000; z-index: 1; pointer-events: none; }
/* visibility (not display:none): GSAP's transform parser needs a laid-out box at
construction time — display:none here crashed timeline build in headless preview */
html.fg-only .cap:not([data-layer="fg"]) { visibility: hidden !important; }
</style>
`;
let fg = src.replace("<html ", '<html class="fg-only" ');
fg = fg.replace("</head>", fgCss + " </head>");
fg = fg.replace('<div id="stage"', '<div id="fg-cover"></div>\n <div id="stage"');
fs.writeFileSync(path.join(project, "index_fg.html"), fg);
console.log(
`[compile] ${fgGroups.length} fg group(s) → ${path.join(project, "index_fg.html")}`,
);
}
}
main();
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env node
/*
* matte.cjs — subject matting via hyperframes' built-in `remove-background`
* (rembg-equivalent u2net_human_seg, Apache-2.0, 320×320 input, ~9 fps on
* CoreML). Replaces the previous bundled PP-MattingV2 ONNX (34 MB asset +
* an onnxruntime inference loop in this script): one engine, zero bundled
* weights — the model auto-downloads once (~168 MB) to ~/.cache/hyperframes/.
*
* Semantics note (validated 2026-06-12 on 6 scenes + a cold-start E2E): a
* HUMAN segmenter by intent, not surgically. Thin offset furniture (mic boom
* arms) is usually excluded — captions render over it, behind the person —
* but large salient objects near the subject (a telescope rig) can still
* leak into the matte and occlude captions. Objects HELD by the subject
* (products, phones) may drop out intermittently, letting captions pass in
* front. Never assume: sample frames_fg/ before placing the hero.
*
* Pipeline:
* source.mp4 → hyperframes remove-background → ProRes 4444 .mov (lossless
* alpha; temp, deleted) → ffmpeg fps=<matte.fps> → frames_fg/f_%04d.png.
* frames_bg/ is extracted at the same rate (preview tooling reads it).
*
* node matte.cjs <project-dir>
* Reads: <project>/source.mp4 (any video in the project dir is adopted)
* Writes: <project>/frames_fg/f_%04d.png (RGBA, subject opaque),
* <project>/frames_bg/f_%04d.png, <project>/matte.fps
* Env: HYPERFRAMES_ROOT — hyperframes checkout (default ~/Downloads/hyperframes)
*/
const path = require("path");
const fs = require("fs");
const os = require("os");
const cp = require("child_process");
function hfCli() {
const roots = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "..", "..", ".."), // skills/embedded-captions/scripts → repo root if in-repo
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
for (const root of roots) {
const cli = path.join(root, "packages", "cli", "dist", "cli.js");
if (fs.existsSync(cli)) return cli;
}
console.error("[matte] cannot find hyperframes cli — set HYPERFRAMES_ROOT to a built checkout");
process.exit(3);
}
function ensureSource(project) {
const src = path.join(project, "source.mp4");
if (!fs.existsSync(src)) {
const found = fs
.readdirSync(project)
.filter((f) => /\.(mp4|mov|webm|mkv)$/i.test(f) && !f.startsWith("_"))
.map((f) => path.join(project, f))[0];
if (!found) return src;
try {
fs.symlinkSync(path.basename(found), src);
} catch {
fs.copyFileSync(found, src);
}
console.log(`[matte] resolved source.mp4 -> ${path.basename(found)}`);
}
return src;
}
function rateOf(expr) {
const [n, d] = String(expr || "").split("/");
const f = parseFloat(n) / parseFloat(d || "1");
return Number.isFinite(f) && f > 0 ? f : 0;
}
// Prefer avg_frame_rate (frames/duration — the truth) over r_frame_rate (the
// container's nominal tick rate, which lies on VFR sources: a 24fps screen
// recording can carry r_frame_rate=60 and would 2.5x-desync the matte).
function probeRates(src) {
try {
const out = cp
.execFileSync("ffprobe", [
"-v",
"0",
"-select_streams",
"v:0",
"-show_entries",
"stream=r_frame_rate,avg_frame_rate",
"-of",
"default=nk=1:nw=1",
src,
])
.toString()
.trim()
.split("\n");
return { r: rateOf(out[0]), avg: rateOf(out[1]) };
} catch {
return { r: 0, avg: 0 };
}
}
function probeFps(src) {
const { r, avg } = probeRates(src);
const f = avg || r;
return f > 0 ? Math.max(1, Math.round(f)) : 24;
}
// VFR when nominal and actual disagree by >5% — the remove-background engine
// mishandles VFR timestamps (observed: 2251 fg frames vs 902 bg on one clip),
// so VFR sources get normalized to CFR before matting.
function isVfr(src) {
const { r, avg } = probeRates(src);
return r > 0 && avg > 0 && Math.abs(r - avg) / avg > 0.05;
}
function extractFrames(src, dst, fps, extra = []) {
fs.mkdirSync(dst, { recursive: true });
if (fs.readdirSync(dst).some((f) => f.endsWith(".png"))) return false;
cp.execFileSync(
"ffmpeg",
["-y", "-i", src, "-vf", `fps=${fps}`, ...extra, path.join(dst, "f_%04d.png")],
{ stdio: "ignore" },
);
return true;
}
function countPngs(dir) {
try {
return fs.readdirSync(dir).filter((f) => f.endsWith(".png")).length;
} catch {
return 0;
}
}
async function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: matte.cjs <project-dir>");
process.exit(1);
}
const src = ensureSource(project);
if (!fs.existsSync(src)) {
console.error(`[matte] no source video found in ${project}`);
process.exit(2);
}
const fpsFile = path.join(project, "matte.fps");
// read-with-catch (no exists-then-read TOCTOU): a missing/corrupt fps file
// simply falls through to probing the source.
let fps = 0;
try {
fps = parseInt(fs.readFileSync(fpsFile, "utf8").replace(/\D/g, ""), 10) || 0;
} catch {
/* no cached fps — probe below */
}
if (!fps) fps = probeFps(src);
fs.writeFileSync(fpsFile, String(fps));
const framesBg = path.join(project, "frames_bg");
const framesFg = path.join(project, "frames_fg");
if (extractFrames(src, framesBg, fps))
console.log(`[matte] source fps=${fps} → frames_bg extracted`);
const want = countPngs(framesBg);
if (want > 0 && countPngs(framesFg) >= want) {
console.log(`[matte] frames_fg already complete (${want} frames) — nothing to do`);
return;
}
// 1) subject matte via hyperframes (ProRes 4444 keeps the alpha lossless).
// VFR sources are normalized to CFR first — remove-background trusts
// timestamps and emits a desynced frame count on VFR input (the ghost
// double-subject bug: the pasted matte runs at the wrong speed).
let matteSrc = src;
if (isVfr(src)) {
const cfr = path.join(project, "_src_cfr.mp4");
if (!fs.existsSync(cfr)) {
console.log(
`[matte] VFR source detected (nominal != actual fps) → normalizing to ${fps}fps CFR for matting`,
);
cp.execFileSync(
"ffmpeg",
[
"-y",
"-i",
src,
"-fps_mode",
"cfr",
"-r",
String(fps),
"-c:v",
"libx264",
"-crf",
"16",
"-preset",
"fast",
"-an",
cfr,
],
{ stdio: "ignore" },
);
}
matteSrc = cfr;
}
const mov = path.join(project, "_matte_tmp.mov");
const t0 = Date.now();
const cached = fs.existsSync(
path.join(
os.homedir(),
".cache",
"hyperframes",
"background-removal",
"models",
"u2net_human_seg.onnx",
),
);
console.log(
`[matte] hyperframes remove-background (u2net_human_seg${cached ? "" : "; first run downloads ~168 MB"})… model load takes ~1-2 min with no output — not hung`,
);
const r = cp.spawnSync("node", [hfCli(), "remove-background", matteSrc, "-o", mov], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
if (r.status !== 0 || !fs.existsSync(mov)) {
console.error("[matte] remove-background FAILED:");
console.error((r.stderr || r.stdout || "").split("\n").slice(-8).join("\n"));
process.exit(4);
}
// 2) burst to RGBA pngs at the project rate
fs.mkdirSync(framesFg, { recursive: true });
cp.execFileSync(
"ffmpeg",
["-y", "-i", mov, "-vf", `fps=${fps}`, "-pix_fmt", "rgba", path.join(framesFg, "f_%04d.png")],
{ stdio: "ignore" },
);
fs.rmSync(mov, { force: true });
// 3) count parity with frames_bg. Composite overlays fg by INDEX at matte.fps,
// so any fg/bg count mismatch is a time desync (subject ghosting). Handle
// BOTH directions: pad when short, linearly remap when long — and shout
// when the mismatch is big enough to mean broken timestamps upstream.
let got = countPngs(framesFg);
const tol = Math.max(2, Math.round(want * 0.01));
if (Math.abs(got - want) > tol) {
console.error(
`[matte] WARN frame-count desync: fg=${got} vs bg=${want} (tolerance ${tol}). ` +
`Reconciling by remap — if the source is VFR this run predates the CFR fix; ` +
`delete frames_fg/ frames_bg/ matte.fps and re-run matte.cjs.`,
);
}
if (got > want && want > 0) {
// keep want frames sampled evenly across got (re-times fg onto the bg timeline)
const keep = [];
for (let j = 1; j <= want; j++)
keep.push(Math.min(got, Math.max(1, Math.round((j - 0.5) * (got / want)))));
const tmp = path.join(project, "_fg_remap");
fs.mkdirSync(tmp, { recursive: true });
keep.forEach((srcIdx, k) => {
fs.copyFileSync(
path.join(framesFg, `f_${String(srcIdx).padStart(4, "0")}.png`),
path.join(tmp, `f_${String(k + 1).padStart(4, "0")}.png`),
);
});
fs.rmSync(framesFg, { recursive: true, force: true });
fs.renameSync(tmp, framesFg);
got = countPngs(framesFg);
}
while (got < want && got > 0) {
fs.copyFileSync(
path.join(framesFg, `f_${String(got).padStart(4, "0")}.png`),
path.join(framesFg, `f_${String(got + 1).padStart(4, "0")}.png`),
);
got++;
}
console.log(
`[matte] done in ${((Date.now() - t0) / 1000).toFixed(1)}s → fg=${got} bg=${want} @ ${fps}fps${got === want ? " (parity ok)" : ""}`,
);
}
main().catch((e) => {
console.error("[matte]", e.message);
process.exit(1);
});
@@ -0,0 +1,293 @@
#!/usr/bin/env node
/**
* measure-layout.cjs — pixel-perfect bbox measurement using headless Chromium.
*
* Loads the compiled index.html, seeks the GSAP timeline to specified sample
* times, queries every .cap container + every .w word span via
* getBoundingClientRect(), writes results to _layout.json.
*
* check-occlusion.cjs then reads _layout.json + frames_fg/*.png and computes
* per-word occlusion against the actual subject silhouette (matte alpha via
* sharp) — pixel accurate, no char_ratio guessing.
*
* Usage:
* node measure-layout.cjs <project-dir> [times...]
* If no times given, samples groups['in', 'out'] midpoints.
*/
const path = require("path");
const fs = require("fs");
const os = require("os");
// Locate hyperframes' bundled puppeteer. render-and-composite.sh exports
// HYPERFRAMES_ROOT; standalone we also try the in-repo path + ~/Downloads, and
// accept ANY puppeteer@* the bun store holds (not a pinned version).
const HF_ROOTS = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "../../.."), // skills/embedded-captions/scripts → repo root if in-repo
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
let puppeteer = null;
for (const root of HF_ROOTS) {
const cands = [path.join(root, "node_modules", "puppeteer")];
const bunDir = path.join(root, "node_modules", ".bun");
try {
if (fs.existsSync(bunDir)) {
for (const d of fs.readdirSync(bunDir)) {
if (d.startsWith("puppeteer@"))
cands.push(path.join(bunDir, d, "node_modules", "puppeteer"));
}
}
} catch {
/* ignore */
}
for (const p of cands) {
try {
if (fs.existsSync(p)) {
puppeteer = require(p);
break;
}
} catch {
/* try next */
}
}
if (puppeteer) break;
}
if (!puppeteer) {
console.error(
"[measure] could not locate puppeteer — set HYPERFRAMES_ROOT to a built hyperframes checkout",
);
process.exit(3);
}
// Resolve hyperframes' bundled GSAP. The templates load GSAP from a CDN
// (cdn.jsdelivr.net), but in headless Chromium that request can be slow or
// blocked — the page's inline `gsap.timeline()` then throws "gsap is not
// defined" and the occlusion gate hard-fails. We inject this local copy on
// every new document (before any page script runs) so window.gsap always
// exists, and abort the CDN request so the parser never stalls on it. The
// render path is unaffected — this is measurement-only.
let gsapSource = null;
for (const root of HF_ROOTS) {
const cands = [path.join(root, "node_modules", "gsap", "dist", "gsap.min.js")];
const bunDir = path.join(root, "node_modules", ".bun");
try {
if (fs.existsSync(bunDir)) {
for (const d of fs.readdirSync(bunDir)) {
if (d.startsWith("gsap@"))
cands.push(path.join(bunDir, d, "node_modules", "gsap", "dist", "gsap.min.js"));
}
}
} catch {
/* ignore */
}
for (const p of cands) {
try {
if (fs.existsSync(p)) {
gsapSource = fs.readFileSync(p, "utf8");
break;
}
} catch {
/* try next */
}
}
if (gsapSource) break;
}
async function main() {
const projectDir = process.argv[2];
if (!projectDir) {
console.error("usage: measure-layout.cjs <project-dir> [t1 t2 ...]");
process.exit(1);
}
const indexPath = path.resolve(projectDir, "index.html");
if (!fs.existsSync(indexPath)) {
console.error(`[measure] missing ${indexPath} — run make-composition.cjs first`);
process.exit(2);
}
// Load plan to get groups + sample times
const planPath = path.join(projectDir, "plan.json");
let plan = null;
if (fs.existsSync(planPath)) plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
// Determine sample times: per group, sample multiple points across [in, out]
// (catches subject motion within block, multi-frame validation).
const explicitTimes = process.argv.slice(3).map(Number).filter(Number.isFinite);
let sampleTimes = explicitTimes;
if (sampleTimes.length === 0 && plan?.groups) {
const allTimes = new Set();
for (const g of plan.groups) {
const dur = g.out - g.in;
// 4 samples per group: 15%, 40%, 65%, 90% through window — covers entry/peak/exit
[0.15, 0.4, 0.65, 0.9].forEach((p) => allTimes.add(+(g.in + dur * p).toFixed(3)));
}
sampleTimes = [...allTimes].sort((a, b) => a - b);
}
// Match render-and-composite's Chrome detection
const exe =
process.platform === "darwin"
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
: "/usr/bin/google-chrome";
const W = plan?.width || 720;
const H = plan?.height || 1290;
const FPS = plan?.fps || 24;
const browser = await puppeteer.launch({
headless: "new",
executablePath: fs.existsSync(exe) ? exe : undefined,
args: [
"--disable-web-security",
"--allow-file-access-from-files",
`--window-size=${W},${H}`,
"--disable-dev-shm-usage",
],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 });
page.on("pageerror", (err) => console.error(`[browser-error] ${err.message}`));
// Inject local GSAP before any page script + abort the CDN <script> so the
// page never depends on network for GSAP (see resolver note above). Falls
// back to the page's own CDN load if no local copy was found.
if (gsapSource) {
await page.evaluateOnNewDocument(gsapSource);
await page.setRequestInterception(true);
page.on("request", (req) => {
const u = req.url();
if (req.resourceType() === "script" && /gsap/i.test(u) && /^https?:/i.test(u)) req.abort();
else req.continue();
});
}
await page.goto(`file://${indexPath}`, { waitUntil: "load", timeout: 15000 });
// GSAP is injected locally above; poll for the page's timeline registration.
const start = Date.now();
let ready = false;
while (Date.now() - start < 15000) {
const r = await page.evaluate(() => !!(window.__timelines && window.__timelines.main));
if (r) {
ready = true;
break;
}
await new Promise((res) => setTimeout(res, 200));
}
if (!ready) {
console.error("[measure] GSAP timeline never registered");
process.exit(4);
}
// Inject the skill's bundled @font-face set so headless Chromium measures the SAME
// glyph metrics the renderer will use. Without this, Inter/etc fall back to system
// fonts here while the real render uses the true (often wider) face → wrapped line
// counts differ → slot layout / occlusion verdicts are measured on the wrong text.
try {
const fontsCss = path.join(__dirname, "..", "modes", "standard", "fonts", "fonts.css");
if (fs.existsSync(fontsCss))
await page.addStyleTag({ content: fs.readFileSync(fontsCss, "utf8") });
} catch {
/* best-effort — fonts.css missing just reverts to old behavior */
}
// let webfonts settle so measured glyph metrics match the render
await page.evaluate(async () => {
try {
await document.fonts.ready;
} catch {}
});
const samples = [];
for (const t of sampleTimes) {
// Seek timeline
await page.evaluate((t) => {
const tl = window.__timelines.main;
tl.seek(t);
// Force layout flush
void document.body.offsetHeight;
}, t);
// Tiny settle for animations / fonts
await new Promise((r) => setTimeout(r, 30));
// Measure every .cap and its .w children
const measurements = await page.evaluate(() => {
const caps = [...document.querySelectorAll(".cap")];
const out = [];
for (const cap of caps) {
const cs = getComputedStyle(cap);
if (cs.opacity === "0" || cs.display === "none") continue;
const cb = cap.getBoundingClientRect();
if (cb.width === 0 || cb.height === 0) continue;
const id = cap.id || "";
const layer = cap.dataset.layer || "";
// Per-line via Range over all word spans
const ws = [...cap.querySelectorAll(".w")];
const words = [];
for (const w of ws) {
const wcs = getComputedStyle(w);
if (wcs.opacity === "0") continue; // not yet animated in
const wb = w.getBoundingClientRect();
if (wb.width === 0) continue;
words.push({
text: w.textContent,
x: +wb.x.toFixed(1),
y: +wb.y.toFixed(1),
w: +wb.width.toFixed(1),
h: +wb.height.toFixed(1),
opacity: +wcs.opacity,
});
}
// Group by line (same y ± 2px)
const lines = [];
for (const w of words) {
const line = lines.find((l) => Math.abs(l.y - w.y) < 3);
if (line) {
line.words.push(w);
line.x = Math.min(line.x, w.x);
line.w = Math.max(line.x + line.w, w.x + w.w) - line.x;
line.h = Math.max(line.h, w.h);
} else {
lines.push({ x: w.x, y: w.y, w: w.w, h: w.h, words: [w] });
}
}
out.push({
id,
layer,
cap_bbox: {
x: +cb.x.toFixed(1),
y: +cb.y.toFixed(1),
w: +cb.width.toFixed(1),
h: +cb.height.toFixed(1),
},
opacity: +cs.opacity,
lines,
words, // also keep flat list
});
}
return out;
});
const frame_idx = Math.max(1, Math.round(t * FPS));
samples.push({ t, frame_idx, caps: measurements });
}
const layout = { width: W, height: H, fps: FPS, samples };
const outPath = path.join(projectDir, "_layout.json");
fs.writeFileSync(outPath, JSON.stringify(layout, null, 2));
console.log(
`[measure] wrote ${outPath} (${sampleTimes.length} sample frames, ${samples.reduce((a, s) => a + s.caps.length, 0)} cap measurements)`,
);
} finally {
// Chromium occasionally hangs on shutdown. This script runs synchronously
// inside check-occlusion.cjs, which the render gate blocks on — a hung close
// would wedge the whole render. Cap the close, then force-exit below.
await Promise.race([browser.close().catch(() => {}), new Promise((r) => setTimeout(r, 8000))]);
}
}
// Force a hard exit so a lingering Chromium/libuv handle can't keep the process
// (and the render gate that spawned it) alive indefinitely.
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# prepare.sh — one-command pre-processing for a captions project.
#
# bash prepare.sh <project-dir>
#
# Runs the three deterministic pre-steps with maximum parallelism:
# matte.cjs (CPU-heavy ONNX) ∥ transcribe.cjs (whisper) → then safe-zones.cjs
# (matte and transcribe are independent; safe-zones needs the matte's frames_fg.)
# Replaces hand-running steps 2 / 3 / 3b — one call, nothing forgotten, ~the cost
# of the slower of the two instead of their sum.
#
# After this completes the project has: frames_bg/ frames_fg/ matte.fps
# transcript.json safe-zones.json — everything the authoring step needs.
set -euo pipefail
PROJECT="${1:?usage: prepare.sh <project-dir>}"
PROJECT="$(cd "$PROJECT" && pwd)"
SD="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "[prepare] matte ∥ transcribe ∥ envelope …"
MLOG="$PROJECT/_prepare_matte.log"; TLOG="$PROJECT/_prepare_transcribe.log"; ELOG="$PROJECT/_prepare_envelope.log"
node "$SD/matte.cjs" "$PROJECT" > "$MLOG" 2>&1 & MPID=$!
node "$SD/transcribe.cjs" "$PROJECT" > "$TLOG" 2>&1 & TPID=$!
node "$SD/audio-envelope.cjs" "$PROJECT" > "$ELOG" 2>&1 & EPID=$!
MRC=0; TRC=0
wait "$MPID" || MRC=$?
wait "$TPID" || TRC=$?
wait "$EPID" || echo "[prepare] (envelope skipped — hero amplitude falls back to neutral)" >&2
# surface both logs (they contain the guards' warnings — near-silent audio, tail trim)
sed 's/^/ [matte] /' "$MLOG" | tail -6
sed 's/^/ [transcribe] /' "$TLOG" | tail -12
if (( MRC != 0 )); then echo "[prepare] matte FAILED (rc=$MRC) — see $MLOG" >&2; exit "$MRC"; fi
if (( TRC != 0 )); then echo "[prepare] transcribe FAILED (rc=$TRC) — see $TLOG" >&2; exit "$TRC"; fi
echo "[prepare] safe-zones …"
node "$SD/safe-zones.cjs" "$PROJECT"
echo "[prepare] ✅ ready: frames_fg/ transcript.json safe-zones.json — author next (see SKILL.md step 4)"
@@ -0,0 +1,268 @@
#!/usr/bin/env node
/*
* preview-frames.cjs — APPROXIMATE final frames in seconds, without rendering.
*
* node preview-frames.cjs <project-dir> [t1 t2 ...]
*
* For each sample time t: screenshot the caption layer(s) in headless Chromium
* (index.html seeked to t; rail.html too if present), then composite in sharp:
* frames_bg[t] → index captions (embed layer) → matte frames_fg[t] (subject
* occludes the embed) → rail captions (in front, like the final alpha-overlay)
* = a faithful preview of the composite for THAT moment, ~2s per frame instead of
* a multi-minute render. Writes <project>/preview/t<t>.png + a contact sheet
* preview/sheet.png.
*
* Use it BEFORE rendering: eyeball placement, occlusion, washout, text-on-text —
* the failure classes the geometric gates can't judge. The QA checklist lives in
* SKILL.md § Visual QA. (Video elements show poster/first-frame in the screenshot;
* the REAL a-roll pixels come from frames_bg, so previews stay accurate.)
*
* Sample-time default: climax/groups midpoints from standard.json or plan.json,
* else 25/50/75% of the clip.
*/
const path = require("path");
const fs = require("fs");
const os = require("os");
const HF_ROOTS = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "../../.."),
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
function findInBun(root, pkg, sub) {
const cands = [path.join(root, "node_modules", pkg)];
const bunDir = path.join(root, "node_modules", ".bun");
try {
if (fs.existsSync(bunDir))
for (const d of fs.readdirSync(bunDir))
if (d.startsWith(pkg + "@")) cands.push(path.join(bunDir, d, "node_modules", pkg));
} catch {}
for (const c of cands) {
const p = sub ? path.join(c, sub) : c;
if (fs.existsSync(p)) return p;
}
return null;
}
let puppeteer = null,
sharp = null,
gsapSource = null;
for (const r of HF_ROOTS) {
if (!puppeteer) {
const p = findInBun(r, "puppeteer");
if (p)
try {
puppeteer = require(p);
} catch {}
}
if (!sharp) {
const p = findInBun(r, "sharp");
if (p)
try {
sharp = require(p);
} catch {}
}
if (!gsapSource) {
const g = findInBun(r, "gsap", path.join("dist", "gsap.min.js"));
if (g) gsapSource = fs.readFileSync(g, "utf8");
}
}
if (!puppeteer || !sharp) {
console.error("[preview] need puppeteer+sharp — set HYPERFRAMES_ROOT");
process.exit(0);
}
async function shotAt(browser, file, W, H, t) {
const page = await browser.newPage();
try {
await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 });
// Serve the page's own CDN <script src=gsap> request from the local bundle
// (offline-safe) instead of injecting gsap at document-start: a document-start
// evaluation runs while document.head is still null, and gsap's init then
// throws "appendChild of null" — which killed previews for theme projects.
await page.setRequestInterception(true);
page.on("request", (req) => {
const u = req.url();
if (req.resourceType() === "script" && /gsap/i.test(u) && /^https?:/i.test(u)) {
if (gsapSource)
req.respond({ status: 200, contentType: "application/javascript", body: gsapSource });
else req.continue(); // no local bundle — let the CDN load (online machines)
} else if (req.resourceType() === "media")
req.abort(); // a-roll pixels come from frames_bg
else req.continue();
});
await page.goto(`file://${file}`, { waitUntil: "load", timeout: 15000 });
const t0 = Date.now();
let tlReady = false;
while (Date.now() - t0 < 15000) {
tlReady = await page.evaluate(() => !!(window.__timelines && window.__timelines.main));
if (tlReady) break;
await new Promise((r) => setTimeout(r, 120));
}
if (!tlReady) throw new Error(`timeline never registered in ${path.basename(file)}`);
// bundled @font-face → previews show the REAL faces (same set the renderer embeds)
try {
const fontsCss = path.join(__dirname, "..", "modes", "standard", "fonts", "fonts.css");
if (fs.existsSync(fontsCss))
await page.addStyleTag({ content: fs.readFileSync(fontsCss, "utf8") });
} catch {}
await page.evaluate(async () => {
try {
await document.fonts.ready;
} catch {}
});
await page.evaluate((t) => {
const v = document.getElementById("a-roll");
if (v) v.style.display = "none"; // transparent hole for the bg frame
document.body.style.background = "transparent";
document.documentElement.style.background = "transparent";
window.__timelines.main.seek(t);
void document.body.offsetHeight;
}, t);
await new Promise((r) => setTimeout(r, 60));
return await page.screenshot({ omitBackground: true }); // RGBA png of caption layer only
} finally {
await page.close().catch(() => {});
}
}
async function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: preview-frames.cjs <project-dir> [times...]");
process.exit(1);
}
const idx = path.join(project, "index.html");
if (!fs.existsSync(idx)) {
console.error("[preview] no index.html — compile first");
process.exit(1);
}
const railP = path.join(project, "rail.html");
const hasRail = fs.existsSync(railP);
const fgP = path.join(project, "index_fg.html");
const hasFg = fs.existsSync(fgP); // hybrid: fg caps render ABOVE the matte (like the real composite)
let fps = 24;
try {
const f = parseFloat(
String(fs.readFileSync(path.join(project, "matte.fps"), "utf8")).replace(/[^\d.]/g, ""),
);
if (f > 0) fps = f;
} catch {}
// sample times: explicit > climax window + line midpoints > thirds
let globalFg = false;
try {
globalFg =
JSON.parse(fs.readFileSync(path.join(project, "plan.json"), "utf8")).caption_layer === "fg";
} catch {}
let times = process.argv.slice(3).map(Number).filter(Number.isFinite);
if (!times.length) {
try {
const plan = JSON.parse(fs.readFileSync(path.join(project, "plan.json"), "utf8"));
// heroes get 2 samples each (entrance + hold); every OTHER group gets at least
// a shot at one midpoint — the old 2-per-group list truncated at 12 and silently
// dropped whole narration blocks from the sheet (cold-start agents missed bugs there)
const gs = plan.groups || [];
const heroes = gs.filter((g) => g.hero === true),
rest = gs.filter((g) => !g.hero);
for (const g of heroes) {
const span = g.out - g.in;
times.push(+(g.in + span * 0.25).toFixed(2), +(g.in + span * 0.7).toFixed(2));
}
const mids = rest.map((g) => +((g.in + g.out) / 2).toFixed(2));
const budget = Math.max(2, 16 - times.length);
const step = Math.max(1, Math.ceil(mids.length / budget));
for (let i = 0; i < mids.length; i += step) times.push(mids[i]);
} catch {}
}
if (!times.length) {
const n = fs.existsSync(path.join(project, "frames_bg"))
? fs.readdirSync(path.join(project, "frames_bg")).length
: 0;
const dur = n / fps || 10;
times = [dur * 0.25, dur * 0.5, dur * 0.75].map((t) => +t.toFixed(2));
}
times = [...new Set(times)].slice(0, 16).sort((a, b) => a - b);
const meta = await sharp(
path.join(
project,
"frames_bg",
fs
.readdirSync(path.join(project, "frames_bg"))
.filter((f) => f.endsWith(".png"))
.sort()[0],
),
).metadata();
const W = meta.width,
H = meta.height;
const outDir = path.join(project, "preview");
fs.mkdirSync(outDir, { recursive: true });
const exe =
process.platform === "darwin"
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
: "/usr/bin/google-chrome";
const browser = await puppeteer.launch({
headless: "new",
executablePath: fs.existsSync(exe) ? exe : undefined,
args: ["--disable-web-security", "--allow-file-access-from-files", "--disable-dev-shm-usage"],
});
const outs = [];
try {
for (const t of times) {
const fi = Math.max(1, Math.round(t * fps));
const bg = path.join(project, "frames_bg", `f_${String(fi).padStart(4, "0")}.png`);
const fg = path.join(project, "frames_fg", `f_${String(fi).padStart(4, "0")}.png`);
if (!fs.existsSync(bg)) {
console.error(`[preview] no bg frame for t=${t}`);
continue;
}
const layers = [{ input: await shotAt(browser, idx, W, H, t) }]; // embed captions
// global caption_layer:"fg" → captions sit ON TOP of the subject; the matte
// must NOT be stacked over them (the render skips the overlay too).
if (!globalFg && fs.existsSync(fg)) layers.push({ input: fg }); // subject occludes embed
if (hasFg) layers.push({ input: await shotAt(browser, fgP, W, H, t), blend: "screen" }); // hybrid fg caps in front (screen, like the real ffmpeg pass)
if (hasRail) layers.push({ input: await shotAt(browser, railP, W, H, t) }); // rail in front
const out = path.join(outDir, `t${String(t).replace(".", "_")}.png`);
await sharp(bg).composite(layers).png().toFile(out);
outs.push({ t, out });
console.log(`[preview] t=${t}s → ${out}`);
}
// contact sheet
if (outs.length) {
const TW = 480,
TH = Math.round((TW * H) / W);
const comps = [];
for (let i = 0; i < outs.length; i++)
comps.push({
input: await sharp(outs[i].out).resize(TW, TH).toBuffer(),
left: (i % 4) * TW,
top: Math.floor(i / 4) * TH,
});
const rows = Math.ceil(outs.length / 4);
await sharp({
create: {
width: 4 * TW,
height: rows * TH,
channels: 3,
background: { r: 16, g: 16, b: 16 },
},
})
.composite(comps)
.png()
.toFile(path.join(outDir, "sheet.png"));
console.log(
`[preview] contact sheet → ${path.join(outDir, "sheet.png")} (${outs.length} frames @ ${times.join(", ")}s)`,
);
}
} finally {
await Promise.race([browser.close().catch(() => {}), new Promise((r) => setTimeout(r, 8000))]);
}
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error("[preview]", e.message);
process.exit(1);
});
+469
View File
@@ -0,0 +1,469 @@
#!/usr/bin/env bash
# End-to-end: hyperframes render + ffmpeg overlay matte → final.mp4
#
# Usage:
# bash render-and-composite.sh <project-dir> [hyperframes-repo-path]
#
# Env:
# HYPERFRAMES_ROOT override the hyperframes checkout location
set -euo pipefail
PROJECT="${1:?usage: render-and-composite.sh <project-dir> [hyperframes-repo]}"
PROJECT="$(cd "$PROJECT" && pwd)"
# Resolve the hyperframes checkout. Candidate order:
# 1. arg 2 2. $HYPERFRAMES_ROOT 3. repo root if this skill ships INSIDE the
# hyperframes repo (skills/embedded-captions/scripts → ../../..) 4. ~/Downloads/hyperframes
SKILL_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HF=""
for cand in "${2:-}" "${HYPERFRAMES_ROOT:-}" "$(cd "$SKILL_SCRIPT_DIR/../../.." 2>/dev/null && pwd)" "$HOME/Downloads/hyperframes"; do
if [[ -n "$cand" && -f "$cand/packages/cli/dist/cli.js" ]]; then HF="$cand"; break; fi
done
if [[ -z "$HF" ]]; then
echo "[render] hyperframes CLI not found. Set HYPERFRAMES_ROOT to your hyperframes" >&2
echo " checkout (needs packages/cli/dist/cli.js — 'bun install && bun run build')." >&2
exit 1
fi
export HYPERFRAMES_ROOT="$HF" # so the occlusion gate's measure-layout.cjs finds puppeteer too
HF_CLI="$HF/packages/cli/dist/cli.js"
if [[ ! -d "$PROJECT/frames_fg" ]]; then
echo "[render] missing matte frames at $PROJECT/frames_fg — run matte.cjs first" >&2
exit 1
fi
# Which compiler owns this project? Cinematic = make-composition.cjs (plan.json).
# (Standard mode retired 2026-06-12 — rail-surface needs are served by theme
# DNAs like "anchor"; legacy standard.json projects re-render from their
# existing index.html or via the archived compiler in embedded-captions-archive.)
compiler_for() {
echo "make-composition.cjs"
}
if [[ ! -f "$PROJECT/index.html" ]]; then
if [[ -f "$PROJECT/plan.json" ]]; then
C="$(compiler_for)"
echo "[render] no index.html — auto-compiling via $C"
node "$(dirname "$0")/$C" "$PROJECT"
elif [[ -f "$PROJECT/cinematic.json" ]]; then
# cinematic.json-only project: the full compiler lowers blocks → plan.json → html
echo "[render] no index.html — auto-compiling via make-cinematic.cjs"
node "$(dirname "$0")/make-cinematic.cjs" "$PROJECT"
else
echo "[render] missing $PROJECT/index.html and standard.json/plan.json/cinematic.json — author + compile first" >&2
exit 1
fi
elif [[ -f "$PROJECT/cinematic.json" && "$PROJECT/cinematic.json" -nt "$PROJECT/index.html" ]]; then
echo "[render] cinematic.json newer than index.html — recompiling"
node "$(dirname "$0")/make-cinematic.cjs" "$PROJECT"
elif [[ -f "$PROJECT/plan.json" && "$PROJECT/plan.json" -nt "$PROJECT/index.html" ]]; then
C="$(compiler_for)"
echo "[render] plan.json newer than index.html — recompiling via $C"
node "$(dirname "$0")/$C" "$PROJECT"
else
# the COMPILERS themselves may have changed since this project last compiled —
# rendering stale HTML after a compiler fix silently re-ships the old bug
C="$(compiler_for)"
RECOMPILER="$C"
# for cinematic projects the FULL compiler is make-cinematic (compiler_for points
# at make-composition, which only re-emits html from an already-compiled plan)
if [[ -f "$PROJECT/cinematic.json" && "$C" == "make-composition.cjs" ]]; then RECOMPILER="make-cinematic.cjs"; fi
ENGINE="$(dirname "$0")/../modes/cinematic/engine.html"
if [[ "$(dirname "$0")/$RECOMPILER" -nt "$PROJECT/index.html" || "$(dirname "$0")/lib-dna.cjs" -nt "$PROJECT/index.html" || ( -f "$ENGINE" && "$ENGINE" -nt "$PROJECT/index.html" ) ]]; then
echo "[render] compiler/engine newer than index.html — recompiling via $RECOMPILER"
node "$(dirname "$0")/$RECOMPILER" "$PROJECT"
fi
fi
# Embed template fonts BEFORE the gates + render. hyperframes only auto-supplies
# its ~18 canonical fonts; every other template family (Anton, Bangers, VT323,
# Press Start 2P, …) silently falls back to a generic font on a clean/offline/CI
# machine — it only "looks right" locally when that font happens to be installed
# as a system font. inject-fonts inlines the @font-face (base64 woff2, from
# modes/standard/fonts/fonts.css) for whatever non-canonical families each HTML
# actually uses, so measure-layout AND the capture see the true glyph metrics.
# Idempotent; a no-op when every font is canonical/system or already declared.
node "$(dirname "$0")/inject-fonts.cjs" "$PROJECT" \
|| echo "[render] (font embed skipped — inject-fonts.cjs/fonts.css unavailable)" >&2
# gate ledger — each gate appends one line; echoed as a summary before "done"
# (verdicts were drowning hundreds of lines up in ffmpeg logs)
GATES="$PROJECT/_gates.txt"; : > "$GATES"
# Gate: plan.json word timings must align with transcript.json within 80ms.
# A caption whose animation fires 500ms before or after the word is spoken
# breaks the "belongs to the scene" illusion — hard fail, not a warning.
# Skip only if transcript.json is missing (custom mode without transcript).
if [[ -f "$PROJECT/plan.json" && -f "$PROJECT/transcript.json" ]]; then
if ! node "$(dirname "$0")/check-timing.cjs" "$PROJECT" --strict; then
echo "[render] ABORTED — fix plan.json word timings to match transcript.json, then re-run." >&2
exit 2
fi
echo "timing PASS (strict)" >> "$GATES"
fi
# Gate: subject occlusion + frame-edge overflow — pixel-perfect via Chromium DOM
# rects (measure-layout.cjs) × the subject-matte alpha (sharp). Template mode only
# (skipped when plan.json is absent; custom mode uses check-overflow.cjs below).
if [[ -f "$PROJECT/plan.json" && -d "$PROJECT/frames_fg" ]]; then
# check-occlusion prints its verdict, then can intermittently hang on native
# (sharp/libvips) teardown — which would wedge the whole render. Run it under a
# watchdog: once the verdict is printed, a stuck exit can't block us. We read the
# pass/fail from its OUTPUT, not its exit code, so the hang is harmless.
OCC_LOG="$PROJECT/_occlusion.log"
node "$(dirname "$0")/check-occlusion.cjs" "$PROJECT" --strict > "$OCC_LOG" 2>&1 &
OCC_PID=$!; occ_t0=$SECONDS; OCC_RC=""; verdict_at=""
while kill -0 "$OCC_PID" 2>/dev/null; do
# mark when the verdict header is printed (analysis done). if-form: a failed grep
# must NOT trip set -e (it silently killed the whole render on fresh projects).
if [[ -z "$verdict_at" ]] && grep -qE '\[v2\].*word-fail' "$OCC_LOG" 2>/dev/null; then verdict_at=$SECONDS; fi
# verdict printed but still alive 8s later → native (sharp) teardown hang; or no
# verdict after 150s → measure/analysis stuck. Either way: kill + read verdict.
if { [[ -n "$verdict_at" ]] && (( SECONDS - verdict_at > 8 )); } || (( SECONDS - occ_t0 > 150 )); then
kill -9 "$OCC_PID" 2>/dev/null
if grep -q 'cap(s) FAIL' "$OCC_LOG"; then OCC_RC=2; else OCC_RC=0; fi
echo "[render] occlusion gate hung after verdict (sharp teardown) — killed zombie; verdict rc=$OCC_RC" >&2
break
fi
sleep 2
done
if [[ -z "$OCC_RC" ]]; then OCC_RC=0; wait "$OCC_PID" 2>/dev/null || OCC_RC=$?; fi # capture rc without tripping set -e
cat "$OCC_LOG"
if (( OCC_RC != 0 )); then
echo "[render] ABORTED — fix plan.json layout to reduce subject occlusion / frame-edge overflow, then re-run." >&2
echo " Override: OCCLUSION_SKIP=1 bash render-and-composite.sh <project>" >&2
if [[ "${OCCLUSION_SKIP:-0}" != "1" ]]; then
exit 2
fi
echo "[render] OCCLUSION_SKIP=1 set — continuing despite occlusion/overflow warnings." >&2
echo "occlusion+overflow OVERRIDDEN (OCCLUSION_SKIP=1 — conscious accept)" >> "$GATES"
fi
if (( OCC_RC == 0 )); then echo "occlusion+overflow PASS" >> "$GATES"; fi
fi
# Custom mode (no plan.json) skips the template gates above. Run a lightweight,
# mode-agnostic frame-overflow check as a WARNING only — custom designs may bleed
# off-frame intentionally, so it never aborts, but it surfaces captions that fall
# off the canvas (the failure we otherwise only catch by eye).
if [[ ! -f "$PROJECT/plan.json" && -f "$PROJECT/index.html" && -f "$(dirname "$0")/check-overflow.cjs" ]]; then
node "$(dirname "$0")/check-overflow.cjs" "$PROJECT" \
|| echo "[render] (overflow check skipped — Chromium/puppeteer unavailable)" >&2
echo "overflow(index) checked (custom mode, warning-only)" >> "$GATES"
fi
# rail.html gets NO other automated coverage (the occlusion gate only reads plan.json
# caps) — run its overflow check whenever it exists. Was dead code inside the
# no-plan.json branch: Standard always HAS a derived plan.json, so it never ran.
if [[ -f "$PROJECT/rail.html" && -f "$(dirname "$0")/check-overflow.cjs" ]]; then
if node "$(dirname "$0")/check-overflow.cjs" "$PROJECT" rail.html; then
echo "overflow(rail) PASS" >> "$GATES"
else
echo "[render] (rail overflow check skipped — Chromium/puppeteer unavailable)" >&2
echo "overflow(rail) skipped (infra)" >> "$GATES"
fi
fi
# Standard hand-off gate: the PROMOTED climax word must NOT also be revealed in the
# rail during the climax's on-screen window (PIPELINE.md "Rail ↔ climax hand-off").
# Hard-fails on a CONFIRMED duplicate; infra issues (no puppeteer, etc.) exit 0 and
# never block. Override with RAIL_CLIMAX_SKIP=1 for a deliberate exception.
if [[ -f "$PROJECT/rail.html" && -f "$PROJECT/index.html" && -f "$(dirname "$0")/check-rail-climax.cjs" ]]; then
if ! node "$(dirname "$0")/check-rail-climax.cjs" "$PROJECT"; then
echo "[render] ABORTED — the promoted climax word is duplicated in the rail." >&2
echo " Apply the rail↔climax hand-off (PIPELINE.md), then re-run." >&2
echo " Override: RAIL_CLIMAX_SKIP=1 bash render-and-composite.sh <project>" >&2
if [[ "${RAIL_CLIMAX_SKIP:-0}" != "1" ]]; then
exit 2
fi
echo "[render] RAIL_CLIMAX_SKIP=1 — continuing despite the rail/climax duplicate." >&2
echo "rail-climax OVERRIDDEN (RAIL_CLIMAX_SKIP=1)" >> "$GATES"
else
echo "rail-climax PASS (no duplicate reveal)" >> "$GATES"
fi
fi
# FPS: matte.fps (written by matte.cjs at the source's NATIVE rate) is authoritative
# so the matte overlay stays frame-aligned with the render. Falls back to plan.fps /
# frame-count inference / 24. Warn if plan.json fps disagrees with the matte.
FPS=""
if [[ -f "$PROJECT/matte.fps" ]]; then
FPS="$(tr -dc '0-9' < "$PROJECT/matte.fps")"
if [[ -f "$PROJECT/plan.json" ]]; then
PFPS="$(node -e 'try{process.stdout.write(String(require(process.argv[1]).fps??""))}catch(e){}' "$PROJECT/plan.json" 2>/dev/null || true)"
if [[ -n "$PFPS" && "$PFPS" != "$FPS" ]]; then
echo "[render] WARN: plan.json fps=$PFPS != matte fps=$FPS — using matte fps to keep occlusion aligned" >&2
fi
fi
fi
if [[ -z "$FPS" || "$FPS" == "0" ]]; then
if [[ -f "$PROJECT/plan.json" ]]; then
FPS="$(node -e 'process.stdout.write(String(require(process.argv[1]).fps??24))' "$PROJECT/plan.json")"
elif [[ -d "$PROJECT/frames_fg" ]]; then
N="$(ls "$PROJECT/frames_fg" | wc -l | tr -d ' ')"
DUR="$(grep -oE 'data-duration="[0-9.]+"' "$PROJECT/index.html" | head -1 | grep -oE '[0-9.]+' || echo '')"
if [[ -n "$DUR" && "$N" -gt 0 ]]; then
FPS="$(awk "BEGIN{printf \"%d\", $N/$DUR + 0.5}")"
else
FPS=24
fi
else
FPS=24
fi
fi
# Caption layer: "bg" (classic embed — matte overlays subject on top of caps)
# or "fg" (captions always on top — announcement feel, used for 9:16 portraits
# where the subject fills the frame and bg mode loses too much to occlusion).
# Precedence: CLI env flag > plan.json > HTML data attribute > "bg".
CAPTION_LAYER="${CAPTION_LAYER_FLAG:-}"
if [[ -z "$CAPTION_LAYER" && -f "$PROJECT/plan.json" ]]; then
CAPTION_LAYER="$(node -e 'process.stdout.write(String(require(process.argv[1]).caption_layer??"bg"))' "$PROJECT/plan.json")"
fi
if [[ -z "$CAPTION_LAYER" && -f "$PROJECT/index.html" ]]; then
ATTR="$(grep -oE 'data-caption-layer="(bg|fg)"' "$PROJECT/index.html" | head -1 | grep -oE '(bg|fg)' || true)"
if [[ -n "$ATTR" ]]; then CAPTION_LAYER="$ATTR"; fi
fi
CAPTION_LAYER="${CAPTION_LAYER:-bg}"
echo "[render] caption_layer=$CAPTION_LAYER"
BG="$PROJECT/bg_plus_caps.mp4"
FINAL="$PROJECT/final.mp4"
# Snapshot the current index.html + plan.json into history/ so the user
# can recover a prior iteration's design after further edits overwrite it.
HISTORY_DIR="$PROJECT/history"
mkdir -p "$HISTORY_DIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
cp "$PROJECT/index.html" "$HISTORY_DIR/index-${STAMP}.html"
cp "$PROJECT/plan.json" "$HISTORY_DIR/plan-${STAMP}.json" 2>/dev/null || true
echo "[render] snapshot → history/index-${STAMP}.html"
echo "[render] hyperframes render @ ${FPS}fps"
# Hyperframes occasionally hangs on Chromium shutdown *after* the output file
# is successfully written (seen multiple times on 1530s clips). Without a
# guard the shell waits forever. This helper enforces a max wall-clock budget,
# and if the output is already on disk when we hit it, treats the run as
# successful and kills the zombie. Tune HF_TIMEOUT_S via env if needed.
# Default SCALES with clip size: two parallel Chromium passes on a long clip
# legitimately exceed a fixed 240s (a 38s/1151-frame render was killed at 244s
# while healthy). ~1.5s per source frame, floor 240s.
N_FRAMES="$(ls "$PROJECT/frames_fg" 2>/dev/null | wc -l | tr -d ' ')"
HF_TIMEOUT_S="${HF_TIMEOUT_S:-$(( N_FRAMES * 3 / 2 > 240 ? N_FRAMES * 3 / 2 : 240 ))}"
# hf_render_dir: render one hyperframes composition.
# args: <output.mp4> <label> <project_dir>
# watches for the Chromium-shutdown-hang; if output file exists and is >1MB
# past timeout, treats as success and kills the zombie.
hf_render_dir() {
local out="$1" label="$2" proj="$3" fmt="${4:-}"
# bash 3.2 (macOS) throws on empty-array expansion under `set -u`, so branch
# explicitly instead of splatting an optional --format array.
if [[ -n "$fmt" ]]; then
node "$HF_CLI" render --skill=embedded-captions --dir "$proj" --fps "$FPS" --format "$fmt" --crf 11 -o "$out" &
else
node "$HF_CLI" render --skill=embedded-captions --dir "$proj" --fps "$FPS" --crf 11 -o "$out" &
fi
local pid=$! start=$SECONDS elapsed
while kill -0 "$pid" 2>/dev/null; do
elapsed=$((SECONDS - start))
if (( elapsed > HF_TIMEOUT_S )); then
local sz=0
[[ -f "$out" ]] && sz=$(stat -f%z "$out" 2>/dev/null || echo 0)
if (( sz > 1000000 )); then
echo "[render] ${label}: node hung ${elapsed}s after shutdown (output ${sz}B exists, treating as success)"
kill -9 "$pid" 2>/dev/null
pkill -9 -f "puppeteer_dev_chrome_profile" 2>/dev/null
return 0
fi
echo "[render] ${label}: hung ${elapsed}s with no output — killing and failing" >&2
kill -9 "$pid" 2>/dev/null
pkill -9 -f "puppeteer_dev_chrome_profile" 2>/dev/null
return 2
fi
sleep 5
done
wait "$pid" 2>/dev/null
[[ -f "$out" ]]
}
# Link a project's assets into a shadow render dir EXCEPT the files we manage
# (the HTML we override + render outputs/intermediates). Links every other entry by
# its real name, so the shadow resolves whatever media the HTML references — including
# the ORIGINAL video filename `hyperframes init` scaffolds (e.g. clip.mp4), not just a
# fixed allow-list. Prevents the shadow-render 404 → silent/frozen output → abort.
link_assets() { # <project> <shadow>
local proj="$1" sh="$2" b
for p in "$proj"/*; do
[[ -e "$p" ]] || continue
b="$(basename "$p")"
case "$b" in
index.html|rail.html|index_fg.html|final.mp4|bg_plus_caps.mp4|fg_caps.mp4|rail.webm|history|_*) continue;;
esac
ln -sf "$p" "$sh/$b"
done
}
# Hybrid renders need 2 independent hyperframes passes. They share no state, so
# we run them in parallel (one in the main PROJECT, one in a shadow dir with
# index_fg.html renamed to index.html). Saves ~half of the Chromium cost.
FG_SHADOW=""
if [[ -f "$PROJECT/index_fg.html" ]]; then
FG_SHADOW="$PROJECT/_fg_shadow"
rm -rf "$FG_SHADOW" && mkdir -p "$FG_SHADOW"
# Link shared assets (any media filename); copy index_fg.html into shadow as index.html.
link_assets "$PROJECT" "$FG_SHADOW"
cp "$PROJECT/index_fg.html" "$FG_SHADOW/index.html"
FG_CAPS="$PROJECT/fg_caps.mp4"
echo "[render] hybrid fg/bg — launching both passes in parallel"
hf_render_dir "$BG" "bg_plus_caps" "$PROJECT" &
BG_PID=$!
hf_render_dir "$FG_CAPS" "fg_caps" "$FG_SHADOW" &
FG_PID=$!
# Wait for both; fail if either fails.
BG_RC=0; wait "$BG_PID" || BG_RC=$?
FG_RC=0; wait "$FG_PID" || FG_RC=$?
rm -rf "$FG_SHADOW"
if (( BG_RC != 0 )); then echo "[render] bg render failed" >&2; exit 1; fi
if (( FG_RC != 0 )); then echo "[render] fg render failed" >&2; exit 1; fi
elif [[ -f "$PROJECT/rail.html" ]]; then
# Standard mode: TWO independent hyperframes passes (base = index.html with the
# embed; rail = rail.html transparent). Each renders from its own shadow dir (the
# multiple-root ambiguity), and they share no state — run them IN PARALLEL like
# the fg-hybrid above (~halves the Chromium wall time). The rail webm is consumed
# by the composite stage below, which finds it already rendered.
BASE_SHADOW="$PROJECT/_base_shadow"; rm -rf "$BASE_SHADOW"; mkdir -p "$BASE_SHADOW"
link_assets "$PROJECT" "$BASE_SHADOW"
cp "$PROJECT/index.html" "$BASE_SHADOW/index.html"
RAIL_SHADOW="$PROJECT/_rail_shadow"; rm -rf "$RAIL_SHADOW"; mkdir -p "$RAIL_SHADOW"
link_assets "$PROJECT" "$RAIL_SHADOW"
cp "$PROJECT/rail.html" "$RAIL_SHADOW/index.html"
RAIL_WEBM="$PROJECT/rail.webm"
echo "[render] standard base + rail — launching both passes in parallel"
hf_render_dir "$BG" "bg_plus_caps" "$BASE_SHADOW" &
BASE_PID=$!
hf_render_dir "$RAIL_WEBM" "rail" "$RAIL_SHADOW" "webm" &
RAIL_PID=$!
BASE_RC=0; wait "$BASE_PID" || BASE_RC=$?
RAIL_RC=0; wait "$RAIL_PID" || RAIL_RC=$?
rm -rf "$BASE_SHADOW" "$RAIL_SHADOW"
if (( BASE_RC != 0 )); then echo "[render] bg render failed" >&2; exit 1; fi
if (( RAIL_RC != 0 )); then echo "[render] rail render failed" >&2; exit 1; fi
else
hf_render_dir "$BG" "bg_plus_caps" "$PROJECT" \
|| { echo "[render] bg render failed" >&2; exit 1; }
fi
# Probe render dims for ffmpeg scale
W="$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=nw=1:nk=1 "$BG")"
H="$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=nw=1:nk=1 "$BG")"
# Clamp every composite to the matte (= source-video) length. The render uses
# plan.duration / data-duration, which can exceed the source (e.g. Whisper word
# timestamps overrun the clip). Past the source the a-roll is gone (black) but the
# matte overlay repeats its last frame → the tail shows the subject floating on
# black. frames_fg count/fps APPROXIMATES the source length, but fractional-rate
# sources (29.97) get over-extracted by integer-fps rounding (e.g. 544 frames for
# a 542-frame 18.085s clip -> 544/30 = 18.133s -> ~1.5 trailing BLACK frames after
# the a-roll stream ends). The true a-roll duration is authoritative: clamp to
# min(matte frames / fps, source duration).
MATTE_DUR="$(awk "BEGIN{printf \"%.3f\", $(ls "$PROJECT/frames_fg" | wc -l)/$FPS}")"
SRC_DUR="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$PROJECT/source.mp4" 2>/dev/null || true)"
if [[ -n "${SRC_DUR:-}" ]]; then
MATTE_DUR="$(awk "BEGIN{m=$MATTE_DUR; s=$SRC_DUR; printf \"%.3f\", (s>0 && s<m) ? s : m}")"
fi
echo "[render] clamp output to source/matte length: ${MATTE_DUR}s"
# Bug-1 guard: the background plate ($BG) must be at least the matte/source length,
# else the tail (where the bg ran out but the matte continues) shows ONLY the
# foreground subject on black. Cinematic auto-fixes this (make-composition sets the
# canvas = source length); this catches a hand-authored Standard duration set to the
# last-caption time instead of the clip length. Clamp to the bg length so we never
# ship the only-foreground tail, and tell the author the real fix.
if [[ -f "$BG" ]]; then
BG_DUR="$(ffprobe -v error -show_entries format=duration -of default=nokey=1:noprint_wrappers=1 "$BG" 2>/dev/null | tr -dc '0-9.')"
if [[ -n "$BG_DUR" ]] && awk "BEGIN{exit !($BG_DUR < $MATTE_DUR - 0.3)}"; then
echo "[render] ⚠ background plate is ${BG_DUR}s but the clip is ${MATTE_DUR}s — the composition is shorter than the footage." >&2
echo " The tail would show ONLY the foreground subject on black. FIX: set the composition" >&2
echo " duration to the SOURCE clip length (data-duration on #root/#a-roll); captions may still" >&2
echo " end earlier. Clamping output to ${BG_DUR}s for now to avoid the broken tail." >&2
MATTE_DUR="$BG_DUR"
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# STANDARD mode (rail + embed) — detected by rail.html.
# $BG = index.html rendered (source video + the embed climax).
# (1) overlay the subject matte so the subject occludes the climax (embed = behind).
# (2) render rail.html → transparent WebM (the verbatim rail).
# (3) alpha-composite the rail IN FRONT so it is never occluded (rail = on top).
# The existing Cinematic paths below are untouched.
if [[ -f "$PROJECT/rail.html" ]]; then
MATTED="$PROJECT/_matted.mp4"
if [[ "$CAPTION_LAYER" == "fg" ]]; then
# caption_layer:fg — the climax sits IN FRONT of the subject (no behind-subject
# embed possible, e.g. a frame-filling 9:16 subject). bg_plus_caps already has the
# climax drawn over the video, so we SKIP the matte overlay (which would push the
# subject back in front of it). The rail still overlays on top below.
echo "[render] STANDARD (rail + FRONT climax) — caption_layer=fg, matte overlay skipped (${W}x${H})"
cp "$BG" "$MATTED"
else
echo "[render] STANDARD (rail + embed) — embed behind subject, rail alpha-overlaid in front (${W}x${H})"
ffmpeg -y -i "$BG" \
-framerate "$FPS" -i "$PROJECT/frames_fg/f_%04d.png" \
-filter_complex "[1:v]scale=${W}:${H},format=yuva420p[m];[0:v][m]overlay=format=auto[v]" \
-map "[v]" -map 0:a -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 11 -preset medium -c:a copy "$MATTED"
fi
# rail.webm was already rendered IN PARALLEL with the base pass above.
RAIL_WEBM="$PROJECT/rail.webm"
[[ -f "$RAIL_WEBM" ]] || { echo "[render] rail.webm missing (parallel rail pass failed?)" >&2; exit 1; }
# alpha-overlay the transparent rail in front of the matted video (force vp9
# decode so the WebM alpha plane is honoured by overlay)
ffmpeg -y -i "$MATTED" -c:v libvpx-vp9 -i "$RAIL_WEBM" \
-filter_complex "[0:v][1:v]overlay=format=auto[v]" \
-map "[v]" -map 0:a -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 11 -preset medium -c:a copy "$FINAL"
rm -f "$MATTED"
if [[ -s "$GATES" ]]; then echo "[render] ── gates ──"; sed 's/^/[render] /' "$GATES"; fi
echo "[render] done → $FINAL"
exit 0
fi
# Decide composite mode:
# - If make-composition emitted index_fg.html (any group has layer:fg),
# use hybrid regardless of plan-level caption_layer.
# - Else if caption_layer=fg globally, skip matte.
# - Else (default), matte embed.
if [[ -f "$PROJECT/index_fg.html" ]]; then
# Hybrid: bg_plus_caps and fg_caps were both rendered above in parallel.
# Now composite: bg_plus_caps + matte (subject on top) + fg_caps (screen).
FG_CAPS="$PROJECT/fg_caps.mp4"
echo "[render] hybrid — composite (bg_plus_caps + matte + fg_caps[screen blend])"
# fg_caps is bright caption on pure black. blend=screen makes it behave
# like CSS mix-blend-mode: screen on the matted video — captions pick up
# scene luminance, NOT a flat opaque overlay (which looks sticker-like).
# Use rgb format to avoid YUV-space color drift, then convert back for libx264.
ffmpeg -y -i "$BG" \
-framerate "$FPS" -i "$PROJECT/frames_fg/f_%04d.png" \
-i "$FG_CAPS" \
-filter_complex "[1:v]scale=${W}:${H},format=yuva420p[matte];[0:v][matte]overlay=format=auto,format=gbrp[matted];[2:v]format=gbrp[fg];[matted][fg]blend=all_mode=screen,format=yuv420p[v]" \
-map "[v]" -map 0:a \
-r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 12 -preset medium -c:a copy \
"$FINAL"
elif [[ "$CAPTION_LAYER" == "fg" ]]; then
# Global FG mode: skip matte overlay entirely. bg_plus_caps.mp4 already
# has captions on top of a-roll. Re-encode for consistency.
echo "[render] fg mode (global) — skipping matte, re-encoding ${W}x${H}"
ffmpeg -y -i "$BG" \
-r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 12 -preset medium -c:a copy \
"$FINAL"
else
echo "[render] bg mode — overlay matte (${W}x${H})"
ffmpeg -y -i "$BG" \
-framerate "$FPS" -i "$PROJECT/frames_fg/f_%04d.png" \
-filter_complex "[1:v]scale=${W}:${H},format=yuva420p[fg];[0:v][fg]overlay=format=auto[v]" \
-map "[v]" -map 0:a \
-r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 12 -preset medium -c:a copy \
"$FINAL"
fi
if [[ -s "$GATES" ]]; then echo "[render] ── gates ──"; sed 's/^/[render] /' "$GATES"; fi
echo "[render] done → $FINAL"
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# render-theme.sh — compile + render + plate-reaction for THEME mode projects.
# bash render-theme.sh <project-dir>
# Project needs: theme.json, transcript.json, source.mp4, frames_fg/, matte.fps
# Output: final_fx.mp4 (final.mp4 = before plate reaction)
set -euo pipefail
PROJECT="${1:?usage: render-theme.sh <project-dir>}"
PROJECT="$(cd "$PROJECT" && pwd)"
SD="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
node "$SD/make-theme.cjs" "$PROJECT"
# Gate honesty (cold-start E2E finding): theme mode enforces word timing at
# COMPILE time (sequential transcript matcher + verbatim completeness gate in
# make-theme.cjs — a mistimed/missing word is a compile error, not a render
# warning). The pixel occlusion gate is Cinematic-only; themes place heroes
# scene-aware (safe-zones) and are covered by overflow + rail-climax + the
# PAGEERROR gate below.
echo "[render-theme] gates: timing PASS (compile-enforced verbatim matcher)"
echo "[render-theme] gates: occlusion = scene-aware placement (pixel gate is Cinematic-only)"
RLOG="$PROJECT/_render-theme.log"
bash "$SD/render-and-composite.sh" "$PROJECT" 2>&1 | tee "$RLOG"
# GATE: a JS error in either generated layer silently yields a blank caption
# layer while every downstream gate still passes — fail loudly instead.
if grep -q "PAGEERROR" "$RLOG"; then
echo "[render-theme] FAIL — Browser PAGEERROR in a generated layer (see $RLOG)." >&2
echo " The caption timeline did not build; the output is missing captions." >&2
exit 2
fi
if [[ -f "$PROJECT/_postfx.sh" ]]; then
bash "$PROJECT/_postfx.sh"
else
cp "$PROJECT/final.mp4" "$PROJECT/final_fx.mp4"
fi
echo "[render-theme] done → $PROJECT/final_fx.mp4 (DELIVERABLE: final_fx.mp4; final.mp4 is the pre-plate-reaction cut)"
@@ -0,0 +1,813 @@
#!/usr/bin/env node
/*
* safe-zones.cjs — PRECOMPUTE where captions can safely go, from the subject matte.
*
* node safe-zones.cjs <project-dir> → safe-zones.json (global + per-sentence windows) + summary
* node safe-zones.cjs <project-dir> <in> <out> → just that time window's zones (ad-hoc query)
*
* The inverse of check-occlusion: read the silhouette FIRST and hand the author the
* clean regions + an embed-vs-fg verdict, so layout is right the first time instead of
* after N occlusion-failure rounds.
*
* Uses the per-pixel ALPHA matte (not a bounding box): a cell counts as subject only
* where the silhouette actually is, so the empty pocket beside the head / above the
* shoulders stays FREE (a bbox would wrongly claim it). The subject MOVES, so zones are
* computed PER TIME WINDOW (the union over just that window's frames), not one global box.
*/
const path = require("path");
const fs = require("fs");
const os = require("os");
const THRESH = 30 / 255; // a cell is "subject" if ≥12% covered at any sampled frame in the window
const SAMPLES = 48; // frames cached across the clip (windows aggregate the cached grids)
const HF_ROOTS = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "../../.."),
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
let sharp = null;
for (const root of HF_ROOTS) {
const cands = [path.join(root, "node_modules", "sharp")];
const bunDir = path.join(root, "node_modules", ".bun");
try {
if (fs.existsSync(bunDir))
for (const d of fs.readdirSync(bunDir))
if (d.startsWith("sharp@")) cands.push(path.join(bunDir, d, "node_modules", "sharp"));
} catch {}
for (const c of cands) {
try {
if (fs.existsSync(c)) {
sharp = require(c);
break;
}
} catch {}
}
if (sharp) break;
}
// largest all-clear (1) rectangle within [c0,c1)×[r0,r1), in CELL units
function largestRect(safe, GW, c0, c1, r0, r1) {
const cols = c1 - c0;
const heights = new Array(cols).fill(0);
let best = { area: 0, x: 0, y: 0, w: 0, h: 0 };
for (let r = r0; r < r1; r++) {
for (let cc = 0; cc < cols; cc++) heights[cc] = safe[r * GW + (c0 + cc)] ? heights[cc] + 1 : 0;
const st = [];
for (let cc = 0; cc <= cols; cc++) {
const h = cc < cols ? heights[cc] : 0;
let start = cc;
while (st.length && st[st.length - 1].h >= h) {
const top = st.pop();
const area = top.h * (cc - top.i);
if (area > best.area)
best = { area, x: c0 + top.i, y: r - top.h + 1, w: cc - top.i, h: top.h };
start = top.i;
}
st.push({ i: start, h });
}
}
return best;
}
// turn a max-coverage grid into {coverage, subject, zones, recommendation}
function analyze(occ, GW, GH, W, H, lum, lumSeries) {
const occCell = new Uint8Array(GW * GH);
for (let c = 0; c < GW * GH; c++) occCell[c] = occ[c] >= THRESH ? 1 : 0;
const safe = new Uint8Array(GW * GH); // 1-cell dilation margin around the silhouette
for (let y = 0; y < GH; y++)
for (let x = 0; x < GW; x++) {
let o = 0;
for (let dy = -1; dy <= 1 && !o; dy++)
for (let dx = -1; dx <= 1 && !o; dx++) {
const nx = x + dx,
ny = y + dy;
if (nx >= 0 && nx < GW && ny >= 0 && ny < GH && occCell[ny * GW + nx]) o = 1;
}
safe[y * GW + x] = o ? 0 : 1;
}
const occupied = occCell.reduce((a, b) => a + b, 0);
const coverage = occupied / (GW * GH);
let colMin = GW,
colMax = -1,
rowMin = GH,
rowMax = -1;
for (let x = 0; x < GW; x++)
for (let y = 0; y < GH; y++)
if (occCell[y * GW + x]) {
if (x < colMin) colMin = x;
if (x > colMax) colMax = x;
if (y < rowMin) rowMin = y;
if (y > rowMax) rowMax = y;
}
if (colMax < 0) {
colMin = 0;
colMax = -1;
rowMin = 0;
rowMax = -1;
}
const clearerSide = colMin >= GW - 1 - colMax ? "left" : "right";
const cellW = W / GW,
cellH = H / GH;
// HERO anchor — where the ONE big promoted word should sit: ON the subject (centered,
// crossing it so the head/torso occludes the middle). The OPPOSITE of the clean zones
// above (those are for narration). A wide band ≈ centered on the subject, vertically
// crossing the head/upper torso. Only meaningful when there IS a subject.
let heroAnchor = null;
if (colMax >= 0) {
const subjCx = ((colMin + colMax + 1) / 2 / GW) * 100;
const subjWpct = ((colMax - colMin + 1) / GW) * 100;
const yTop = (rowMin / GH) * 100,
yBot = ((rowMax + 1) / GH) * 100;
const wPct = Math.round(Math.min(92, Math.max(58, subjWpct + 26)));
const xPct = Math.round(Math.min(98 - wPct, Math.max(2, subjCx - wPct / 2)));
const yPct = Math.round(yTop + (yBot - yTop) * 0.12); // band crosses the head / upper torso
let bandLuma = null;
if (lum) {
const y0 = Math.round((yPct / 100) * GH),
y1 = Math.min(GH, y0 + Math.max(2, Math.round(GH * 0.14)));
const x0 = Math.round((xPct / 100) * GW),
x1 = Math.min(GW, Math.round(((xPct + wPct) / 100) * GW));
// luma of the BACKGROUND cells only — that's where the hero's glyphs are visible
// (the subject-occluded middle doesn't show text; averaging it in hides washout).
let s2 = 0,
n = 0;
for (let y = y0; y < y1; y++)
for (let x = x0; x < x1; x++) {
if (occCell[y * GW + x]) continue;
s2 += lum[y * GW + x];
n++;
}
bandLuma = n ? Math.round(s2 / n) : null;
}
heroAnchor = {
centerXPct: +subjCx.toFixed(1),
plane: { xPct, yPct, wPct, align: "center" },
...(bandLuma != null ? { bandLuma, washoutRisk: bandLuma > 175 } : {}),
note:
"Place the ONE big hero here (centered on the subject); the head/torso occludes its middle (~30-55%) — that is the embed. Do NOT put the hero in a clean zone." +
(bandLuma != null && bandLuma > 175
? " ⚠ BAND IS BRIGHT (luma " +
bandLuma +
"): cream/screen text will wash out — lower the hero onto the darker subject body, or use a template/mode with opaque text."
: ""),
};
}
const zoneLuma = (r) => {
if (!lum || r.area === 0) return null;
let s2 = 0,
n = 0;
for (let y = r.y; y < r.y + r.h; y++)
for (let x = r.x; x < r.x + r.w; x++) {
s2 += lum[y * GW + x];
n++;
}
return n ? Math.round(s2 / n) : null;
};
// a TIME-AVERAGED map walks a moving minefield: a dark wall swept by a bright
// moving object (handheld drift, screens) averages "clean" while peaking hot.
// peakLuma = p95 of the zone's per-sample mean over the window.
const zoneLumaPeak = (r) => {
if (!lumSeries || !lumSeries.length || r.area === 0) return null;
const means = lumSeries
.map((Lg) => {
let s2 = 0,
n = 0;
for (let y = r.y; y < r.y + r.h; y++)
for (let x = r.x; x < r.x + r.w; x++) {
s2 += Lg[y * GW + x];
n++;
}
return n ? s2 / n : 0;
})
.sort((a, b) => a - b);
return Math.round(means[Math.max(0, Math.ceil(means.length * 0.95) - 1)]);
};
const toZone = (r) =>
r.area === 0
? null
: {
xPct: +((r.x / GW) * 100).toFixed(1),
yPct: +((r.y / GH) * 100).toFixed(1),
wPct: +((r.w / GW) * 100).toFixed(1),
hPct: +((r.h / GH) * 100).toFixed(1),
areaPct: +(((r.w * r.h) / (GW * GH)) * 100).toFixed(1),
px: {
x: Math.round(r.x * cellW),
y: Math.round(r.y * cellH),
w: Math.round(r.w * cellW),
h: Math.round(r.h * cellH),
},
...(zoneLuma(r) != null
? {
meanLuma: zoneLuma(r),
bright: zoneLuma(r) > 180,
...(zoneLumaPeak(r) != null ? { peakLuma: zoneLumaPeak(r) } : {}),
}
: {}),
};
const zones = {
largest: toZone(largestRect(safe, GW, 0, GW, 0, GH)),
left: toZone(largestRect(safe, GW, 0, Math.round(GW / 2), 0, GH)),
right: toZone(largestRect(safe, GW, Math.round(GW / 2), GW, 0, GH)),
top: toZone(largestRect(safe, GW, 0, GW, 0, Math.max(2, Math.round(GH * 0.38)))),
};
// HUGGING zones — clean strips that ABUT the silhouette (the embed aesthetic wants
// text NEAR the subject, not parked in the farthest corner). Grown outward from the
// subject's edge at upper-body height; prefer these for narration.
const hug = (side) => {
if (colMax < 0) return null;
const pad = Math.max(1, Math.round(GW * 0.02));
const y0 = rowMin,
y1 = Math.min(GH, rowMin + Math.max(3, Math.round((rowMax - rowMin + 1) * 0.45)));
let x0, x1;
if (side === "right") {
x0 = Math.min(GW - 1, colMax + 1 + pad);
x1 = GW - pad;
} else {
x1 = Math.max(1, colMin - pad);
x0 = pad;
}
if (x1 - x0 < Math.round(GW * 0.1)) return null;
// shrink until actually clean (≤8% occupied cells)
let occN = 0,
tot = 0;
for (let y = y0; y < y1; y++)
for (let x = x0; x < x1; x++) {
tot++;
if (occCell[y * GW + x]) occN++;
}
if (tot === 0 || occN / tot > 0.08) return null;
const r = { x: x0, y: y0, w: x1 - x0, h: y1 - y0, area: (x1 - x0) * (y1 - y0) };
const z2 = toZone(r);
// GLYPHS must hug, not just the plane: in a wide column, text aligned to the far
// edge parks the words a third of the frame away from the subject. Align TOWARD
// the silhouette: right-side column → text-align:left (text starts beside the
// subject); left-side column → text-align:right.
if (z2) z2.align = side === "right" ? "left" : "right";
return z2;
};
zones.hugLeft = hug("left");
zones.hugRight = hug("right");
// HERO BAND PROFILE — per-height predicted occlusion of a centered hero band. The hero
// WANTS ~3055% (occlusion IS the embed); fg is the LAST resort, only when no height
// achieves ≤62%. Even an 88%-coverage frame usually has a feasible band over the hairline.
let heroBands = null;
if (colMax >= 0) {
const bandH = Math.max(2, Math.round(GH * 0.13));
const hx0 = Math.round(((heroAnchor ? heroAnchor.plane.xPct : 8) / 100) * GW);
const hx1 = Math.min(
GW,
Math.round(((heroAnchor ? heroAnchor.plane.xPct + heroAnchor.plane.wPct : 92) / 100) * GW),
);
const profile = [];
for (let y0 = 0; y0 + bandH <= GH; y0 += Math.max(1, Math.round(GH * 0.02))) {
let n = 0,
occN = 0,
lsum = 0;
for (let y = y0; y < y0 + bandH; y++)
for (let x = hx0; x < hx1; x++) {
n++;
if (occCell[y * GW + x]) occN++;
if (lum) lsum += lum[y * GW + x];
}
profile.push({
topPct: +((y0 / GH) * 100).toFixed(1),
occPct: +((occN / n) * 100).toFixed(1),
...(lum ? { bgLuma: Math.round(lsum / n) } : {}),
});
}
const ok = profile.filter((b) => b.occPct >= 12 && b.occPct <= 62);
const best = (ok.length ? ok : profile).reduce((a, b) =>
Math.abs(b.occPct - 40) < Math.abs(a.occPct - 40) ? b : a,
);
heroBands = { feasible: ok.length > 0, best, profile };
}
const big = zones.largest;
const embeddable = !!big && big.areaPct >= 8 && big.hPct >= 10 && big.wPct >= 18;
return {
coverage: +(coverage * 100).toFixed(1),
subject: {
colMinPct: +((colMin / GW) * 100).toFixed(1),
colMaxPct: +(((colMax + 1) / GW) * 100).toFixed(1),
clearerSide,
},
zones,
heroAnchor,
heroBands,
recommendation: embeddable ? "embed" : "fg",
};
}
// ── SCENE OPTICS + PALETTE (v2) ──────────────────────────────────────────────
// Deterministic scene measurements that drive the DNA tokens, so "design that fits
// the scene" is a pipeline product, not agent inspiration:
// palette — dominant scene colors + a READABLE accent suggestion (sampled, then
// clamped to usable saturation/lightness) + warm/cool temperature
// optics — background vs subject sharpness (Laplacian proxy) → suggested text
// blur so embed type matches the scene's depth-of-field
// lighting — bright-side estimate → contact-shadow direction for embed type
function rgb2hsv(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const mx = Math.max(r, g, b),
mn = Math.min(r, g, b),
d = mx - mn;
let h = 0;
if (d > 0) {
if (mx === r) h = ((g - b) / d) % 6;
else if (mx === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
return { h, s: mx === 0 ? 0 : d / mx, v: mx };
}
function hsv2hex(h, s, v) {
const c = v * s,
x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
m = v - c;
let [r, g, b] =
h < 60
? [c, x, 0]
: h < 120
? [x, c, 0]
: h < 180
? [0, c, x]
: h < 240
? [0, x, c]
: h < 300
? [x, 0, c]
: [c, 0, x];
const f = (n) =>
Math.round((n + m) * 255)
.toString(16)
.padStart(2, "0");
return `#${f(r)}${f(g)}${f(b)}`;
}
// dominant colors + accent suggestion from the BACKGROUND cells of a mid frame
async function scenePalette(bgPath, occCell, GW, GH) {
const { data, info } = await sharp(bgPath)
.resize(GW, GH, { fit: "fill" })
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const ch = info.channels;
const cells = [];
for (let c = 0; c < GW * GH; c++) {
if (occCell && occCell[c]) continue; // background only
cells.push([data[c * ch], data[c * ch + 1], data[c * ch + 2]]);
}
if (!cells.length) return null;
// dominant: quantize to 3 bits/channel, top buckets by count
const buckets = new Map();
for (const [r, g, b] of cells) {
const k = ((r >> 5) << 6) | ((g >> 5) << 3) | (b >> 5);
const e = buckets.get(k) || { n: 0, r: 0, g: 0, b: 0 };
e.n++;
e.r += r;
e.g += g;
e.b += b;
buckets.set(k, e);
}
const hex = (e) =>
"#" +
[e.r, e.g, e.b]
.map((x) =>
Math.round(x / e.n)
.toString(16)
.padStart(2, "0"),
)
.join("");
const dominant = [...buckets.values()]
.sort((a, b) => b.n - a.n)
.slice(0, 3)
.map((e) => ({ hex: hex(e), sharePct: +((e.n / cells.length) * 100).toFixed(1) }));
// chromatic accent: hue histogram over saturated cells, weighted s·v
const bins = Array.from({ length: 12 }, () => ({ w: 0, h: 0, s: 0, v: 0, n: 0 }));
let warmW = 0,
coolW = 0;
for (const [r, g, b] of cells) {
const { h, s, v } = rgb2hsv(r, g, b);
if (h <= 90 || h >= 330) warmW += s * v;
else if (h >= 150 && h <= 300) coolW += s * v;
if (s < 0.18 || v < 0.12 || v > 0.97) continue;
const bi = Math.floor(h / 30) % 12,
w = s * v;
const B = bins[bi];
B.w += w;
B.h += h * w;
B.s += s * w;
B.v += v * w;
B.n++;
}
const top = bins.reduce((a, b) => (b.w > a.w ? b : a));
let accent = null;
if (top.w > 0.5 && top.n >= 3) {
const h = top.h / top.w,
s = top.s / top.w,
v = top.v / top.w;
// clamp to a readable accent: saturated enough to read as a choice, light enough to sit on video
accent = hsv2hex(
h,
Math.min(0.78, Math.max(0.5, s * 1.2)),
Math.min(0.8, Math.max(0.55, v * 1.15)),
);
}
const temperature = warmW > coolW * 1.25 ? "warm" : coolW > warmW * 1.25 ? "cool" : "neutral";
return { dominant, accentSuggestion: accent, temperature };
}
// Laplacian-stdev sharpness proxy of a region crop (full-res)
async function regionSharpness(imgPath, rect, W, H) {
const x = Math.max(0, Math.min(W - 2, Math.round(rect.x))),
y = Math.max(0, Math.min(H - 2, Math.round(rect.y)));
const w = Math.max(2, Math.min(W - x, Math.round(rect.w))),
h = Math.max(2, Math.min(H - y, Math.round(rect.h)));
// two passes: crop to a buffer FIRST, then convolve+stats on the crop — sharp's
// internal pipeline ordering otherwise convolves/stats the full frame and the two
// regions measure identical.
const crop = await sharp(imgPath)
.extract({ left: x, top: y, width: w, height: h })
.png()
.toBuffer();
const st = await sharp(crop)
.greyscale()
.convolve({ width: 3, height: 3, kernel: [0, 1, 0, 1, -4, 1, 0, 1, 0], scale: 1, offset: 128 })
.stats();
return st.channels[0].stdev;
}
async function sceneOptics(project, bgPath, fgPath, zones, subjectBox, W, H) {
let bgSharp = null,
subjSharp = null;
const bigZone = zones && zones.largest && zones.largest.px;
try {
if (bigZone && bigZone.w >= 64 && bigZone.h >= 64)
bgSharp = await regionSharpness(bgPath, bigZone, W, H);
} catch {}
try {
if (subjectBox && subjectBox.w >= 64)
subjSharp = await regionSharpness(bgPath, subjectBox, W, H);
} catch {}
let suggestedTextBlurPx = 0,
ratio = null;
if (bgSharp != null && subjSharp != null && subjSharp > 1) {
ratio = +(bgSharp / subjSharp).toFixed(3);
// strong bokeh → text in that depth plane should soften to match
suggestedTextBlurPx = ratio < 0.35 ? 1.6 : ratio < 0.55 ? 1.0 : ratio < 0.8 ? 0.5 : 0;
}
return {
bgSharpness: bgSharp != null ? +bgSharp.toFixed(2) : null,
subjSharpness: subjSharp != null ? +subjSharp.toFixed(2) : null,
sharpnessRatio: ratio,
suggestedTextBlurPx,
};
}
// bright-side estimate from the luminance grid → contact-shadow vector (shadow falls AWAY from light)
function sceneLighting(lum, occCell, GW, GH) {
if (!lum) return null;
let sw = 0,
sx = 0,
sy = 0,
n = 0,
mean = 0;
for (let c = 0; c < GW * GH; c++) {
if (!occCell[c]) {
mean += lum[c];
n++;
}
}
if (!n) return null;
mean /= n;
for (let y = 0; y < GH; y++)
for (let x = 0; x < GW; x++) {
const c = y * GW + x;
if (occCell[c]) continue;
const w = Math.max(0, lum[c] - mean);
sw += w;
sx += w * (x / GW - 0.5);
sy += w * (y / GH - 0.5);
}
if (sw < 1) return { lightFrom: "flat", shadow: { dx: 0, dy: 3 } };
const lx = sx / sw,
ly = sy / sw; // light centroid offset from center, 0.5..0.5
const mag = Math.hypot(lx, ly);
if (mag < 0.04) return { lightFrom: "frontal", shadow: { dx: 0, dy: 3 } };
// shadow direction = opposite the light, scaled to a subtle px offset
const s = Math.min(1, mag / 0.25);
const dx = Math.round((-lx / mag) * 4 * s),
dy = Math.round(Math.max(1, (-ly / mag) * 4 * s + 2));
const compass =
Math.abs(lx) > Math.abs(ly) * 1.8
? lx > 0
? "right"
: "left"
: Math.abs(ly) > Math.abs(lx) * 1.8
? ly > 0
? "below"
: "above"
: `${ly > 0 ? "lower" : "upper"}-${lx > 0 ? "right" : "left"}`;
return { lightFrom: compass, shadow: { dx, dy } };
}
// split the transcript into sentence windows (punctuation, or a > 0.7s gap)
function sentenceWindows(project) {
const tp = path.join(project, "transcript.json");
if (!fs.existsSync(tp)) return [];
let words;
try {
words = (JSON.parse(fs.readFileSync(tp, "utf8")).words || []).filter((w) => w && "start" in w);
} catch {
return [];
}
const out = [];
let cur = [];
for (let i = 0; i < words.length; i++) {
cur.push(words[i]);
const w = words[i],
nx = words[i + 1];
const ends = /[.!?…]$/.test((w.text || "").trim());
const gap = nx ? nx.start - w.end > 0.7 : true;
if (ends || gap || !nx) {
if (cur.length)
out.push({
in: +cur[0].start.toFixed(2),
out: +cur[cur.length - 1].end.toFixed(2),
text: cur.map((x) => x.text).join(" "),
});
cur = [];
}
}
return out;
}
async function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: safe-zones.cjs <project-dir> [in out]");
process.exit(1);
}
const fgDir = path.join(project, "frames_fg");
if (!fs.existsSync(fgDir)) {
console.error(`[safe-zones] no ${fgDir} — run matte.cjs first`);
process.exit(2);
}
if (!sharp) {
console.error("[safe-zones] sharp unavailable — set HYPERFRAMES_ROOT");
process.exit(0);
}
const frames = fs
.readdirSync(fgDir)
.filter((f) => /\.png$/i.test(f))
.sort();
if (!frames.length) {
console.error("[safe-zones] no PNG frames");
process.exit(2);
}
const meta = await sharp(path.join(fgDir, frames[0])).metadata();
const W = meta.width,
H = meta.height;
const CELL = Math.max(W, H) / 48;
const GW = Math.max(8, Math.round(W / CELL)),
GH = Math.max(8, Math.round(H / CELL));
let fps = 24;
try {
const f = parseFloat(
String(fs.readFileSync(path.join(project, "matte.fps"), "utf8")).replace(/[^\d.]/g, ""),
);
if (f > 0) fps = f;
} catch {}
const bgDir = path.join(project, "frames_bg");
const hasBg = fs.existsSync(bgDir);
// cache evenly-sampled frame grids once (each = per-cell avg subject alpha 0..1,
// plus per-cell mean LUMINANCE from frames_bg — bright zones wash out cream/screen text)
const sampleIdx = [
...new Set(
Array.from({ length: SAMPLES }, (_, i) =>
Math.min(frames.length - 1, Math.round((i / (SAMPLES - 1)) * (frames.length - 1))),
),
),
];
const grids = [];
for (const i of sampleIdx) {
const { data, info } = await sharp(path.join(fgDir, frames[i]))
.resize(GW, GH, { fit: "fill" })
.raw()
.toBuffer({ resolveWithObject: true });
const ch = info.channels,
g = new Float32Array(GW * GH);
for (let c = 0; c < GW * GH; c++) g[c] = (ch >= 4 ? data[c * ch + 3] : 255) / 255;
let lum = null;
if (hasBg && fs.existsSync(path.join(bgDir, frames[i]))) {
const { data: bd, info: bi } = await sharp(path.join(bgDir, frames[i]))
.resize(GW, GH, { fit: "fill" })
.greyscale()
.raw()
.toBuffer({ resolveWithObject: true });
const bch = bi.channels;
lum = new Float32Array(GW * GH);
for (let c = 0; c < GW * GH; c++) lum[c] = bd[c * bch];
}
grids.push({ t: i / fps, g, lum });
}
const lumWindow = (t0, t1) => {
const acc = new Float32Array(GW * GH);
let n = 0;
let inWin = grids.filter((s2) => s2.t >= t0 - 1e-6 && s2.t <= t1 + 1e-6 && s2.lum);
if (!inWin.length) inWin = grids.filter((s2) => s2.lum);
for (const s2 of inWin) {
for (let c = 0; c < GW * GH; c++) acc[c] += s2.lum[c];
n++;
}
if (!n) return null;
for (let c = 0; c < GW * GH; c++) acc[c] /= n;
return acc;
};
const lumSeriesWindow = (t0, t1) => {
let inWin = grids.filter((s2) => s2.t >= t0 - 1e-6 && s2.t <= t1 + 1e-6 && s2.lum);
if (!inWin.length) inWin = grids.filter((s2) => s2.lum);
return inWin.map((s2) => s2.lum);
};
const occWindow = (t0, t1) => {
const occ = new Float32Array(GW * GH);
let inWin = grids.filter((s) => s.t >= t0 - 1e-6 && s.t <= t1 + 1e-6);
if (!inWin.length) {
// window between samples → use nearest grid
const mid = (t0 + t1) / 2;
inWin = [grids.reduce((a, b) => (Math.abs(b.t - mid) < Math.abs(a.t - mid) ? b : a))];
}
for (const s of inWin) for (let c = 0; c < GW * GH; c++) if (s.g[c] > occ[c]) occ[c] = s.g[c];
return occ;
};
// ad-hoc window query
const qIn = parseFloat(process.argv[3]),
qOut = parseFloat(process.argv[4]);
if (Number.isFinite(qIn) && Number.isFinite(qOut)) {
const a = analyze(
occWindow(qIn, qOut),
GW,
GH,
W,
H,
lumWindow(qIn, qOut),
lumSeriesWindow(qIn, qOut),
);
console.log(
`[safe-zones] window ${qIn}-${qOut}s: ${a.recommendation.toUpperCase()} coverage ${a.coverage}% clearer:${a.subject.clearerSide}`,
);
const z = a.zones;
for (const k of ["largest", "left", "right", "top"])
if (z[k]) console.log(` ${k}: ${z[k].wPct}%×${z[k].hPct}% @ (${z[k].xPct}%,${z[k].yPct}%)`);
console.log(JSON.stringify({ in: qIn, out: qOut, ...a }));
return;
}
const globalOcc = occWindow(-1e9, 1e9);
const globalLum = lumWindow(-1e9, 1e9);
const global = analyze(globalOcc, GW, GH, W, H, globalLum, lumSeriesWindow(-1e9, 1e9));
const windows = sentenceWindows(project).map((s) => {
const a = analyze(
occWindow(s.in, s.out),
GW,
GH,
W,
H,
lumWindow(s.in, s.out),
lumSeriesWindow(s.in, s.out),
);
return {
in: s.in,
out: s.out,
text: s.text.slice(0, 48),
coverage: a.coverage,
recommendation: a.recommendation,
clearerSide: a.subject.clearerSide,
zones: a.zones,
};
});
// ── v2: palette / optics / lighting from the mid frame + global grids ───────
let palette = null,
optics = null,
lighting = null;
try {
const occCellG = new Uint8Array(GW * GH);
for (let c = 0; c < GW * GH; c++) occCellG[c] = globalOcc[c] >= THRESH ? 1 : 0;
const midName = frames[Math.floor(frames.length / 2)];
const midBg = path.join(bgDir, midName);
if (hasBg && fs.existsSync(midBg)) {
palette = await scenePalette(midBg, occCellG, GW, GH);
// subject bbox in px from the global occupancy grid
let cx0 = GW,
cx1 = -1,
cy0 = GH,
cy1 = -1;
for (let y = 0; y < GH; y++)
for (let x = 0; x < GW; x++)
if (occCellG[y * GW + x]) {
if (x < cx0) cx0 = x;
if (x > cx1) cx1 = x;
if (y < cy0) cy0 = y;
if (y > cy1) cy1 = y;
}
const subjectBox =
cx1 >= 0
? {
x: (cx0 / GW) * W,
y: (cy0 / GH) * H,
w: ((cx1 - cx0 + 1) / GW) * W,
h: ((cy1 - cy0 + 1) / GH) * H,
}
: null;
optics = await sceneOptics(
project,
midBg,
path.join(fgDir, midName),
global.zones,
subjectBox,
W,
H,
);
lighting = sceneLighting(globalLum, occCellG, GW, GH);
}
} catch (e) {
console.error(`[safe-zones] scene optics skipped — ${e.message}`);
}
const out = {
width: W,
height: H,
fps,
grid: { cols: GW, rows: GH },
...global,
palette,
optics,
lighting,
windows,
};
fs.writeFileSync(path.join(project, "safe-zones.json"), JSON.stringify(out, null, 2));
const z = (n, zn) =>
zn
? `${n}: ${zn.wPct}%×${zn.hPct}% @ (${zn.xPct}%,${zn.yPct}%) [${zn.areaPct}%${zn.meanLuma != null ? ` · luma ${zn.meanLuma}${zn.bright ? " ⚠BRIGHT" : ""}` : ""}]`
: `${n}: —`;
console.log(
`[safe-zones] ${W}×${H} grid ${GW}×${GH} @ ${fps}fps · GLOBAL coverage ${global.coverage}% · clearer ${global.subject.clearerSide} · verdict ${global.recommendation.toUpperCase()}`,
);
console.log(
` ${z("largest", global.zones.largest)} | ${z("left", global.zones.left)} | ${z("right", global.zones.right)} | ${z("top", global.zones.top)}`,
);
if (global.recommendation === "embed") {
console.log(
`[safe-zones] ✅ EMBED — NARRATION planes go in the clean zones (prefer ${global.subject.clearerSide}/top).`,
);
if (global.heroAnchor)
console.log(
`[safe-zones] 🎯 HERO → centered ON the subject: plane ≈ x${global.heroAnchor.plane.xPct}% y${global.heroAnchor.plane.yPct}% w${global.heroAnchor.plane.wPct}% center · BIG (~0.220.34·h) · target ~3055% occlusion${global.heroAnchor.bandLuma != null ? ` · band luma ${global.heroAnchor.bandLuma}${global.heroAnchor.washoutRisk ? " ⚠WASHOUT RISK — see heroAnchor.note" : ""}` : ""}`,
);
if (global.heroBands)
console.log(
`[safe-zones] hero bands: best top ${global.heroBands.best.topPct}% (predicted occlusion ${global.heroBands.best.occPct}%) · bg-hero ${global.heroBands.feasible ? "FEASIBLE — keep the hero EMBEDDED (fg is last resort)" : "INFEASIBLE (no band ≤62%) → hero fg"}`,
);
if (global.zones.hugLeft || global.zones.hugRight)
console.log(
`[safe-zones] hugging zones (narration belongs HERE, abutting the silhouette): L ${global.zones.hugLeft ? global.zones.hugLeft.wPct + "%×" + global.zones.hugLeft.hPct + "%@x" + global.zones.hugLeft.xPct + "%" + (global.zones.hugLeft.bright ? "⚠bright" : "") : "—"} · R ${global.zones.hugRight ? global.zones.hugRight.wPct + "%×" + global.zones.hugRight.hPct + "%@x" + global.zones.hugRight.xPct + "%" + (global.zones.hugRight.bright ? "⚠bright" : "") : "—"}`,
);
} else {
console.log(
`[safe-zones] ⚠ FG — subject fills the frame; use caption_layer:"fg" (no clean region to embed behind).`,
);
}
if (palette)
console.log(
`[safe-zones] 🎨 palette: dominant ${palette.dominant.map((d) => d.hex).join(" ")} · accent suggestion ${palette.accentSuggestion || "— (no chromatic anchor; use the DNA default)"} · ${palette.temperature}`,
);
if (optics && optics.sharpnessRatio != null)
console.log(
`[safe-zones] 🔭 depth: bg/subject sharpness ${optics.sharpnessRatio} → embed text blur ${optics.suggestedTextBlurPx}px${optics.suggestedTextBlurPx ? " (match the scene's depth-of-field)" : " (scene is uniformly sharp)"}`,
);
if (lighting)
console.log(
`[safe-zones] 💡 light from ${lighting.lightFrom} → contact shadow offset (${lighting.shadow.dx}px, ${lighting.shadow.dy}px)`,
);
if (windows.length) {
console.log(`[safe-zones] per-sentence windows (place each group using ITS window's zones):`);
for (const w of windows)
console.log(
` [${w.in}-${w.out}s] ${w.recommendation.toUpperCase()} cov ${w.coverage}% clear:${w.clearerSide} ${w.zones.largest ? `best ${w.zones.largest.wPct}%×${w.zones.largest.hPct}%@(${w.zones.largest.xPct}%,${w.zones.largest.yPct}%)` : ""} "${w.text}"`,
);
}
console.log(`[safe-zones] → ${path.join(project, "safe-zones.json")}`);
}
main().catch((e) => {
console.error(`[safe-zones] (skipped — ${e.message})`);
process.exit(0);
});
@@ -0,0 +1,332 @@
#!/usr/bin/env node
/*
* transcribe.cjs — word-level transcription via hyperframes' native Whisper
* (replaces the Python ElevenLabs Scribe path; no Python, no API key).
*
* node transcribe.cjs <project-dir> [model] [language]
* Reads: <project>/source.mp4 (audio track)
* Writes: <project>/transcript.json — { text, language_code, words:[{text,start,end,type}] }
*/
const path = require("path");
const fs = require("fs");
const os = require("os");
const cp = require("child_process");
function hfRoot() {
const roots = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "..", "..", ".."),
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
for (const r of roots)
if (fs.existsSync(path.join(r, "packages", "cli", "dist", "cli.js"))) return r;
console.error("[transcribe] hyperframes CLI not found — set HYPERFRAMES_ROOT");
process.exit(3);
}
function ensureSource(project) {
const src = path.join(project, "source.mp4");
if (fs.existsSync(src)) return src;
const EXCL = new Set(["final", "bg_plus_caps", "fg_caps", "audio"]);
let cands = fs
.readdirSync(project)
.filter(
(f) =>
["mp4", "mov", "webm", "mkv", "m4v"].includes(path.extname(f).slice(1).toLowerCase()) &&
!EXCL.has(path.basename(f, path.extname(f))) &&
!f.startsWith("index"),
)
.map((f) => path.join(project, f));
let found = cands.sort((a, b) => fs.statSync(b).size - fs.statSync(a).size)[0];
if (found) {
try {
fs.symlinkSync(path.basename(found), src);
} catch {
fs.copyFileSync(found, src);
}
}
return src;
}
function _usableWords(d) {
return d && Array.isArray(d.words) && d.words.some((w) => w && "start" in w && "end" in w);
}
// Mean loudness of the audio, for the no-speech guard below. Silence → whisper
// hallucinates (famously "Thank you."), and the decision gate refuses "no speech".
function meanVolumeDb(audio) {
try {
// ffmpeg writes volumedetect stats to STDERR — capture it (spawnSync, no throw).
const r = cp.spawnSync(
"ffmpeg",
["-hide_banner", "-nostats", "-i", audio, "-af", "volumedetect", "-f", "null", "-"],
{ encoding: "utf8" },
);
const out = (r.stderr || "") + (r.stdout || "");
const m = out.match(/mean_volume:\s*(-?[\d.]+) dB/);
return m ? parseFloat(m[1]) : null;
} catch {
return null;
}
}
// Where does AUDIBLE content end? Whisper hallucinates trailing words over a silent
// tail (observed: "I'm sorry." repeated over dead air at a clip's end). silencedetect
// finds a terminal silence running to EOF; words "spoken" inside it are fabricated.
// Conservative: applause/music read as non-silence, so this fires only on truly dead
// tails. Returns {speechEnd, total} or null.
function audibleEnd(audio) {
try {
const r = cp.spawnSync(
"ffmpeg",
[
"-hide_banner",
"-nostats",
"-i",
audio,
"-af",
"silencedetect=noise=-35dB:d=0.6",
"-f",
"null",
"-",
],
{ encoding: "utf8" },
);
const out = (r.stderr || "") + (r.stdout || "");
const durM = out.match(/Duration:\s*(\d+):(\d+):([\d.]+)/);
const total = durM ? +durM[1] * 3600 + +durM[2] * 60 + +durM[3] : null;
if (total == null) return null;
const starts = [...out.matchAll(/silence_start:\s*([\d.]+)/g)].map((x) => +x[1]);
const ends = [...out.matchAll(/silence_end:\s*([\d.]+)/g)].map((x) => +x[1]);
if (!starts.length) return { speechEnd: total, total };
const lastStart = starts[starts.length - 1];
const closed = ends.some((e) => e > lastStart); // silence re-broken before EOF?
return { speechEnd: closed ? total : lastStart, total };
} catch {
return null;
}
}
function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: transcribe.cjs <project-dir> [model] [language]");
process.exit(1);
}
// Default = multilingual `small`, NOT `small.en`. Per media-use: ".en models
// mistranslate non-English and mis-handle accented speech; default to small (auto-detects
// language)." We hardcoded small.en before — it hallucinated a wrong transcript on an
// accented speaker. Pass `small.en` only for known-clean-English; tough accents → a larger model.
const model = process.argv[3] || process.env.WHISPER_MODEL || "small";
const language = process.argv[4] || process.env.WHISPER_LANG || "";
const out = path.join(project, "transcript.json");
// already in our schema? skip — but validate the SHAPE, not just the keys:
// `hyperframes init` drops a whisper.cpp segment/token-format transcript.json
// (offsets-in-ms, nested tokens) that can carry a `words` key yet poison the
// compilers. Only a word-level {text,start,end} array counts as normalized.
try {
const d = JSON.parse(fs.readFileSync(out, "utf8"));
const wordShaped =
d &&
Array.isArray(d.words) &&
d.words.length > 0 &&
d.words.every(
(w) =>
w &&
typeof (w.text ?? w.word) === "string" &&
Number.isFinite(w.start) &&
Number.isFinite(w.end) &&
w.end < 36000, // ms-offset formats blow past any sane seconds value
);
if (wordShaped && d.language_code) {
console.log("[transcribe] already normalized, skipping");
return;
}
if (d && !wordShaped) {
console.log(
"[transcribe] existing transcript.json is NOT word-level (init stub / segment format) — regenerating",
);
}
} catch {}
const src = ensureSource(project);
if (!fs.existsSync(src)) {
console.error(`[transcribe] no source in ${project}`);
process.exit(2);
}
const audio = path.join(project, "audio.mp3");
if (!fs.existsSync(audio))
cp.execFileSync(
"ffmpeg",
["-y", "-i", src, "-vn", "-acodec", "libmp3lame", "-q:a", "2", audio],
{ stdio: "ignore" },
);
// ── engine: WhisperX (preferred — wav2vec2 forced alignment gives word timings far
// tighter than whisper.cpp's segment-interpolated ones; our gates are 80ms-strict) →
// fallback hyperframes whisper.cpp. Force with TRANSCRIBE_ENGINE=whisper|whisperx.
let words = null,
engine = null;
const wantWx = (process.env.TRANSCRIBE_ENGINE || "whisperx") === "whisperx";
if (wantWx) {
try {
const wav = path.join(project, "_wx_audio.wav");
cp.execFileSync("ffmpeg", ["-y", "-i", src, "-vn", "-ac", "1", "-ar", "16000", wav], {
stdio: "ignore",
});
const outDir = path.join(project, "_wx_out");
fs.mkdirSync(outDir, { recursive: true });
const wxModel = model.replace(/\.en$/, ""); // whisperx model names are multilingual ids
// Pin whisperx so `uvx` fetches a reproducible build instead of resolving
// "latest" on every run (a supply-chain + determinism foot-gun). Override
// with $WHISPERX_VERSION if you've validated a different release.
const whisperxSpec = `whisperx==${process.env.WHISPERX_VERSION || "3.8.6"}`;
const wxArgs = [
"--python",
"3.12",
"--from",
whisperxSpec,
"whisperx",
wav,
"--model",
wxModel,
"--device",
"cpu",
"--compute_type",
"int8",
"--output_dir",
outDir,
"--output_format",
"json",
"--no_align_deletes",
"--print_progress",
"False",
];
if (language) wxArgs.push("--language", language);
// strip our flag if this whisperx build doesn't know it
let r = cp.spawnSync("uvx", wxArgs, { encoding: "utf8", timeout: 600000 });
if ((r.status || 0) !== 0 && /no_align_deletes/.test(r.stderr || "")) {
r = cp.spawnSync(
"uvx",
wxArgs.filter((a) => a !== "--no_align_deletes"),
{ encoding: "utf8", timeout: 600000 },
);
}
if ((r.status || 0) !== 0)
throw new Error(
(r.stderr || "whisperx failed").split("\n").slice(-4).join(" ").slice(0, 300),
);
const wxJson = JSON.parse(fs.readFileSync(path.join(outDir, "_wx_audio.json"), "utf8"));
const wx = [];
for (const seg of wxJson.segments || [])
for (const w of seg.words || []) {
// alignment occasionally yields a word with no timing (OOV) — interpolate from neighbors later; mark null now
wx.push({ text: String(w.word || "").trim(), start: w.start, end: w.end, type: "word" });
}
// interpolate missing timings from neighbors (rare OOV/number cases)
for (let i = 0; i < wx.length; i++) {
if (wx[i].start == null || wx[i].end == null) {
const prevEnd = i > 0 ? wx[i - 1].end : 0;
const nextStart = wx.slice(i + 1).find((x) => x.start != null);
const ns = nextStart ? nextStart.start : prevEnd + 0.3;
wx[i].start = prevEnd;
wx[i].end = Math.max(prevEnd + 0.05, ns - 0.02);
}
}
if (wx.length) {
words = wx.filter((w) => w.text);
engine = `whisperx(${wxModel}+wav2vec2)`;
}
try {
fs.unlinkSync(wav);
} catch {}
} catch (e) {
console.error(
`[transcribe] whisperx unavailable (${String(e.message || e).slice(0, 160)}) — falling back to whisper.cpp`,
);
}
}
if (!words) {
// run hyperframes Whisper → writes a flat word array to <dir>/transcript.json
const cli = path.join(hfRoot(), "packages", "cli", "dist", "cli.js");
const args = ["transcribe", audio, "-d", project, "--json", "--model", model];
if (language) args.push("--language", language);
let info = {};
try {
const so = cp.execFileSync("node", [cli, ...args], { encoding: "utf8" });
const line = so.trim().split("\n").filter(Boolean).pop();
info = JSON.parse(line);
} catch (e) {
console.error("[transcribe] hyperframes whisper failed:", e.message);
process.exit(1);
}
const flatPath = info.transcriptPath || out;
const flat = JSON.parse(fs.readFileSync(flatPath, "utf8"));
const arr = Array.isArray(flat) ? flat : flat.words || [];
words = arr
.filter((w) => (w.text ?? w.word) != null)
.map((w) => ({
text: w.text ?? w.word,
start: w.start ?? w.t0,
end: w.end ?? w.t1,
type: "word",
}));
engine = `whisper.cpp(${model})`;
}
// Tail-hallucination guard: drop words whisper placed entirely inside a terminal
// silence (it fabricates e.g. repeated "I'm sorry." over dead air). Word START past
// the audible end (+0.4s slack) = fabricated; real final words start before it.
const ae = audibleEnd(audio);
let trimmedTail = 0;
if (ae && ae.speechEnd < ae.total - 0.8) {
const keep = words.filter((w) => w.start <= ae.speechEnd + 0.4);
trimmedTail = words.length - keep.length;
if (trimmedTail > 0) {
console.error(
`[transcribe] ⚠ trimmed ${trimmedTail} trailing word(s) starting after the audible end ` +
`(${ae.speechEnd.toFixed(2)}s; clip ${ae.total.toFixed(2)}s) — whisper hallucinates over silent tails.`,
);
words = keep;
}
}
const text = words
.map((w) => w.text)
.join(" ")
.replace(/\s+([,.!?;:])/g, "$1")
.trim();
fs.writeFileSync(
out,
JSON.stringify(
{
text,
language_code: language || "en",
engine,
words,
...(trimmedTail ? { trimmed_tail_words: trimmedTail } : {}),
},
null,
2,
),
);
console.log(`[transcribe] ${engine} ${words.length} words → ${out}`);
console.log(`[transcribe] text: ${text.slice(0, 160)}${text.length > 160 ? "…" : ""}`);
// No-speech guard: whisper returns confident hallucinations over silence (e.g. the
// whole clip as "Thank you."). The decision gate REFUSES "no speech" — operationalize
// it so an agent trusting the transcript can't sail past the gate.
const meanDb = meanVolumeDb(audio);
if (meanDb != null && meanDb < -45) {
console.error(
`\n[transcribe] ⚠ NEAR-SILENT AUDIO — mean ${meanDb.toFixed(1)} dB (real speech ≈ -16..-26 dB).`,
);
console.error(
` This transcript is almost certainly a Whisper hallucination, NOT real speech.`,
);
console.error(
` Per the decision gate, REFUSE "no speech" — confirm with \`ffmpeg -i <src> -af silencedetect\`;`,
);
console.error(` do NOT author captions from fabricated words.`);
}
}
main();