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
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:
@@ -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 (~30–55%), 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 (~30–55% 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
|
||||
// (~30–55%). 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 30–55% 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 (~30–55% 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
return e
|
||||
.replace(/<br>/g, "<br>")
|
||||
.replace(/<br\/>/g, "<br>")
|
||||
.replace(/<br \/>/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
@@ -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
@@ -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 15–30s 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 ~30–55% (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.22–0.34·h) · target ~30–55% 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();
|
||||
Reference in New Issue
Block a user