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
251 lines
10 KiB
JavaScript
251 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
||
/* check-occlusion.cjs — pixel-perfect occlusion gate (Node port of check-occlusion-v2.py).
|
||
* Runs measure-layout.cjs (real Chromium DOM rects), reads the subject-matte alpha via sharp,
|
||
* computes per-word / per-cap occlusion. No Python.
|
||
* node check-occlusion.cjs <project-dir> [--strict] [--word-fail F] [--word-warn F] [--cap-fail F] [--remeasure]
|
||
*/
|
||
const path = require("path");
|
||
const fs = require("fs");
|
||
const os = require("os");
|
||
const cp = require("child_process");
|
||
|
||
function hfResolve(pkg) {
|
||
const roots = [
|
||
process.env.HYPERFRAMES_ROOT,
|
||
path.resolve(__dirname, "..", "..", ".."),
|
||
path.join(os.homedir(), "Downloads", "hyperframes"),
|
||
].filter(Boolean);
|
||
for (const root of roots) {
|
||
const cands = [path.join(root, "node_modules", pkg)];
|
||
const bun = path.join(root, "node_modules", ".bun");
|
||
try {
|
||
if (fs.existsSync(bun))
|
||
for (const d of fs.readdirSync(bun))
|
||
if (d.startsWith(pkg + "@")) cands.push(path.join(bun, d, "node_modules", pkg));
|
||
} catch {}
|
||
for (const c of cands) {
|
||
try {
|
||
if (fs.existsSync(c)) return require(c);
|
||
} catch {}
|
||
}
|
||
}
|
||
console.error(`[v2] cannot find ${pkg} — set HYPERFRAMES_ROOT`);
|
||
process.exit(3);
|
||
}
|
||
const sharp = hfResolve("sharp");
|
||
|
||
function ensureLayoutMeasured(project, force) {
|
||
const lp = path.join(project, "_layout.json"),
|
||
idx = path.join(project, "index.html");
|
||
let stale = force || !fs.existsSync(lp);
|
||
if (!stale && fs.existsSync(idx) && fs.statSync(idx).mtimeMs > fs.statSync(lp).mtimeMs)
|
||
stale = true;
|
||
if (stale)
|
||
cp.execFileSync("node", [path.join(__dirname, "measure-layout.cjs"), project], {
|
||
stdio: "inherit",
|
||
});
|
||
return JSON.parse(fs.readFileSync(lp, "utf8"));
|
||
}
|
||
async function loadAlphaMask(png) {
|
||
if (!fs.existsSync(png)) return null;
|
||
const { data, info } = await sharp(png)
|
||
.ensureAlpha()
|
||
.extractChannel(3)
|
||
.raw()
|
||
.toBuffer({ resolveWithObject: true });
|
||
return { data, W: info.width, H: info.height };
|
||
}
|
||
function occlusionForRect(m, x, y, w, h) {
|
||
if (!m) return 0;
|
||
const x0 = Math.max(0, Math.round(x)),
|
||
y0 = Math.max(0, Math.round(y));
|
||
const x1 = Math.min(m.W, Math.round(x + w)),
|
||
y1 = Math.min(m.H, Math.round(y + h));
|
||
if (x1 <= x0 || y1 <= y0) return 0;
|
||
let cnt = 0,
|
||
tot = 0;
|
||
for (let yy = y0; yy < y1; yy++) {
|
||
const row = yy * m.W;
|
||
for (let xx = x0; xx < x1; xx++) {
|
||
tot++;
|
||
if (m.data[row + xx] > 128) cnt++;
|
||
}
|
||
}
|
||
return tot ? cnt / tot : 0;
|
||
}
|
||
function argf(name, d) {
|
||
const i = process.argv.indexOf(name);
|
||
return i >= 0 ? parseFloat(process.argv[i + 1]) : d;
|
||
}
|
||
|
||
async function main() {
|
||
const project = path.resolve(process.argv[2] || "");
|
||
if (!process.argv[2]) {
|
||
console.error("usage: check-occlusion.cjs <project-dir> [--strict]");
|
||
process.exit(1);
|
||
}
|
||
const strict = process.argv.includes("--strict");
|
||
const wordFail = argf("--word-fail", 0.65),
|
||
wordWarn = argf("--word-warn", 0.35),
|
||
capFail = argf("--cap-fail", 0.5);
|
||
const layout = ensureLayoutMeasured(project, process.argv.includes("--remeasure"));
|
||
const framesDir = path.join(project, "frames_fg");
|
||
if (!fs.existsSync(framesDir)) {
|
||
console.error(`[v2] missing ${framesDir}`);
|
||
process.exit(2);
|
||
}
|
||
const planPath = path.join(project, "plan.json");
|
||
const plan = fs.existsSync(planPath) ? JSON.parse(fs.readFileSync(planPath, "utf8")) : {};
|
||
const planLayer = plan.caption_layer || "bg";
|
||
// Hero groups (the ONE big promoted word) are SUPPOSED to sit ON the subject — for them
|
||
// occlusion is a TARGET (~30–55%), not "minimize". Collect their ids for the advisory below.
|
||
const heroIds = new Set();
|
||
const heroIn = {};
|
||
for (const g of plan.groups || [])
|
||
if (g && (g.hero === true || /^(hero|crown)$/i.test(g.plane || ""))) {
|
||
heroIds.add(g.id);
|
||
heroIn[g.id] = g.in;
|
||
}
|
||
if (plan.crown_group && plan.crown_group.id) {
|
||
heroIds.add(plan.crown_group.id);
|
||
heroIn[plan.crown_group.id] = plan.crown_group.in;
|
||
}
|
||
const M = 2; // frame-edge tolerance (px) — matches check-overflow.cjs
|
||
const frameW = layout.width,
|
||
frameH = layout.height;
|
||
|
||
const capStats = {};
|
||
for (const sample of layout.samples) {
|
||
const png = path.join(framesDir, `f_${String(sample.frame_idx).padStart(4, "0")}.png`);
|
||
const mask = await loadAlphaMask(png);
|
||
if (!mask) continue;
|
||
for (const cap of sample.caps) {
|
||
const entry = (capStats[cap.id] ||= { layer: cap.layer || planLayer, samples: [] });
|
||
const wordsData = [];
|
||
for (const w of cap.words || []) {
|
||
if ((w.opacity ?? 1) < 0.3) continue;
|
||
wordsData.push({ text: w.text, occlusion: occlusionForRect(mask, w.x, w.y, w.w, w.h) });
|
||
// Frame-edge overflow — clipped SETTLED text is always wrong; a hero's first
|
||
// 0.5s is its entrance TRANSIENT (slam over-scale, streak fly-in pass through
|
||
// off-frame states by design) — judge overflow on the hold, not mid-flight.
|
||
if (heroIds.has(cap.id) && heroIn[cap.id] != null && sample.t < heroIn[cap.id] + 0.5)
|
||
continue;
|
||
const off = {
|
||
left: Math.max(0, Math.round(-w.x - M)),
|
||
right: Math.max(0, Math.round(w.x + w.w - frameW - M)),
|
||
top: Math.max(0, Math.round(-w.y - M)),
|
||
bottom: Math.max(0, Math.round(w.y + w.h - frameH - M)),
|
||
};
|
||
const score = off.left + off.right + off.top + off.bottom;
|
||
if (score > 0 && (!entry.overflow || score > entry.overflow.score))
|
||
entry.overflow = { text: w.text, off, score, t: sample.t };
|
||
}
|
||
const capOccl = occlusionForRect(
|
||
mask,
|
||
cap.cap_bbox.x,
|
||
cap.cap_bbox.y,
|
||
cap.cap_bbox.w,
|
||
cap.cap_bbox.h,
|
||
);
|
||
entry.samples.push({
|
||
t: sample.t,
|
||
cap_occl: capOccl,
|
||
cap_bbox: cap.cap_bbox,
|
||
words: wordsData,
|
||
});
|
||
}
|
||
}
|
||
|
||
const failures = [];
|
||
console.log(
|
||
`[v2] ${path.basename(project)} word-fail≥${(wordFail * 100).toFixed(0)}% cap-fail≥${(capFail * 100).toFixed(0)}%`,
|
||
);
|
||
for (const [gid, entry] of Object.entries(capStats)) {
|
||
// Frame-edge overflow applies to every layer (fg too). The skill allows the
|
||
// climax a few-px graze on the first/last letter, so only a clear glyph clip
|
||
// (>8px past an edge) is a hard FAIL; a sub-glyph graze is a WARN.
|
||
if (entry.overflow) {
|
||
const o = entry.overflow;
|
||
const maxOff = Math.max(o.off.left, o.off.right, o.off.top, o.off.bottom);
|
||
const sides = Object.entries(o.off)
|
||
.filter(([, v]) => v > 0)
|
||
.map(([s, v]) => `${s} ${v}px`)
|
||
.join(", ");
|
||
const hard = maxOff >= 8;
|
||
console.log(
|
||
` ${gid} [overflow${hard ? "" : "-warn"}] "${o.text}" off-frame: ${sides} (@${o.t}s)` +
|
||
(hard ? " — cropped text is always wrong" : " (graze — within climax tolerance)"),
|
||
);
|
||
if (hard && !failures.includes(gid)) failures.push(gid);
|
||
}
|
||
if (entry.layer === "fg") {
|
||
console.log(` ${gid} fg (occlusion skipped — fg renders above matte)`);
|
||
continue;
|
||
}
|
||
const capOccls = entry.samples.map((s) => s.cap_occl);
|
||
const avgCap = capOccls.length ? capOccls.reduce((a, b) => a + b, 0) / capOccls.length : 0;
|
||
const peakCap = capOccls.length ? Math.max(...capOccls) : 0;
|
||
const wordPeaks = {};
|
||
for (const s of entry.samples)
|
||
for (const wd of s.words)
|
||
wordPeaks[wd.text] = Math.max(wordPeaks[wd.text] || 0, wd.occlusion);
|
||
const oblit = Object.entries(wordPeaks).filter(([, p]) => p >= wordFail);
|
||
const warn = Object.entries(wordPeaks).filter(([, p]) => p >= wordWarn && p < wordFail);
|
||
let status = "OK";
|
||
// HERO caps WANT occlusion (~30–55% is the embed); the generic cap threshold would
|
||
// fail a working hero. Heroes fail only past the feasibility ceiling (68%).
|
||
const capLimit = heroIds.has(gid) ? Math.max(capFail, 0.68) : capFail;
|
||
if (oblit.length || peakCap >= capLimit) {
|
||
status = "FAIL";
|
||
failures.push(gid);
|
||
} else if (warn.length) status = "WARN";
|
||
let s = "";
|
||
if (oblit.length)
|
||
s =
|
||
oblit
|
||
.slice(0, 5)
|
||
.map(([t, p]) => `${t}(${(p * 100).toFixed(0)}%)`)
|
||
.join(" ") + (oblit.length > 5 ? ` …+${oblit.length - 5}` : "");
|
||
else if (warn.length)
|
||
s =
|
||
"[warn] " +
|
||
warn
|
||
.slice(0, 3)
|
||
.map(([t, p]) => `${t}(${(p * 100).toFixed(0)}%)`)
|
||
.join(" ");
|
||
console.log(
|
||
` ${gid} ${entry.layer} avg ${(avgCap * 100).toFixed(0)}% peak ${(peakCap * 100).toFixed(0)}% ${status} ${s}`,
|
||
);
|
||
// HERO target-occlusion advisory (not a failure): a hero should sit ON the subject
|
||
// (~30–55%). If it barely grazes, it reads as a small floating word, not an embed.
|
||
if (heroIds.has(gid) && peakCap < 0.15) {
|
||
// METRIC HONESTY: this advisory uses CAP-AREA occlusion, which saturates ~15%
|
||
// for a width-filled hero over a narrow subject (the 30–55% figure elsewhere is
|
||
// the safe-zones BAND metric — different denominator). If the hero already owns
|
||
// the width, "center it + make it BIG" is unactionable — stay quiet.
|
||
const widest = Math.max(...entry.samples.map((sm) => (sm.cap_bbox && sm.cap_bbox.w) || 0), 0);
|
||
if (widest >= frameW * 0.8) {
|
||
console.log(
|
||
` ${gid} [hero-ok] peak ${(peakCap * 100).toFixed(0)}% cap-area — width-saturated hero over a narrow subject; cap-area can't reach the band target (this is the geometry, not a layout fault).`,
|
||
);
|
||
} else {
|
||
console.log(
|
||
` ${gid} [hero-weak] peak ${(peakCap * 100).toFixed(0)}% — hero barely crosses the subject; it should sit ON the subject (~30–55% by the safe-zones BAND metric = the embed effect). Center it (safe-zones heroAnchor) + make it BIG; don't park it in a clean margin.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if (failures.length) {
|
||
const uniq = [...new Set(failures)];
|
||
console.error(`\n[v2] ${uniq.length} cap(s) FAIL: ${uniq.join(", ")}`);
|
||
console.error(
|
||
" → occlusion: set that cap's layer to fg, OR shrink/reposition; overflow: shrink/reposition (fg won't help)",
|
||
);
|
||
}
|
||
process.exit(strict && failures.length ? 2 : 0);
|
||
}
|
||
main().catch((e) => {
|
||
console.error("[v2]", e.message);
|
||
process.exit(1);
|
||
});
|