scripts/shoot.mjs
scripts/shoot.mjsBrowse 27 files
2,339 tokens
8,799 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env node2/**3 * shoot.mjs — screenshot journey for scroll-driven websites4 * Usage: node shoot.mjs <url> [--stops 7] [--out shots] [--breakpoints 390,768,1440] [--dpr 1] [--reduced-motion] [--full]5 */6 7import { mkdir, writeFile } from 'fs/promises';8import { existsSync } from 'fs';9import { resolve, join } from 'path';10 11// --- CLI parsing ---12const args = process.argv.slice(2);13if (!args.length || args[0] === '--help') {14 console.log('Usage: node shoot.mjs <url> [<url>...] [--stops 7] [--out shots] [--breakpoints 390,768,1440] [--dpr 1] [--reduced-motion] [--full]');15 console.log('Writes: <out>/bp<width>-stop<NN>.png (also bp<width>-rm-stop<NN>.png with --reduced-motion, bp<width>-full.png with --full)');16 process.exit(0);17}18 19// Multi-screen products need every route shot, not a sample — take all leading non-flag args.20const rawTargets = [];21for (const a of args) { if (a.startsWith('--')) break; rawTargets.push(a); }22const get = (flag, def) => {23 const i = args.indexOf(flag);24 return i !== -1 ? args[i + 1] : def;25};26const has = flag => args.includes(flag);27 28const stopsCount = parseInt(get('--stops', '7'), 10);29const outDir = resolve(get('--out', 'shots'));30const bpArg = get('--breakpoints', '390,768,1440');31const reducedMotion = has('--reduced-motion');32const fullPage = has('--full');33// Layout bugs read fine at 1x, so that stays the default (30 frames at 2x cost 4x the bytes for34// nothing). Shoot --dpr 2 when the question is rendering rather than layout: hairline borders that35// vanish, text that only looks crisp at 1x, moiré in a fine pattern, an image whose real resolution36// is half what the layout claims.37const dpr = parseFloat(get('--dpr', '1')) || 1;38 39const breakpoints = bpArg.split(',').map(Number);40const heights = { 390: 844, 768: 1024, 1440: 900 };41const getHeight = w => heights[w] ?? 900;42 43// Convert local paths → file:// URLs44const toUrl = u => (u.startsWith('http://') || u.startsWith('https://') || u.startsWith('file://'))45 ? u46 : 'file:///' + resolve(u).replace(/\\/g, '/');47const targets = rawTargets.map(toUrl);48// One route keeps the historical bp<width>-stop<NN>.png names; several get a per-route prefix.49// Truncating the tail collides for routes that share a long suffix and silently overwrites frames,50// so keep the distinguishing end AND an index that cannot repeat.51const labelFor = (u, i) => targets.length === 1 ? '' :52 `${String(i).padStart(2, '0')}_${(u.replace(/^https?:\/\//, '').replace(/^file:\/\/\//, '')53 .replace(/[^\w]+/g, '_').replace(/^_+|_+$/g, '').slice(-24)) || 'r'}-`;54 55// --- Dynamic import playwright with friendly error ---56let chromium;57try {58 ({ chromium } = await import('playwright'));59} catch {60 console.error('playwright not found. Install: npm i -D playwright && npx playwright install chromium');61 process.exit(2);62}63 64// --- Heuristic: is this screenshot likely blank? ---65// Checks byte-entropy of the PNG buffer — a nearly-uniform image compresses to66// a much smaller ratio vs a varied one. ponytail: approximate, not pixel-perfect.67function likelyBlank(buf) {68 if (buf.length === 0) return true;69 // PNG files with almost all identical pixels compress extremely well.70 // Heuristic: if buffer is <2KB for any resolution it's suspicious.71 const VERY_SMALL = 2048;72 if (buf.length < VERY_SMALL) return true;73 // Sample 512 bytes spread across the buffer, count unique byte values.74 const samples = new Set();75 const step = Math.max(1, Math.floor(buf.length / 512));76 for (let i = 33; i < buf.length; i += step) samples.add(buf[i]); // skip PNG header77 return samples.size < 8; // fewer than 8 distinct byte values → very uniform78}79 80// --- Zero-pad helper ---81const pad = (n, len = 2) => String(n).padStart(len, '0');82 83// --- Main ---84await mkdir(outDir, { recursive: true });85 86let totalWritten = 0;87let anySucceeded = false;88const summary = [];89 90for (const [ti, pageUrl] of targets.entries()) {91const prefix = labelFor(pageUrl, ti);92for (const width of breakpoints) {93 const vpHeight = getHeight(width);94 const bpErrors = [];95 const bpFiles = [];96 97 let browser;98 try {99 browser = await chromium.launch({ headless: true });100 const context = await browser.newContext({101 viewport: { width, height: vpHeight },102 deviceScaleFactor: dpr,103 });104 const page = await context.newPage();105 106 // Collect page console errors107 page.on('console', msg => { if (msg.type() === 'error') bpErrors.push(msg.text()); });108 page.on('pageerror', err => bpErrors.push(err.message));109 110 // Navigate with networkidle, fall back to load on timeout111 try {112 await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30_000 });113 } catch {114 await page.goto(pageUrl, { waitUntil: 'load', timeout: 15_000 });115 }116 117 const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight);118 const maxScroll = Math.max(0, scrollHeight - vpHeight);119 120 // Compute evenly spaced stop positions121 const stops = Array.from({ length: stopsCount }, (_, i) =>122 stopsCount === 1 ? 0 : Math.round((i / (stopsCount - 1)) * maxScroll)123 );124 125 // Screenshot each stop (incremental scroll so animations fire)126 let currentY = 0;127 for (let si = 0; si < stops.length; si++) {128 const target = stops[si];129 // Scroll incrementally toward target in chunks so IntersectionObserver triggers130 const CHUNK = 200;131 while (Math.abs(currentY - target) > CHUNK) {132 const next = currentY < target ? Math.min(currentY + CHUNK, target) : Math.max(currentY - CHUNK, target);133 await page.evaluate(y => window.scrollTo({ top: y, behavior: 'smooth' }), next);134 await page.waitForTimeout(120);135 currentY = next;136 }137 await page.evaluate(y => window.scrollTo({ top: y, behavior: 'smooth' }), target);138 await page.waitForTimeout(700); // let scroll-triggered animations settle139 140 const fname = `${prefix}bp${width}-stop${pad(si)}.png`;141 const fpath = join(outDir, fname);142 const buf = await page.screenshot({ path: fpath, fullPage: false });143 bpFiles.push(fname);144 totalWritten++;145 if (likelyBlank(buf)) console.warn(` [warn] ${fname} possibly blank`);146 }147 148 // --reduced-motion: redo first + last stop149 if (reducedMotion) {150 const rmContext = await browser.newContext({151 viewport: { width, height: vpHeight },152 deviceScaleFactor: dpr,153 reducedMotion: 'reduce',154 });155 const rmPage = await rmContext.newPage();156 try {157 await rmPage.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30_000 });158 } catch {159 await rmPage.goto(pageUrl, { waitUntil: 'load', timeout: 15_000 });160 }161 // Every stop, not just first and last: the peak lives at 40-70% depth, so shooting only the162 // ends captures exactly the frames the reduced-motion cut was never going to break.163 for (const [idx, stop] of stops.map((s, i) => [i, s])) {164 await rmPage.evaluate(y => window.scrollTo({ top: y, behavior: 'smooth' }), stop);165 await rmPage.waitForTimeout(700);166 const fname = `${prefix}bp${width}-rm-stop${pad(idx)}.png`;167 const fpath = join(outDir, fname);168 const buf = await rmPage.screenshot({ path: fpath, fullPage: false });169 bpFiles.push(fname);170 totalWritten++;171 if (likelyBlank(buf)) console.warn(` [warn] ${fname} possibly blank`);172 }173 await rmContext.close();174 }175 176 // --full: one full-page screenshot (and its reduced-motion twin when both flags are on)177 if (fullPage) {178 const fname = `${prefix}bp${width}-full.png`;179 const fpath = join(outDir, fname);180 const buf = await page.screenshot({ path: fpath, fullPage: true });181 bpFiles.push(fname);182 totalWritten++;183 if (likelyBlank(buf)) console.warn(` [warn] ${fname} possibly blank`);184 }185 186 anySucceeded = true;187 summary.push({ url: pageUrl, width, scrollHeight, files: bpFiles, errors: bpErrors });188 } catch (err) {189 console.error(`[${pageUrl} bp ${width}] failed: ${err.message}`);190 summary.push({ url: pageUrl, width, failed: true, error: err.message });191 } finally {192 await browser?.close();193 }194}195}196 197// --- Print summary ---198console.log('\n=== shoot.mjs summary ===');199console.log(`Output dir : ${outDir}`);200console.log(`Files written: ${totalWritten}`);201for (const s of summary) {202 const where = targets.length > 1 ? `${s.url} ` : '';203 if (s.failed) {204 console.log(` ${where}bp${s.width}: FAILED — ${s.error}`);205 } else {206 console.log(` ${where}bp${s.width}: scrollHeight=${s.scrollHeight}px files=${s.files.length}`);207 if (s.errors.length) console.log(` page errors: ${s.errors.join(' | ')}`);208 }209}210 211process.exit(anySucceeded ? 0 : 1);212 Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 36.SKILL.mdView in source ↗36- **Node 18+** — every QA gate is a `.mjs` script run with `node` via the `terminal` tool.37- **Playwright (for the QA gates)** — in the project directory: `npm install playwright` then `npx playwright install chromium`. Required by `scripts/shoot.mjs`, `motionqa.mjs`, `systemscan.mjs`, `refscout.mjs`, `chromadiff.mjs`, `moodboard.mjs` (the scripts import `playwright` at runtime; `slopscan.mjs` and `source.mjs` are dependency-light).38- **ffmpeg** — optional; only for the video/score paths in `references/assets.md` and `references/scroll-flight.md`.
Source excerpt starting at line 174.1741. `node scripts/slopscan.mjs <src-dir>` exits 0 (fails are fixed, not suppressed — `/* auteur-allow: RULE_ID -- reason */` exists for deliberate choices and demands a real reason);1752. `node scripts/shoot.mjs <url>` has produced screenshot journeys at 390 / 768 / 1440 and you have **looked at every frame** — text overflow, blank scenes, broken reveals, layout collapse are found by eyes, not by text search;1763. the numeric rubric in `references/verify.md` passes (contrast, LCP, CLS, reduced-motion journey, scene variety);