#!/usr/bin/env node /** * measure-layout.cjs — pixel-perfect bbox measurement using headless Chromium. * * Loads the compiled index.html, seeks the GSAP timeline to specified sample * times, queries every .cap container + every .w word span via * getBoundingClientRect(), writes results to _layout.json. * * check-occlusion.cjs then reads _layout.json + frames_fg/*.png and computes * per-word occlusion against the actual subject silhouette (matte alpha via * sharp) — pixel accurate, no char_ratio guessing. * * Usage: * node measure-layout.cjs [times...] * If no times given, samples groups['in', 'out'] midpoints. */ const path = require("path"); const fs = require("fs"); const os = require("os"); // Locate hyperframes' bundled puppeteer. render-and-composite.sh exports // HYPERFRAMES_ROOT; standalone we also try the in-repo path + ~/Downloads, and // accept ANY puppeteer@* the bun store holds (not a pinned version). const HF_ROOTS = [ process.env.HYPERFRAMES_ROOT, path.resolve(__dirname, "../../.."), // skills/embedded-captions/scripts → repo root if in-repo path.join(os.homedir(), "Downloads", "hyperframes"), ].filter(Boolean); let puppeteer = null; for (const root of HF_ROOTS) { const cands = [path.join(root, "node_modules", "puppeteer")]; const bunDir = path.join(root, "node_modules", ".bun"); try { if (fs.existsSync(bunDir)) { for (const d of fs.readdirSync(bunDir)) { if (d.startsWith("puppeteer@")) cands.push(path.join(bunDir, d, "node_modules", "puppeteer")); } } } catch { /* ignore */ } for (const p of cands) { try { if (fs.existsSync(p)) { puppeteer = require(p); break; } } catch { /* try next */ } } if (puppeteer) break; } if (!puppeteer) { console.error( "[measure] could not locate puppeteer — set HYPERFRAMES_ROOT to a built hyperframes checkout", ); process.exit(3); } // Resolve hyperframes' bundled GSAP. The templates load GSAP from a CDN // (cdn.jsdelivr.net), but in headless Chromium that request can be slow or // blocked — the page's inline `gsap.timeline()` then throws "gsap is not // defined" and the occlusion gate hard-fails. We inject this local copy on // every new document (before any page script runs) so window.gsap always // exists, and abort the CDN request so the parser never stalls on it. The // render path is unaffected — this is measurement-only. let gsapSource = null; for (const root of HF_ROOTS) { const cands = [path.join(root, "node_modules", "gsap", "dist", "gsap.min.js")]; const bunDir = path.join(root, "node_modules", ".bun"); try { if (fs.existsSync(bunDir)) { for (const d of fs.readdirSync(bunDir)) { if (d.startsWith("gsap@")) cands.push(path.join(bunDir, d, "node_modules", "gsap", "dist", "gsap.min.js")); } } } catch { /* ignore */ } for (const p of cands) { try { if (fs.existsSync(p)) { gsapSource = fs.readFileSync(p, "utf8"); break; } } catch { /* try next */ } } if (gsapSource) break; } async function main() { const projectDir = process.argv[2]; if (!projectDir) { console.error("usage: measure-layout.cjs [t1 t2 ...]"); process.exit(1); } const indexPath = path.resolve(projectDir, "index.html"); if (!fs.existsSync(indexPath)) { console.error(`[measure] missing ${indexPath} — run make-composition.cjs first`); process.exit(2); } // Load plan to get groups + sample times const planPath = path.join(projectDir, "plan.json"); let plan = null; if (fs.existsSync(planPath)) plan = JSON.parse(fs.readFileSync(planPath, "utf8")); // Determine sample times: per group, sample multiple points across [in, out] // (catches subject motion within block, multi-frame validation). const explicitTimes = process.argv.slice(3).map(Number).filter(Number.isFinite); let sampleTimes = explicitTimes; if (sampleTimes.length === 0 && plan?.groups) { const allTimes = new Set(); for (const g of plan.groups) { const dur = g.out - g.in; // 4 samples per group: 15%, 40%, 65%, 90% through window — covers entry/peak/exit [0.15, 0.4, 0.65, 0.9].forEach((p) => allTimes.add(+(g.in + dur * p).toFixed(3))); } sampleTimes = [...allTimes].sort((a, b) => a - b); } // Match render-and-composite's Chrome detection const exe = process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "/usr/bin/google-chrome"; const W = plan?.width || 720; const H = plan?.height || 1290; const FPS = plan?.fps || 24; const browser = await puppeteer.launch({ headless: "new", executablePath: fs.existsSync(exe) ? exe : undefined, args: [ "--disable-web-security", "--allow-file-access-from-files", `--window-size=${W},${H}`, "--disable-dev-shm-usage", ], }); try { const page = await browser.newPage(); await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 }); page.on("pageerror", (err) => console.error(`[browser-error] ${err.message}`)); // Inject local GSAP before any page script + abort the CDN