Files
heygen-com--hyperframes/skills/embedded-captions/scripts/safe-zones.cjs
T
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

814 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/*
* safe-zones.cjs — PRECOMPUTE where captions can safely go, from the subject matte.
*
* node safe-zones.cjs <project-dir> → safe-zones.json (global + per-sentence windows) + summary
* node safe-zones.cjs <project-dir> <in> <out> → just that time window's zones (ad-hoc query)
*
* The inverse of check-occlusion: read the silhouette FIRST and hand the author the
* clean regions + an embed-vs-fg verdict, so layout is right the first time instead of
* after N occlusion-failure rounds.
*
* Uses the per-pixel ALPHA matte (not a bounding box): a cell counts as subject only
* where the silhouette actually is, so the empty pocket beside the head / above the
* shoulders stays FREE (a bbox would wrongly claim it). The subject MOVES, so zones are
* computed PER TIME WINDOW (the union over just that window's frames), not one global box.
*/
const path = require("path");
const fs = require("fs");
const os = require("os");
const THRESH = 30 / 255; // a cell is "subject" if ≥12% covered at any sampled frame in the window
const SAMPLES = 48; // frames cached across the clip (windows aggregate the cached grids)
const HF_ROOTS = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "../../.."),
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
let sharp = null;
for (const root of HF_ROOTS) {
const cands = [path.join(root, "node_modules", "sharp")];
const bunDir = path.join(root, "node_modules", ".bun");
try {
if (fs.existsSync(bunDir))
for (const d of fs.readdirSync(bunDir))
if (d.startsWith("sharp@")) cands.push(path.join(bunDir, d, "node_modules", "sharp"));
} catch {}
for (const c of cands) {
try {
if (fs.existsSync(c)) {
sharp = require(c);
break;
}
} catch {}
}
if (sharp) break;
}
// largest all-clear (1) rectangle within [c0,c1)×[r0,r1), in CELL units
function largestRect(safe, GW, c0, c1, r0, r1) {
const cols = c1 - c0;
const heights = new Array(cols).fill(0);
let best = { area: 0, x: 0, y: 0, w: 0, h: 0 };
for (let r = r0; r < r1; r++) {
for (let cc = 0; cc < cols; cc++) heights[cc] = safe[r * GW + (c0 + cc)] ? heights[cc] + 1 : 0;
const st = [];
for (let cc = 0; cc <= cols; cc++) {
const h = cc < cols ? heights[cc] : 0;
let start = cc;
while (st.length && st[st.length - 1].h >= h) {
const top = st.pop();
const area = top.h * (cc - top.i);
if (area > best.area)
best = { area, x: c0 + top.i, y: r - top.h + 1, w: cc - top.i, h: top.h };
start = top.i;
}
st.push({ i: start, h });
}
}
return best;
}
// turn a max-coverage grid into {coverage, subject, zones, recommendation}
function analyze(occ, GW, GH, W, H, lum, lumSeries) {
const occCell = new Uint8Array(GW * GH);
for (let c = 0; c < GW * GH; c++) occCell[c] = occ[c] >= THRESH ? 1 : 0;
const safe = new Uint8Array(GW * GH); // 1-cell dilation margin around the silhouette
for (let y = 0; y < GH; y++)
for (let x = 0; x < GW; x++) {
let o = 0;
for (let dy = -1; dy <= 1 && !o; dy++)
for (let dx = -1; dx <= 1 && !o; dx++) {
const nx = x + dx,
ny = y + dy;
if (nx >= 0 && nx < GW && ny >= 0 && ny < GH && occCell[ny * GW + nx]) o = 1;
}
safe[y * GW + x] = o ? 0 : 1;
}
const occupied = occCell.reduce((a, b) => a + b, 0);
const coverage = occupied / (GW * GH);
let colMin = GW,
colMax = -1,
rowMin = GH,
rowMax = -1;
for (let x = 0; x < GW; x++)
for (let y = 0; y < GH; y++)
if (occCell[y * GW + x]) {
if (x < colMin) colMin = x;
if (x > colMax) colMax = x;
if (y < rowMin) rowMin = y;
if (y > rowMax) rowMax = y;
}
if (colMax < 0) {
colMin = 0;
colMax = -1;
rowMin = 0;
rowMax = -1;
}
const clearerSide = colMin >= GW - 1 - colMax ? "left" : "right";
const cellW = W / GW,
cellH = H / GH;
// HERO anchor — where the ONE big promoted word should sit: ON the subject (centered,
// crossing it so the head/torso occludes the middle). The OPPOSITE of the clean zones
// above (those are for narration). A wide band ≈ centered on the subject, vertically
// crossing the head/upper torso. Only meaningful when there IS a subject.
let heroAnchor = null;
if (colMax >= 0) {
const subjCx = ((colMin + colMax + 1) / 2 / GW) * 100;
const subjWpct = ((colMax - colMin + 1) / GW) * 100;
const yTop = (rowMin / GH) * 100,
yBot = ((rowMax + 1) / GH) * 100;
const wPct = Math.round(Math.min(92, Math.max(58, subjWpct + 26)));
const xPct = Math.round(Math.min(98 - wPct, Math.max(2, subjCx - wPct / 2)));
const yPct = Math.round(yTop + (yBot - yTop) * 0.12); // band crosses the head / upper torso
let bandLuma = null;
if (lum) {
const y0 = Math.round((yPct / 100) * GH),
y1 = Math.min(GH, y0 + Math.max(2, Math.round(GH * 0.14)));
const x0 = Math.round((xPct / 100) * GW),
x1 = Math.min(GW, Math.round(((xPct + wPct) / 100) * GW));
// luma of the BACKGROUND cells only — that's where the hero's glyphs are visible
// (the subject-occluded middle doesn't show text; averaging it in hides washout).
let s2 = 0,
n = 0;
for (let y = y0; y < y1; y++)
for (let x = x0; x < x1; x++) {
if (occCell[y * GW + x]) continue;
s2 += lum[y * GW + x];
n++;
}
bandLuma = n ? Math.round(s2 / n) : null;
}
heroAnchor = {
centerXPct: +subjCx.toFixed(1),
plane: { xPct, yPct, wPct, align: "center" },
...(bandLuma != null ? { bandLuma, washoutRisk: bandLuma > 175 } : {}),
note:
"Place the ONE big hero here (centered on the subject); the head/torso occludes its middle (~30-55%) — that is the embed. Do NOT put the hero in a clean zone." +
(bandLuma != null && bandLuma > 175
? " ⚠ BAND IS BRIGHT (luma " +
bandLuma +
"): cream/screen text will wash out — lower the hero onto the darker subject body, or use a template/mode with opaque text."
: ""),
};
}
const zoneLuma = (r) => {
if (!lum || r.area === 0) return null;
let s2 = 0,
n = 0;
for (let y = r.y; y < r.y + r.h; y++)
for (let x = r.x; x < r.x + r.w; x++) {
s2 += lum[y * GW + x];
n++;
}
return n ? Math.round(s2 / n) : null;
};
// a TIME-AVERAGED map walks a moving minefield: a dark wall swept by a bright
// moving object (handheld drift, screens) averages "clean" while peaking hot.
// peakLuma = p95 of the zone's per-sample mean over the window.
const zoneLumaPeak = (r) => {
if (!lumSeries || !lumSeries.length || r.area === 0) return null;
const means = lumSeries
.map((Lg) => {
let s2 = 0,
n = 0;
for (let y = r.y; y < r.y + r.h; y++)
for (let x = r.x; x < r.x + r.w; x++) {
s2 += Lg[y * GW + x];
n++;
}
return n ? s2 / n : 0;
})
.sort((a, b) => a - b);
return Math.round(means[Math.max(0, Math.ceil(means.length * 0.95) - 1)]);
};
const toZone = (r) =>
r.area === 0
? null
: {
xPct: +((r.x / GW) * 100).toFixed(1),
yPct: +((r.y / GH) * 100).toFixed(1),
wPct: +((r.w / GW) * 100).toFixed(1),
hPct: +((r.h / GH) * 100).toFixed(1),
areaPct: +(((r.w * r.h) / (GW * GH)) * 100).toFixed(1),
px: {
x: Math.round(r.x * cellW),
y: Math.round(r.y * cellH),
w: Math.round(r.w * cellW),
h: Math.round(r.h * cellH),
},
...(zoneLuma(r) != null
? {
meanLuma: zoneLuma(r),
bright: zoneLuma(r) > 180,
...(zoneLumaPeak(r) != null ? { peakLuma: zoneLumaPeak(r) } : {}),
}
: {}),
};
const zones = {
largest: toZone(largestRect(safe, GW, 0, GW, 0, GH)),
left: toZone(largestRect(safe, GW, 0, Math.round(GW / 2), 0, GH)),
right: toZone(largestRect(safe, GW, Math.round(GW / 2), GW, 0, GH)),
top: toZone(largestRect(safe, GW, 0, GW, 0, Math.max(2, Math.round(GH * 0.38)))),
};
// HUGGING zones — clean strips that ABUT the silhouette (the embed aesthetic wants
// text NEAR the subject, not parked in the farthest corner). Grown outward from the
// subject's edge at upper-body height; prefer these for narration.
const hug = (side) => {
if (colMax < 0) return null;
const pad = Math.max(1, Math.round(GW * 0.02));
const y0 = rowMin,
y1 = Math.min(GH, rowMin + Math.max(3, Math.round((rowMax - rowMin + 1) * 0.45)));
let x0, x1;
if (side === "right") {
x0 = Math.min(GW - 1, colMax + 1 + pad);
x1 = GW - pad;
} else {
x1 = Math.max(1, colMin - pad);
x0 = pad;
}
if (x1 - x0 < Math.round(GW * 0.1)) return null;
// shrink until actually clean (≤8% occupied cells)
let occN = 0,
tot = 0;
for (let y = y0; y < y1; y++)
for (let x = x0; x < x1; x++) {
tot++;
if (occCell[y * GW + x]) occN++;
}
if (tot === 0 || occN / tot > 0.08) return null;
const r = { x: x0, y: y0, w: x1 - x0, h: y1 - y0, area: (x1 - x0) * (y1 - y0) };
const z2 = toZone(r);
// GLYPHS must hug, not just the plane: in a wide column, text aligned to the far
// edge parks the words a third of the frame away from the subject. Align TOWARD
// the silhouette: right-side column → text-align:left (text starts beside the
// subject); left-side column → text-align:right.
if (z2) z2.align = side === "right" ? "left" : "right";
return z2;
};
zones.hugLeft = hug("left");
zones.hugRight = hug("right");
// HERO BAND PROFILE — per-height predicted occlusion of a centered hero band. The hero
// WANTS ~3055% (occlusion IS the embed); fg is the LAST resort, only when no height
// achieves ≤62%. Even an 88%-coverage frame usually has a feasible band over the hairline.
let heroBands = null;
if (colMax >= 0) {
const bandH = Math.max(2, Math.round(GH * 0.13));
const hx0 = Math.round(((heroAnchor ? heroAnchor.plane.xPct : 8) / 100) * GW);
const hx1 = Math.min(
GW,
Math.round(((heroAnchor ? heroAnchor.plane.xPct + heroAnchor.plane.wPct : 92) / 100) * GW),
);
const profile = [];
for (let y0 = 0; y0 + bandH <= GH; y0 += Math.max(1, Math.round(GH * 0.02))) {
let n = 0,
occN = 0,
lsum = 0;
for (let y = y0; y < y0 + bandH; y++)
for (let x = hx0; x < hx1; x++) {
n++;
if (occCell[y * GW + x]) occN++;
if (lum) lsum += lum[y * GW + x];
}
profile.push({
topPct: +((y0 / GH) * 100).toFixed(1),
occPct: +((occN / n) * 100).toFixed(1),
...(lum ? { bgLuma: Math.round(lsum / n) } : {}),
});
}
const ok = profile.filter((b) => b.occPct >= 12 && b.occPct <= 62);
const best = (ok.length ? ok : profile).reduce((a, b) =>
Math.abs(b.occPct - 40) < Math.abs(a.occPct - 40) ? b : a,
);
heroBands = { feasible: ok.length > 0, best, profile };
}
const big = zones.largest;
const embeddable = !!big && big.areaPct >= 8 && big.hPct >= 10 && big.wPct >= 18;
return {
coverage: +(coverage * 100).toFixed(1),
subject: {
colMinPct: +((colMin / GW) * 100).toFixed(1),
colMaxPct: +(((colMax + 1) / GW) * 100).toFixed(1),
clearerSide,
},
zones,
heroAnchor,
heroBands,
recommendation: embeddable ? "embed" : "fg",
};
}
// ── SCENE OPTICS + PALETTE (v2) ──────────────────────────────────────────────
// Deterministic scene measurements that drive the DNA tokens, so "design that fits
// the scene" is a pipeline product, not agent inspiration:
// palette — dominant scene colors + a READABLE accent suggestion (sampled, then
// clamped to usable saturation/lightness) + warm/cool temperature
// optics — background vs subject sharpness (Laplacian proxy) → suggested text
// blur so embed type matches the scene's depth-of-field
// lighting — bright-side estimate → contact-shadow direction for embed type
function rgb2hsv(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const mx = Math.max(r, g, b),
mn = Math.min(r, g, b),
d = mx - mn;
let h = 0;
if (d > 0) {
if (mx === r) h = ((g - b) / d) % 6;
else if (mx === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
return { h, s: mx === 0 ? 0 : d / mx, v: mx };
}
function hsv2hex(h, s, v) {
const c = v * s,
x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
m = v - c;
let [r, g, b] =
h < 60
? [c, x, 0]
: h < 120
? [x, c, 0]
: h < 180
? [0, c, x]
: h < 240
? [0, x, c]
: h < 300
? [x, 0, c]
: [c, 0, x];
const f = (n) =>
Math.round((n + m) * 255)
.toString(16)
.padStart(2, "0");
return `#${f(r)}${f(g)}${f(b)}`;
}
// dominant colors + accent suggestion from the BACKGROUND cells of a mid frame
async function scenePalette(bgPath, occCell, GW, GH) {
const { data, info } = await sharp(bgPath)
.resize(GW, GH, { fit: "fill" })
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const ch = info.channels;
const cells = [];
for (let c = 0; c < GW * GH; c++) {
if (occCell && occCell[c]) continue; // background only
cells.push([data[c * ch], data[c * ch + 1], data[c * ch + 2]]);
}
if (!cells.length) return null;
// dominant: quantize to 3 bits/channel, top buckets by count
const buckets = new Map();
for (const [r, g, b] of cells) {
const k = ((r >> 5) << 6) | ((g >> 5) << 3) | (b >> 5);
const e = buckets.get(k) || { n: 0, r: 0, g: 0, b: 0 };
e.n++;
e.r += r;
e.g += g;
e.b += b;
buckets.set(k, e);
}
const hex = (e) =>
"#" +
[e.r, e.g, e.b]
.map((x) =>
Math.round(x / e.n)
.toString(16)
.padStart(2, "0"),
)
.join("");
const dominant = [...buckets.values()]
.sort((a, b) => b.n - a.n)
.slice(0, 3)
.map((e) => ({ hex: hex(e), sharePct: +((e.n / cells.length) * 100).toFixed(1) }));
// chromatic accent: hue histogram over saturated cells, weighted s·v
const bins = Array.from({ length: 12 }, () => ({ w: 0, h: 0, s: 0, v: 0, n: 0 }));
let warmW = 0,
coolW = 0;
for (const [r, g, b] of cells) {
const { h, s, v } = rgb2hsv(r, g, b);
if (h <= 90 || h >= 330) warmW += s * v;
else if (h >= 150 && h <= 300) coolW += s * v;
if (s < 0.18 || v < 0.12 || v > 0.97) continue;
const bi = Math.floor(h / 30) % 12,
w = s * v;
const B = bins[bi];
B.w += w;
B.h += h * w;
B.s += s * w;
B.v += v * w;
B.n++;
}
const top = bins.reduce((a, b) => (b.w > a.w ? b : a));
let accent = null;
if (top.w > 0.5 && top.n >= 3) {
const h = top.h / top.w,
s = top.s / top.w,
v = top.v / top.w;
// clamp to a readable accent: saturated enough to read as a choice, light enough to sit on video
accent = hsv2hex(
h,
Math.min(0.78, Math.max(0.5, s * 1.2)),
Math.min(0.8, Math.max(0.55, v * 1.15)),
);
}
const temperature = warmW > coolW * 1.25 ? "warm" : coolW > warmW * 1.25 ? "cool" : "neutral";
return { dominant, accentSuggestion: accent, temperature };
}
// Laplacian-stdev sharpness proxy of a region crop (full-res)
async function regionSharpness(imgPath, rect, W, H) {
const x = Math.max(0, Math.min(W - 2, Math.round(rect.x))),
y = Math.max(0, Math.min(H - 2, Math.round(rect.y)));
const w = Math.max(2, Math.min(W - x, Math.round(rect.w))),
h = Math.max(2, Math.min(H - y, Math.round(rect.h)));
// two passes: crop to a buffer FIRST, then convolve+stats on the crop — sharp's
// internal pipeline ordering otherwise convolves/stats the full frame and the two
// regions measure identical.
const crop = await sharp(imgPath)
.extract({ left: x, top: y, width: w, height: h })
.png()
.toBuffer();
const st = await sharp(crop)
.greyscale()
.convolve({ width: 3, height: 3, kernel: [0, 1, 0, 1, -4, 1, 0, 1, 0], scale: 1, offset: 128 })
.stats();
return st.channels[0].stdev;
}
async function sceneOptics(project, bgPath, fgPath, zones, subjectBox, W, H) {
let bgSharp = null,
subjSharp = null;
const bigZone = zones && zones.largest && zones.largest.px;
try {
if (bigZone && bigZone.w >= 64 && bigZone.h >= 64)
bgSharp = await regionSharpness(bgPath, bigZone, W, H);
} catch {}
try {
if (subjectBox && subjectBox.w >= 64)
subjSharp = await regionSharpness(bgPath, subjectBox, W, H);
} catch {}
let suggestedTextBlurPx = 0,
ratio = null;
if (bgSharp != null && subjSharp != null && subjSharp > 1) {
ratio = +(bgSharp / subjSharp).toFixed(3);
// strong bokeh → text in that depth plane should soften to match
suggestedTextBlurPx = ratio < 0.35 ? 1.6 : ratio < 0.55 ? 1.0 : ratio < 0.8 ? 0.5 : 0;
}
return {
bgSharpness: bgSharp != null ? +bgSharp.toFixed(2) : null,
subjSharpness: subjSharp != null ? +subjSharp.toFixed(2) : null,
sharpnessRatio: ratio,
suggestedTextBlurPx,
};
}
// bright-side estimate from the luminance grid → contact-shadow vector (shadow falls AWAY from light)
function sceneLighting(lum, occCell, GW, GH) {
if (!lum) return null;
let sw = 0,
sx = 0,
sy = 0,
n = 0,
mean = 0;
for (let c = 0; c < GW * GH; c++) {
if (!occCell[c]) {
mean += lum[c];
n++;
}
}
if (!n) return null;
mean /= n;
for (let y = 0; y < GH; y++)
for (let x = 0; x < GW; x++) {
const c = y * GW + x;
if (occCell[c]) continue;
const w = Math.max(0, lum[c] - mean);
sw += w;
sx += w * (x / GW - 0.5);
sy += w * (y / GH - 0.5);
}
if (sw < 1) return { lightFrom: "flat", shadow: { dx: 0, dy: 3 } };
const lx = sx / sw,
ly = sy / sw; // light centroid offset from center, 0.5..0.5
const mag = Math.hypot(lx, ly);
if (mag < 0.04) return { lightFrom: "frontal", shadow: { dx: 0, dy: 3 } };
// shadow direction = opposite the light, scaled to a subtle px offset
const s = Math.min(1, mag / 0.25);
const dx = Math.round((-lx / mag) * 4 * s),
dy = Math.round(Math.max(1, (-ly / mag) * 4 * s + 2));
const compass =
Math.abs(lx) > Math.abs(ly) * 1.8
? lx > 0
? "right"
: "left"
: Math.abs(ly) > Math.abs(lx) * 1.8
? ly > 0
? "below"
: "above"
: `${ly > 0 ? "lower" : "upper"}-${lx > 0 ? "right" : "left"}`;
return { lightFrom: compass, shadow: { dx, dy } };
}
// split the transcript into sentence windows (punctuation, or a > 0.7s gap)
function sentenceWindows(project) {
const tp = path.join(project, "transcript.json");
if (!fs.existsSync(tp)) return [];
let words;
try {
words = (JSON.parse(fs.readFileSync(tp, "utf8")).words || []).filter((w) => w && "start" in w);
} catch {
return [];
}
const out = [];
let cur = [];
for (let i = 0; i < words.length; i++) {
cur.push(words[i]);
const w = words[i],
nx = words[i + 1];
const ends = /[.!?…]$/.test((w.text || "").trim());
const gap = nx ? nx.start - w.end > 0.7 : true;
if (ends || gap || !nx) {
if (cur.length)
out.push({
in: +cur[0].start.toFixed(2),
out: +cur[cur.length - 1].end.toFixed(2),
text: cur.map((x) => x.text).join(" "),
});
cur = [];
}
}
return out;
}
async function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: safe-zones.cjs <project-dir> [in out]");
process.exit(1);
}
const fgDir = path.join(project, "frames_fg");
if (!fs.existsSync(fgDir)) {
console.error(`[safe-zones] no ${fgDir} — run matte.cjs first`);
process.exit(2);
}
if (!sharp) {
console.error("[safe-zones] sharp unavailable — set HYPERFRAMES_ROOT");
process.exit(0);
}
const frames = fs
.readdirSync(fgDir)
.filter((f) => /\.png$/i.test(f))
.sort();
if (!frames.length) {
console.error("[safe-zones] no PNG frames");
process.exit(2);
}
const meta = await sharp(path.join(fgDir, frames[0])).metadata();
const W = meta.width,
H = meta.height;
const CELL = Math.max(W, H) / 48;
const GW = Math.max(8, Math.round(W / CELL)),
GH = Math.max(8, Math.round(H / CELL));
let fps = 24;
try {
const f = parseFloat(
String(fs.readFileSync(path.join(project, "matte.fps"), "utf8")).replace(/[^\d.]/g, ""),
);
if (f > 0) fps = f;
} catch {}
const bgDir = path.join(project, "frames_bg");
const hasBg = fs.existsSync(bgDir);
// cache evenly-sampled frame grids once (each = per-cell avg subject alpha 0..1,
// plus per-cell mean LUMINANCE from frames_bg — bright zones wash out cream/screen text)
const sampleIdx = [
...new Set(
Array.from({ length: SAMPLES }, (_, i) =>
Math.min(frames.length - 1, Math.round((i / (SAMPLES - 1)) * (frames.length - 1))),
),
),
];
const grids = [];
for (const i of sampleIdx) {
const { data, info } = await sharp(path.join(fgDir, frames[i]))
.resize(GW, GH, { fit: "fill" })
.raw()
.toBuffer({ resolveWithObject: true });
const ch = info.channels,
g = new Float32Array(GW * GH);
for (let c = 0; c < GW * GH; c++) g[c] = (ch >= 4 ? data[c * ch + 3] : 255) / 255;
let lum = null;
if (hasBg && fs.existsSync(path.join(bgDir, frames[i]))) {
const { data: bd, info: bi } = await sharp(path.join(bgDir, frames[i]))
.resize(GW, GH, { fit: "fill" })
.greyscale()
.raw()
.toBuffer({ resolveWithObject: true });
const bch = bi.channels;
lum = new Float32Array(GW * GH);
for (let c = 0; c < GW * GH; c++) lum[c] = bd[c * bch];
}
grids.push({ t: i / fps, g, lum });
}
const lumWindow = (t0, t1) => {
const acc = new Float32Array(GW * GH);
let n = 0;
let inWin = grids.filter((s2) => s2.t >= t0 - 1e-6 && s2.t <= t1 + 1e-6 && s2.lum);
if (!inWin.length) inWin = grids.filter((s2) => s2.lum);
for (const s2 of inWin) {
for (let c = 0; c < GW * GH; c++) acc[c] += s2.lum[c];
n++;
}
if (!n) return null;
for (let c = 0; c < GW * GH; c++) acc[c] /= n;
return acc;
};
const lumSeriesWindow = (t0, t1) => {
let inWin = grids.filter((s2) => s2.t >= t0 - 1e-6 && s2.t <= t1 + 1e-6 && s2.lum);
if (!inWin.length) inWin = grids.filter((s2) => s2.lum);
return inWin.map((s2) => s2.lum);
};
const occWindow = (t0, t1) => {
const occ = new Float32Array(GW * GH);
let inWin = grids.filter((s) => s.t >= t0 - 1e-6 && s.t <= t1 + 1e-6);
if (!inWin.length) {
// window between samples → use nearest grid
const mid = (t0 + t1) / 2;
inWin = [grids.reduce((a, b) => (Math.abs(b.t - mid) < Math.abs(a.t - mid) ? b : a))];
}
for (const s of inWin) for (let c = 0; c < GW * GH; c++) if (s.g[c] > occ[c]) occ[c] = s.g[c];
return occ;
};
// ad-hoc window query
const qIn = parseFloat(process.argv[3]),
qOut = parseFloat(process.argv[4]);
if (Number.isFinite(qIn) && Number.isFinite(qOut)) {
const a = analyze(
occWindow(qIn, qOut),
GW,
GH,
W,
H,
lumWindow(qIn, qOut),
lumSeriesWindow(qIn, qOut),
);
console.log(
`[safe-zones] window ${qIn}-${qOut}s: ${a.recommendation.toUpperCase()} coverage ${a.coverage}% clearer:${a.subject.clearerSide}`,
);
const z = a.zones;
for (const k of ["largest", "left", "right", "top"])
if (z[k]) console.log(` ${k}: ${z[k].wPct}%×${z[k].hPct}% @ (${z[k].xPct}%,${z[k].yPct}%)`);
console.log(JSON.stringify({ in: qIn, out: qOut, ...a }));
return;
}
const globalOcc = occWindow(-1e9, 1e9);
const globalLum = lumWindow(-1e9, 1e9);
const global = analyze(globalOcc, GW, GH, W, H, globalLum, lumSeriesWindow(-1e9, 1e9));
const windows = sentenceWindows(project).map((s) => {
const a = analyze(
occWindow(s.in, s.out),
GW,
GH,
W,
H,
lumWindow(s.in, s.out),
lumSeriesWindow(s.in, s.out),
);
return {
in: s.in,
out: s.out,
text: s.text.slice(0, 48),
coverage: a.coverage,
recommendation: a.recommendation,
clearerSide: a.subject.clearerSide,
zones: a.zones,
};
});
// ── v2: palette / optics / lighting from the mid frame + global grids ───────
let palette = null,
optics = null,
lighting = null;
try {
const occCellG = new Uint8Array(GW * GH);
for (let c = 0; c < GW * GH; c++) occCellG[c] = globalOcc[c] >= THRESH ? 1 : 0;
const midName = frames[Math.floor(frames.length / 2)];
const midBg = path.join(bgDir, midName);
if (hasBg && fs.existsSync(midBg)) {
palette = await scenePalette(midBg, occCellG, GW, GH);
// subject bbox in px from the global occupancy grid
let cx0 = GW,
cx1 = -1,
cy0 = GH,
cy1 = -1;
for (let y = 0; y < GH; y++)
for (let x = 0; x < GW; x++)
if (occCellG[y * GW + x]) {
if (x < cx0) cx0 = x;
if (x > cx1) cx1 = x;
if (y < cy0) cy0 = y;
if (y > cy1) cy1 = y;
}
const subjectBox =
cx1 >= 0
? {
x: (cx0 / GW) * W,
y: (cy0 / GH) * H,
w: ((cx1 - cx0 + 1) / GW) * W,
h: ((cy1 - cy0 + 1) / GH) * H,
}
: null;
optics = await sceneOptics(
project,
midBg,
path.join(fgDir, midName),
global.zones,
subjectBox,
W,
H,
);
lighting = sceneLighting(globalLum, occCellG, GW, GH);
}
} catch (e) {
console.error(`[safe-zones] scene optics skipped — ${e.message}`);
}
const out = {
width: W,
height: H,
fps,
grid: { cols: GW, rows: GH },
...global,
palette,
optics,
lighting,
windows,
};
fs.writeFileSync(path.join(project, "safe-zones.json"), JSON.stringify(out, null, 2));
const z = (n, zn) =>
zn
? `${n}: ${zn.wPct}%×${zn.hPct}% @ (${zn.xPct}%,${zn.yPct}%) [${zn.areaPct}%${zn.meanLuma != null ? ` · luma ${zn.meanLuma}${zn.bright ? " ⚠BRIGHT" : ""}` : ""}]`
: `${n}: —`;
console.log(
`[safe-zones] ${W}×${H} grid ${GW}×${GH} @ ${fps}fps · GLOBAL coverage ${global.coverage}% · clearer ${global.subject.clearerSide} · verdict ${global.recommendation.toUpperCase()}`,
);
console.log(
` ${z("largest", global.zones.largest)} | ${z("left", global.zones.left)} | ${z("right", global.zones.right)} | ${z("top", global.zones.top)}`,
);
if (global.recommendation === "embed") {
console.log(
`[safe-zones] ✅ EMBED — NARRATION planes go in the clean zones (prefer ${global.subject.clearerSide}/top).`,
);
if (global.heroAnchor)
console.log(
`[safe-zones] 🎯 HERO → centered ON the subject: plane ≈ x${global.heroAnchor.plane.xPct}% y${global.heroAnchor.plane.yPct}% w${global.heroAnchor.plane.wPct}% center · BIG (~0.220.34·h) · target ~3055% occlusion${global.heroAnchor.bandLuma != null ? ` · band luma ${global.heroAnchor.bandLuma}${global.heroAnchor.washoutRisk ? " ⚠WASHOUT RISK — see heroAnchor.note" : ""}` : ""}`,
);
if (global.heroBands)
console.log(
`[safe-zones] hero bands: best top ${global.heroBands.best.topPct}% (predicted occlusion ${global.heroBands.best.occPct}%) · bg-hero ${global.heroBands.feasible ? "FEASIBLE — keep the hero EMBEDDED (fg is last resort)" : "INFEASIBLE (no band ≤62%) → hero fg"}`,
);
if (global.zones.hugLeft || global.zones.hugRight)
console.log(
`[safe-zones] hugging zones (narration belongs HERE, abutting the silhouette): L ${global.zones.hugLeft ? global.zones.hugLeft.wPct + "%×" + global.zones.hugLeft.hPct + "%@x" + global.zones.hugLeft.xPct + "%" + (global.zones.hugLeft.bright ? "⚠bright" : "") : "—"} · R ${global.zones.hugRight ? global.zones.hugRight.wPct + "%×" + global.zones.hugRight.hPct + "%@x" + global.zones.hugRight.xPct + "%" + (global.zones.hugRight.bright ? "⚠bright" : "") : "—"}`,
);
} else {
console.log(
`[safe-zones] ⚠ FG — subject fills the frame; use caption_layer:"fg" (no clean region to embed behind).`,
);
}
if (palette)
console.log(
`[safe-zones] 🎨 palette: dominant ${palette.dominant.map((d) => d.hex).join(" ")} · accent suggestion ${palette.accentSuggestion || "— (no chromatic anchor; use the DNA default)"} · ${palette.temperature}`,
);
if (optics && optics.sharpnessRatio != null)
console.log(
`[safe-zones] 🔭 depth: bg/subject sharpness ${optics.sharpnessRatio} → embed text blur ${optics.suggestedTextBlurPx}px${optics.suggestedTextBlurPx ? " (match the scene's depth-of-field)" : " (scene is uniformly sharp)"}`,
);
if (lighting)
console.log(
`[safe-zones] 💡 light from ${lighting.lightFrom} → contact shadow offset (${lighting.shadow.dx}px, ${lighting.shadow.dy}px)`,
);
if (windows.length) {
console.log(`[safe-zones] per-sentence windows (place each group using ITS window's zones):`);
for (const w of windows)
console.log(
` [${w.in}-${w.out}s] ${w.recommendation.toUpperCase()} cov ${w.coverage}% clear:${w.clearerSide} ${w.zones.largest ? `best ${w.zones.largest.wPct}%×${w.zones.largest.hPct}%@(${w.zones.largest.xPct}%,${w.zones.largest.yPct}%)` : ""} "${w.text}"`,
);
}
console.log(`[safe-zones] → ${path.join(project, "safe-zones.json")}`);
}
main().catch((e) => {
console.error(`[safe-zones] (skipped — ${e.message})`);
process.exit(0);
});