#!/usr/bin/env node // visual-cues.mjs — crop + compile for document seed visual cues. // Pipeline doc: skill/reference/visual-cues.md (canonical; this help text is not). // // Each cue is two images: a full-bleed hero scene and an artifact sheet // (four objects on one flat cream canvas, one per quadrant). No alpha, no // chroma key: crops keep the cream. // // node visual-cues.mjs crop --slug // [--palette "primary=#RRGGBB;secondary=...;tertiary=...;neutral=..."] // [--out ] (default: .impeccable/visual-cues) // Copies the hero untouched to .png, keeps the sheet under // /masters/-artifacts.png, quadrant-crops the sheet into // -2..5.png, finds each planned palette hex's closest pixel in // the hero, and updates /cues.json. // // Dependency-free: PNG decode/encode on node:zlib. Rejects interlaced and // indexed-color PNGs; convert those with sips/ImageMagick/PIL first. import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import zlib from 'node:zlib'; // ---------------------------------------------------------------- PNG codec const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // Every PNG chunk carries a CRC-32 trailer (the spec's fixed polynomial, // 0xedb88320); precompute the 256-entry lookup table once instead of doing // the bit-by-bit division per byte. const CRC_TABLE = (() => { const t = new Int32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c; } return t; })(); function crc32(buf) { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } // PNG filter type 4 (Paeth): predicts a byte from its left (a), above (b), // and above-left (c) neighbors, picking whichever of a, b, or a+b-c lands // closest to the actual gradient. Used only by decodePng's unfilter step; // encodePng always writes filter 0, so it never needs the inverse. function paeth(a, b, c) { const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); if (pa <= pb && pa <= pc) return a; if (pb <= pc) return b; return c; } export function decodePng(buf) { if (!buf.subarray(0, 8).equals(PNG_SIG)) throw new Error('not a PNG file'); // Walk the chunk stream: each chunk is [4-byte length][4-byte type][data][4-byte crc]. // IHDR carries the header fields; IDAT is the (possibly multi-chunk) // compressed pixel data, concatenated below before inflating; other // chunk types (tEXt, iCCP, etc.) are skipped since nothing here needs them. let pos = 8; let ihdr = null; const idat = []; while (pos + 8 <= buf.length) { const len = buf.readUInt32BE(pos); const type = buf.toString('ascii', pos + 4, pos + 8); const data = buf.subarray(pos + 8, pos + 8 + len); if (type === 'IHDR') { ihdr = { width: data.readUInt32BE(0), height: data.readUInt32BE(4), bitDepth: data[8], colorType: data[9], interlace: data[12], }; } else if (type === 'IDAT') { idat.push(data); } else if (type === 'IEND') { break; } pos += 12 + len; // length + type + data + crc } if (!ihdr) throw new Error('PNG has no IHDR chunk'); const { width, height, bitDepth, colorType, interlace } = ihdr; if (interlace) throw new Error('interlaced PNG not supported; re-save without interlacing (sips, ImageMagick, or PIL)'); if (colorType === 3) throw new Error('indexed-color PNG not supported; convert to RGB/RGBA first (sips, ImageMagick, or PIL)'); if (bitDepth !== 8 && bitDepth !== 16) throw new Error(`unsupported bit depth ${bitDepth}; convert to 8-bit first`); const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colorType]; if (!channels) throw new Error(`unsupported color type ${colorType}`); const sampleBytes = bitDepth / 8; const bpp = channels * sampleBytes; // bytes per pixel const stride = width * bpp; // bytes per scanline, excluding the filter-type byte const raw = zlib.inflateSync(Buffer.concat(idat)); // Each scanline in the inflated stream is prefixed with a 1-byte filter // type (0-4) that says how it was delta-encoded against the row above // and/or the pixel to the left; undo that in place, row by row, since // filter 2-4 need the already-unfiltered previous row to reconstruct. const px = Buffer.alloc(height * stride); let rp = 0; for (let y = 0; y < height; y++) { const filter = raw[rp++]; const row = px.subarray(y * stride, (y + 1) * stride); raw.copy(row, 0, rp, rp + stride); rp += stride; const prev = y > 0 ? px.subarray((y - 1) * stride, y * stride) : null; if (filter === 0) continue; // None: bytes are already the real pixel values if (filter === 1) { // Sub: each byte was stored as (value - left). for (let i = bpp; i < stride; i++) row[i] = (row[i] + row[i - bpp]) & 0xff; } else if (filter === 2) { // Up: each byte was stored as (value - above). if (prev) for (let i = 0; i < stride; i++) row[i] = (row[i] + prev[i]) & 0xff; } else if (filter === 3) { // Average: each byte was stored as (value - floor((left + above) / 2)). for (let i = 0; i < stride; i++) { const left = i >= bpp ? row[i - bpp] : 0; const up = prev ? prev[i] : 0; row[i] = (row[i] + ((left + up) >> 1)) & 0xff; } } else if (filter === 4) { // Paeth: each byte was stored as (value - paeth(left, above, above-left)). for (let i = 0; i < stride; i++) { const a = i >= bpp ? row[i - bpp] : 0; const b = prev ? prev[i] : 0; const c = prev && i >= bpp ? prev[i - bpp] : 0; row[i] = (row[i] + paeth(a, b, c)) & 0xff; } } else { throw new Error(`unknown PNG filter ${filter} at row ${y}`); } } // Normalize every supported color type (grayscale, RGB, grayscale+alpha, // RGBA) down to one consistent RGBA8 buffer, so everything past this // point (crop, palette search, re-encode) only ever deals with one shape. // 16-bit samples keep only the high byte; visual cues never need more // than 8 bits of precision per channel. const rgba = Buffer.alloc(width * height * 4); const at = (base, ch) => px[base + ch * sampleBytes]; for (let i = 0; i < width * height; i++) { const base = i * bpp; let r, g, b, a; if (colorType === 0) { r = g = b = at(base, 0); a = 255; } else if (colorType === 2) { r = at(base, 0); g = at(base, 1); b = at(base, 2); a = 255; } else if (colorType === 4) { r = g = b = at(base, 0); a = at(base, 1); } else { r = at(base, 0); g = at(base, 1); b = at(base, 2); a = at(base, 3); } const o = i * 4; rgba[o] = r; rgba[o + 1] = g; rgba[o + 2] = b; rgba[o + 3] = a; } return { width, height, rgba, hasAlpha: colorType === 4 || colorType === 6 }; } // Wraps one chunk's payload with its length header, type tag, and CRC // trailer, matching the layout decodePng's chunk walk expects. function pngChunk(type, data) { const out = Buffer.alloc(12 + data.length); out.writeUInt32BE(data.length, 0); out.write(type, 4, 'ascii'); data.copy(out, 8); out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length); return out; } // Always writes 8-bit RGBA with filter type 0 (None) on every scanline: the // crops here are small and this script has no bandwidth concerns, so the // simplicity of never predicting/unpredicting bytes outweighs the larger // file size a real filter choice would save. export function encodePng(rgba, width, height) { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; // bit depth ihdr[9] = 6; // color type 6 = RGBA const stride = width * 4; // One extra byte per row for the filter-type prefix (always 0 here). const raw = Buffer.alloc((stride + 1) * height); for (let y = 0; y < height; y++) { raw[y * (stride + 1)] = 0; // filter: None rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); } const idat = zlib.deflateSync(raw, { level: 9 }); return Buffer.concat([PNG_SIG, pngChunk('IHDR', ihdr), pngChunk('IDAT', idat), pngChunk('IEND', Buffer.alloc(0))]); } // ------------------------------------------------------------ quadrant math // The artifact sheet is one 2x2 grid on a flat cream canvas, one object per // quadrant, in reading order: q2 top-left, q3 top-right, q4 bottom-left, // q5 bottom-right. Proportional, so any square-ish sheet cuts the same way. export function quadrants(width, height) { const mx = Math.round(width / 2); const my = Math.round(height / 2); return { q2: { x: 0, y: 0, w: mx, h: my }, q3: { x: mx, y: 0, w: width - mx, h: my }, q4: { x: 0, y: my, w: mx, h: height - my }, q5: { x: mx, y: my, w: width - mx, h: height - my }, }; } // Copies one rectangle r = {x, y, w, h} out of img.rgba, row by row (rows // aren't contiguous across the crop boundary in the source buffer). function cropRegion(img, r) { const out = Buffer.alloc(r.w * r.h * 4); for (let y = 0; y < r.h; y++) { const src = ((r.y + y) * img.width + r.x) * 4; img.rgba.copy(out, y * r.w * 4, src, src + r.w * 4); } return out; } // ----------------------------------------------------------------- palette // role=#RRGGBB per entry; a legacy trailing @x,y is accepted and ignored // (the search below beats model-reported coordinates every time). const PALETTE_ENTRY = /^([a-z][a-z-]*)=(#[0-9a-fA-F]{6})(?:@\d+,\d+)?$/; function parsePalette(str) { const out = {}; for (const part of str.split(';')) { const m = part.trim().match(PALETTE_ENTRY); if (!m) throw new Error(`bad palette entry "${part.trim()}" (expected role=#RRGGBB)`); out[m[1]] = { hex: m[2].toUpperCase() }; } return out; } // The parent designed the palette, so the planned hex is known; what needs // measuring is where and how faithfully the hero staged it. Search the whole // hero for the pixel closest to each planned hex. hex stays the planned // value; snapped is the closest rendered pixel; at is its hero position. function snapPalette(img, palette) { const out = {}; // Sample on a grid instead of every pixel: ~150 samples per axis is dense // enough to find a representative patch of any staged color, and scanning // a 1500x1500 hero at full resolution for every role adds up otherwise. const step = Math.max(1, Math.floor(Math.min(img.width, img.height) / 150)); for (const [role, entry] of Object.entries(palette)) { const pr = parseInt(entry.hex.slice(1, 3), 16); const pg = parseInt(entry.hex.slice(3, 5), 16); const pb = parseInt(entry.hex.slice(5, 7), 16); let best = Infinity; let bx = 0; let by = 0; // Squared Euclidean distance in RGB space; skipping the sqrt is fine // since only the relative ordering of distances matters here. for (let y = 0; y < img.height; y += step) { for (let x = 0; x < img.width; x += step) { const o = (y * img.width + x) * 4; const dr = img.rgba[o] - pr; const dg = img.rgba[o + 1] - pg; const db = img.rgba[o + 2] - pb; const d = dr * dr + dg * dg + db * db; if (d < best) { best = d; bx = x; by = y; } } } const o = (by * img.width + bx) * 4; const snapped = `#${[img.rgba[o], img.rgba[o + 1], img.rgba[o + 2]] .map((v) => v.toString(16).padStart(2, '0')) .join('') .toUpperCase()}`; out[role] = { hex: entry.hex, snapped, at: [bx, by] }; } return out; } // ---------------------------------------------------------------- cues.json // Reads the existing cues.json (if any) and merges this cue in, so cropping // the six concepts one after another accumulates into one shared manifest // instead of each crop overwriting the last. function updateCuesJson(outDir, slug, artifactIds, palette) { const path = join(outDir, 'cues.json'); let data = {}; if (existsSync(path)) data = JSON.parse(readFileSync(path, 'utf8')); data.cues = data.cues || []; data['supporting-artifacts'] = data['supporting-artifacts'] || {}; if (!data.cues.includes(slug)) data.cues.push(slug); data['supporting-artifacts'][slug] = artifactIds; if (palette) { data.palette = data.palette || {}; data.palette[slug] = palette; } writeFileSync(path, JSON.stringify(data, null, 2) + '\n'); return data; } // -------------------------------------------------------------------- CLI // Minimal flag parser: positional args collect into `_`, everything after // a `--name` becomes args.name. Good enough for this script's small, // fixed set of options; no need for a dependency here. function parseArgs(argv) { const args = { _: [] }; for (let i = 0; i < argv.length; i++) { if (argv[i].startsWith('--')) { args[argv[i].slice(2)] = argv[i + 1]; i++; } else { args._.push(argv[i]); } } return args; } // Errors surface as JSON on stderr (matching the success shape on stdout) // so the calling agent can parse either outcome the same way. function fail(msg) { console.error(JSON.stringify({ ok: false, error: msg })); process.exit(1); } function cmdCrop(args) { const [heroFile, sheetFile] = args._; const slug = args.slug; if (!heroFile || !sheetFile || !slug) { fail('usage: visual-cues.mjs crop --slug [--palette "..."] [--out ]'); } if (!/^[a-z0-9]+(-[a-z0-9]+)+$/.test(slug)) fail(`slug "${slug}" must be lowercase words joined by hyphens (e.g. amber-dusk)`); const outDir = resolve(args.out || '.impeccable/visual-cues'); const hero = decodePng(readFileSync(resolve(heroFile))); const sheet = decodePng(readFileSync(resolve(sheetFile))); mkdirSync(join(outDir, 'masters'), { recursive: true }); const heroPath = join(outDir, `${slug}.png`); copyFileSync(resolve(heroFile), heroPath); // the hero ships untouched, no crop const keptSheet = join(outDir, 'masters', `${slug}-artifacts.png`); copyFileSync(resolve(sheetFile), keptSheet); // uncropped sheet, kept for reference // q2..q5 in reading order (top-left, top-right, bottom-left, bottom-right) // become -2.png..-5.png, matching the numbering documented in // reference/visual-cues.md and expected by cues.json readers. const qs = quadrants(sheet.width, sheet.height); const files = [heroPath]; const artifactIds = []; const order = ['q2', 'q3', 'q4', 'q5']; for (let i = 0; i < order.length; i++) { const r = qs[order[i]]; const id = `${slug}-${i + 2}`; artifactIds.push(id); const outPath = join(outDir, `${id}.png`); writeFileSync(outPath, encodePng(cropRegion(sheet, r), r.w, r.h)); files.push(outPath); } // --palette is optional: the agent may crop before it has finished // designing the palette, and can re-run crop later once it has hexes. let palette = null; if (args.palette) palette = snapPalette(hero, parsePalette(args.palette)); updateCuesJson(outDir, slug, artifactIds, palette); console.log(JSON.stringify({ ok: true, slug, hero: heroPath, artifacts: keptSheet, files, palette, cuesJson: join(outDir, 'cues.json'), }, null, 2)); } function main() { const [cmd, ...rest] = process.argv.slice(2); const args = parseArgs(rest); try { if (cmd === 'crop') cmdCrop(args); else fail('usage: visual-cues.mjs crop --slug [options] (see reference/visual-cues.md)'); } catch (err) { fail(err.message); } } // Only auto-run when invoked directly (`node visual-cues.mjs ...`), not // when another module imports its exports (decodePng, encodePng, etc.), // e.g. from a test file. if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { main(); }