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
234 lines
8.2 KiB
JavaScript
234 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/*
|
||
* check-rail-climax.cjs — enforce the Rail ↔ climax hand-off (PIPELINE.md).
|
||
*
|
||
* node check-rail-climax.cjs <project-dir>
|
||
*
|
||
* The embed climax is a PROMOTED word — lifted out of the rail into the hero
|
||
* layer. It must NEVER also be revealed in the rail. This gate loads index.html
|
||
* (the climax) and rail.html (the verbatim rail) in headless Chromium, finds the
|
||
* climax's on-screen window + its word(s), then checks whether the rail reveals
|
||
* any of those same words DURING that window. If it does, the promoted word is
|
||
* duplicated on screen → exit 2 (the render aborts). Standard mode only.
|
||
*
|
||
* Conservative by design: only a CONFIRMED duplicate fails. Anything we can't
|
||
* determine (no rail.html, no puppeteer, timeline never registers, no climax)
|
||
* exits 0 — infra problems never block a render.
|
||
*/
|
||
const path = require("path");
|
||
const fs = require("fs");
|
||
const os = require("os");
|
||
|
||
const HF_ROOTS = [
|
||
process.env.HYPERFRAMES_ROOT,
|
||
path.resolve(__dirname, "../../.."),
|
||
path.join(os.homedir(), "Downloads", "hyperframes"),
|
||
].filter(Boolean);
|
||
|
||
function findInBun(root, pkg, sub) {
|
||
const cands = [path.join(root, "node_modules", pkg)];
|
||
const bunDir = path.join(root, "node_modules", ".bun");
|
||
try {
|
||
if (fs.existsSync(bunDir))
|
||
for (const d of fs.readdirSync(bunDir))
|
||
if (d.startsWith(pkg + "@")) cands.push(path.join(bunDir, d, "node_modules", pkg));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
for (const c of cands) {
|
||
const p = sub ? path.join(c, sub) : c;
|
||
if (fs.existsSync(p)) return p;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
let puppeteer = null,
|
||
gsapSource = null;
|
||
for (const root of HF_ROOTS) {
|
||
if (!puppeteer) {
|
||
const p = findInBun(root, "puppeteer");
|
||
if (p) {
|
||
try {
|
||
puppeteer = require(p);
|
||
} catch {}
|
||
}
|
||
}
|
||
if (!gsapSource) {
|
||
const g = findInBun(root, "gsap", path.join("dist", "gsap.min.js"));
|
||
if (g) gsapSource = fs.readFileSync(g, "utf8");
|
||
}
|
||
}
|
||
|
||
const norm = (s) =>
|
||
String(s)
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]/g, "");
|
||
const toks = (s) =>
|
||
String(s)
|
||
.split(/\s+/)
|
||
.map(norm)
|
||
.filter((t) => t.length >= 2);
|
||
|
||
function ok(msg) {
|
||
if (msg) console.log(msg);
|
||
process.exit(0);
|
||
} // can't-determine / pass → never blocks
|
||
function fail(msg) {
|
||
console.error(msg);
|
||
process.exit(2);
|
||
}
|
||
|
||
async function newPage(browser, W, H) {
|
||
const page = await browser.newPage();
|
||
await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 });
|
||
if (gsapSource) {
|
||
await page.evaluateOnNewDocument(gsapSource);
|
||
await page.setRequestInterception(true);
|
||
page.on("request", (req) => {
|
||
const u = req.url();
|
||
if (req.resourceType() === "script" && /gsap/i.test(u) && /^https?:/i.test(u)) req.abort();
|
||
else req.continue();
|
||
});
|
||
}
|
||
return page;
|
||
}
|
||
async function load(page, file) {
|
||
await page.goto(`file://${file}`, { waitUntil: "load", timeout: 15000 });
|
||
const start = Date.now();
|
||
while (Date.now() - start < 12000) {
|
||
if (await page.evaluate(() => !!(window.__timelines && window.__timelines.main))) {
|
||
try {
|
||
await page.evaluate(async () => {
|
||
await document.fonts.ready;
|
||
});
|
||
} catch {}
|
||
return true;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 150));
|
||
}
|
||
return false;
|
||
}
|
||
// visible text of a selector at time t, by seeking the page's main timeline.
|
||
// Visibility must be EFFECTIVE (own opacity × every ancestor's): a rail line that
|
||
// fades out as a CONTAINER leaves its word spans at opacity:1 — checking only the
|
||
// span's own style false-positives on text that is actually invisible.
|
||
async function visibleAt(page, t, selector, childSel) {
|
||
return page.evaluate(
|
||
(t, selector, childSel) => {
|
||
const tl = window.__timelines.main;
|
||
tl.seek(t);
|
||
void document.body.offsetHeight;
|
||
const eff = (el) => {
|
||
let o = 1,
|
||
n = el;
|
||
while (n && n.nodeType === 1) {
|
||
const cs = getComputedStyle(n);
|
||
if (cs.display === "none" || cs.visibility === "hidden") return 0;
|
||
o *= +cs.opacity;
|
||
n = n.parentElement;
|
||
}
|
||
return o;
|
||
};
|
||
const out = [];
|
||
for (const el of document.querySelectorAll(selector)) {
|
||
if (eff(el) < 0.05) continue;
|
||
const kids = childSel ? [...el.querySelectorAll(childSel)] : [];
|
||
if (kids.length) {
|
||
for (const k of kids) {
|
||
if (eff(k) > 0.05) out.push(k.textContent);
|
||
}
|
||
} else if ((el.textContent || "").trim()) out.push(el.textContent);
|
||
}
|
||
return out.join(" ").trim();
|
||
},
|
||
t,
|
||
selector,
|
||
childSel,
|
||
);
|
||
}
|
||
|
||
async function main() {
|
||
const project = path.resolve(process.argv[2] || "");
|
||
if (!process.argv[2]) ok("[rail-climax] usage: check-rail-climax.cjs <project-dir>");
|
||
const indexPath = path.join(project, "index.html");
|
||
const railPath = path.join(project, "rail.html");
|
||
if (!fs.existsSync(railPath) || !fs.existsSync(indexPath))
|
||
ok("[rail-climax] no rail.html+index.html — not Standard, skipping");
|
||
if (!puppeteer) ok("[rail-climax] puppeteer unavailable — skipping (set HYPERFRAMES_ROOT)");
|
||
|
||
const exe =
|
||
process.platform === "darwin"
|
||
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||
: "/usr/bin/google-chrome";
|
||
let browser;
|
||
try {
|
||
browser = await puppeteer.launch({
|
||
headless: "new",
|
||
executablePath: fs.existsSync(exe) ? exe : undefined,
|
||
args: ["--disable-web-security", "--allow-file-access-from-files", "--disable-dev-shm-usage"],
|
||
});
|
||
} catch (e) {
|
||
ok(`[rail-climax] could not launch Chromium — skipping (${e.message})`);
|
||
}
|
||
|
||
try {
|
||
// index.html → climax window + climax word tokens
|
||
const ip = await newPage(browser, 1920, 1080);
|
||
if (!(await load(ip, indexPath)))
|
||
ok("[rail-climax] index timeline never registered — skipping");
|
||
const dur =
|
||
+(await ip.evaluate(() => {
|
||
const r = document.querySelector("#root") || document.querySelector("[data-duration]");
|
||
return (r && r.dataset && r.dataset.duration) || 0;
|
||
})) || 30;
|
||
const STEP = 0.1;
|
||
const climaxTokens = new Set();
|
||
let winIn = null,
|
||
winOut = null;
|
||
for (let t = 0; t <= dur + 0.001; t += STEP) {
|
||
const txt = await visibleAt(ip, +t.toFixed(2), ".climax", "span,.w");
|
||
if (txt) {
|
||
toks(txt).forEach((w) => climaxTokens.add(w));
|
||
if (winIn === null) winIn = t;
|
||
winOut = t;
|
||
}
|
||
}
|
||
if (!climaxTokens.size || winIn === null)
|
||
ok("[rail-climax] no visible climax found in index.html — skipping");
|
||
|
||
// rail.html → words visible during [winIn, winOut]
|
||
const rp = await newPage(browser, 1920, 1080);
|
||
if (!(await load(rp, railPath))) ok("[rail-climax] rail timeline never registered — skipping");
|
||
const hits = new Map(); // token -> first t seen
|
||
for (let t = Math.max(0, winIn - STEP); t <= winOut + 0.001; t += STEP) {
|
||
const railToks = new Set(toks(await visibleAt(rp, +t.toFixed(2), ".w", null)));
|
||
for (const w of climaxTokens) if (railToks.has(w) && !hits.has(w)) hits.set(w, +t.toFixed(2));
|
||
}
|
||
|
||
if (hits.size) {
|
||
const list = [...hits.entries()]
|
||
.map(([w, t]) => `"${w}" (rail shows it at t=${t}s)`)
|
||
.join(", ");
|
||
fail(
|
||
`[rail-climax] ✗ DUPLICATE PROMOTED WORD: the climax word(s) ${[...climaxTokens].map((w) => `"${w}"`).join(", ")} ` +
|
||
`are on screen during the climax window [${winIn.toFixed(2)}–${winOut.toFixed(2)}s], ` +
|
||
`and the rail ALSO reveals: ${list}.\n` +
|
||
` The promoted word must be handed off, never duplicated — see PIPELINE.md "Rail ↔ climax hand-off":\n` +
|
||
` freeze the rail before the promoted word, let the climax carry it, hold the climax across the rail's\n` +
|
||
` page-flip, and exit it at the end of the thought. The rail must never reveal the promoted word.`,
|
||
);
|
||
}
|
||
console.log(
|
||
`[rail-climax] ✓ ok — promoted word(s) ${[...climaxTokens].map((w) => `"${w}"`).join(", ")} not duplicated in the rail (window ${winIn.toFixed(2)}–${winOut.toFixed(2)}s)`,
|
||
);
|
||
} finally {
|
||
await Promise.race([browser.close().catch(() => {}), new Promise((r) => setTimeout(r, 8000))]);
|
||
}
|
||
}
|
||
main()
|
||
.then(() => process.exit(0))
|
||
.catch((e) => {
|
||
console.error(`[rail-climax] (skipped — ${e.message})`);
|
||
process.exit(0);
|
||
});
|