项目文件夹

文件
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 行
30 KiB
JavaScript

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