816 lines
30 KiB
JavaScript
816 lines
30 KiB
JavaScript
#!/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.argv[2] && path.resolve(process.argv[2]), // project-owned helper dependencies
|
|||
|
|
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 — install project dependencies per embedded-captions SKILL.md Runtime prerequisites",
|
|||
|
|
);
|
|||
|
|
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);
|
|||
|
|
});
|