Add comp-diff, comp-spec, and build-phase: measured comp fidelity for the build phase

Dependency-free PNG codec, perceptual metrics (structure / color / detail /
bands), side-by-side + heatmap + per-region crops, a measured spec from the
approved comp (grid overlay, sampled palette, plate list), and a phase state
machine whose spec / plates / hero gates run the diff instead of asking the
model to remember the image.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-08-28 06:09:46 +05:00
committed by Abdul Wahab
co-authored by Claude
parent f86473ba7d
commit b0fc2e8801
10 changed files with 1979 additions and 1 deletions
+3 -1
View File
@@ -26,7 +26,7 @@ export const SUITES = {
triggers: [
...COMMON_INFRA_PATTERNS,
/^scripts\/(?!benchmark-detector|build-browser-detector|build-extension)/,
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|concept-seed|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|pin|surface-brief))/,
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|comp-diff|comp-spec|build-phase|concept-seed|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|png|raster|image-metrics|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|pin|surface-brief))/,
/^README(\.npm)?\.md$/,
/^cli\/bin\//,
],
@@ -54,6 +54,8 @@ export const SUITES = {
'tests/ci-test-plan.test.mjs',
'tests/cli-args.test.mjs',
'tests/concept-seed.test.mjs',
'tests/comp-diff.test.mjs',
'tests/build-phase.test.mjs',
'tests/serve-question.test.mjs',
'tests/context.test.mjs',
'tests/context-signals.test.mjs',
+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env node
/**
* build-phase: the comp-led build as a state machine on disk, so the phases
* new-work.md names are gated by scripts instead of remembered by the model.
*
* State lives at .impeccable/build/state.json. Phases, in order:
*
* spec the approved comp is measured (comp-spec.mjs wrote spec.json)
* plates every raster region in the spec has its plate on disk
* hero the first viewport is reproduced: comp-diff of hero-repro.png
* against the comp clears the gate
* sections the rest of the surface is built inside the spec's system
* motion interaction, reveals, motion
* responsive the other viewports
* review the finish reviewer ran; disposition recorded
*
* node build-phase.mjs start --comp <approved.png> [--breakpoint 1440x900]
* node build-phase.mjs status # human-readable, plus NEXT line
* node build-phase.mjs status --json
* node build-phase.mjs advance # try to close the current phase; runs its gate
* node build-phase.mjs advance --force --reason "<why>" # skip a gate; recorded, never silent
* node build-phase.mjs record hero --build .impeccable/review/hero-repro.png # run the hero gate explicitly
* node build-phase.mjs note "<text>" # append a note to the current phase
* node build-phase.mjs finish --disposition ship|fix|rebuild|recapture
*
* Gates:
* spec -> spec.json exists and has >= 1 region
* plates -> every region with medium raster has its plate file, decodable,
* at least 2x the comp region's pixel size in width, and the
* plate scores >= PLATE_MIN against the comp crop (comp-diff,
* detail-weighted). A missing or thin plate names itself.
* hero -> .impeccable/review/hero-repro.png exists and comp-diff overall
* >= HERO_MIN (default 0.72) with no region `missing`. The
* score, the report path, and the attempt count are recorded.
* sections / motion / responsive -> no mechanical gate; advancing records
* the moment, and the finish reviewer reads the timeline.
*
* Exit codes: 0 ok / advanced, 2 gate failed (state unchanged, reasons
* printed), 1 usage.
*
* Nothing here needs a browser. Screenshots come from the harness; this
* script only measures them.
*/
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { decodePng } from './lib/png.mjs';
import { crop } from './lib/raster.mjs';
import { compare, verdictFor } from './comp-diff.mjs';
import { SPEC_PATH, BUILD_DIR, loadSpec } from './comp-spec.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const STATE_PATH = path.join(BUILD_DIR, 'state.json');
export const PHASES = ['spec', 'plates', 'hero', 'sections', 'motion', 'responsive', 'review'];
export const HERO_MIN = 0.72;
export const PLATE_MIN = 0.6;
export const HERO_REPRO = path.join('.impeccable', 'review', 'hero-repro.png');
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = process.argv[i + 1];
return v && !v.startsWith('--') ? v : fallback;
}
const flag = (name) => process.argv.includes(`--${name}`);
const now = () => new Date().toISOString();
export function loadState(statePath = STATE_PATH) {
if (!fs.existsSync(statePath)) return null;
return JSON.parse(fs.readFileSync(statePath, 'utf8'));
}
export function saveState(state, statePath = STATE_PATH) {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
}
export function newState({ comp, breakpoint = null }) {
return {
tool: 'build-phase',
version: 1,
startedAt: now(),
comp,
breakpoint,
phase: 'spec',
phases: Object.fromEntries(PHASES.map((p) => [p, { status: p === 'spec' ? 'open' : 'pending', openedAt: p === 'spec' ? now() : null, closedAt: null, attempts: 0, notes: [], gate: null, forced: null }])),
finish: null,
};
}
// ---- gates -----------------------------------------------------------------
export function gateSpec(state, { specPath = SPEC_PATH } = {}) {
const spec = loadSpec(specPath);
if (!spec) return { ok: false, reasons: [`no spec at ${specPath}: run comp-spec.mjs --comp ${state.comp} --grid, name the regions, then --regions regions.json`] };
if (!spec.regions || spec.regions.length < 1) return { ok: false, reasons: ['spec has no regions'] };
if (spec.comp && state.comp && path.resolve(spec.comp) !== path.resolve(state.comp)) {
return { ok: false, reasons: [`spec measures ${spec.comp}, but this build started on ${state.comp}; re-run comp-spec on the approved comp`] };
}
const plates = spec.regions.filter((r) => r.medium === 'raster').length;
return { ok: true, reasons: [], summary: `${spec.regions.length} regions, ${plates} plates` };
}
export function gatePlates(state, { specPath = SPEC_PATH } = {}) {
const spec = loadSpec(specPath);
if (!spec) return { ok: false, reasons: ['no spec'] };
const rasterRegions = spec.regions.filter((r) => r.medium === 'raster');
if (!rasterRegions.length) return { ok: true, reasons: [], summary: 'no plates owed', plates: [] };
let comp = null;
try { comp = decodePng(fs.readFileSync(spec.comp)); } catch { /* scored without the comp crop below */ }
const reasons = [], plates = [];
for (const r of rasterRegions) {
const file = r.plate;
if (!file || !fs.existsSync(file)) { reasons.push(`plate missing for ${r.id}: expected ${file || '(no path)'}; produce it from comp-spec.mjs --crop ${r.id} with generate-image.mjs --plate`); plates.push({ id: r.id, file, status: 'missing' }); continue; }
let img;
try { img = decodePng(fs.readFileSync(file)); } catch (e) { reasons.push(`plate ${file} is not a decodable PNG: ${e.message}`); plates.push({ id: r.id, file, status: 'unreadable' }); continue; }
const minW = r.px.w * 1.5;
if (img.width < minW) reasons.push(`plate ${file} is ${img.width}px wide; the comp region is ${r.px.w}px and a shipping plate needs at least 1.5x (${Math.round(minW)}px). Regenerate at asset size, do not crop the comp.`);
let score = null;
if (comp) {
const ref = crop(comp, r.px.x, r.px.y, r.px.w, r.px.h);
const res = compare({ comp: ref, build: img, align: 'stretch', spec: null });
score = res.whole;
if (score.overall < PLATE_MIN) reasons.push(`plate ${file} scores ${(score.overall * 100).toFixed(0)}% against the comp region ${r.id} (structure ${(score.structure * 100).toFixed(0)}%, color ${(score.color * 100).toFixed(0)}%, detail ${(score.detail * 100).toFixed(0)}%); it does not read as the same region. Regenerate with the crop as --ref and the comp-spec plate prompt.`);
}
plates.push({ id: r.id, file, status: 'ok', size: `${img.width}x${img.height}`, score: score ? score.overall : null });
}
return { ok: reasons.length === 0, reasons, summary: `${plates.filter((p) => p.status === 'ok').length}/${rasterRegions.length} plates`, plates };
}
export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, min = HERO_MIN, outDir = path.join('.impeccable', 'review', 'diff', 'hero') } = {}) {
if (!fs.existsSync(buildPath)) return { ok: false, reasons: [`no hero capture at ${buildPath}: screenshot the first viewport at the comp's own dimensions (${state.breakpoint || 'comp size'}) into that path`] };
const script = path.join(HERE, 'comp-diff.mjs');
const args = [script, '--comp', state.comp, '--build', buildPath, '--out-dir', outDir, '--label', 'hero', '--json'];
const spec = loadSpec(specPath);
if (spec) args.push('--spec', specPath);
const res = spawnSync(process.execPath, args, { encoding: 'utf8' });
if (res.status !== 0 && res.status !== 3) return { ok: false, reasons: [`comp-diff failed: ${res.stderr || res.stdout}`] };
let report;
try { report = JSON.parse(res.stdout); } catch { return { ok: false, reasons: ['comp-diff produced no report'] }; }
const reasons = [];
if (report.overall < min) reasons.push(`hero overall ${(report.overall * 100).toFixed(0)}% < ${(min * 100).toFixed(0)}% (structure ${(report.scores.structure * 100).toFixed(0)}%, color ${(report.scores.color * 100).toFixed(0)}%, detail ${(report.scores.detail * 100).toFixed(0)}%)`);
const missing = report.regions.filter((r) => r.verdict === 'missing');
for (const r of missing) reasons.push(`region ${r.id} is missing (detail ${(r.score.detail * 100).toFixed(0)}%, structure ${(r.score.structure * 100).toFixed(0)}%): the comp shows material the build does not`);
const contradicted = report.regions.filter((r) => r.verdict === 'contradicted');
if (contradicted.length > Math.max(1, Math.floor(report.regions.length / 3))) reasons.push(`${contradicted.length} of ${report.regions.length} regions contradicted: ${contradicted.map((r) => r.id).join(', ')}`);
return {
ok: reasons.length === 0,
reasons,
summary: `hero ${(report.overall * 100).toFixed(0)}% (${report.verdict})`,
score: report.overall,
verdict: report.verdict,
report: path.join(outDir, 'report.json'),
sideBySide: report.files ? report.files.sideBySide : null,
worst: [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3).map((r) => `${r.id} ${r.verdict} ${(r.score.overall * 100).toFixed(0)}%`),
};
}
const GATES = { spec: gateSpec, plates: gatePlates, hero: gateHero };
// ---- transitions -----------------------------------------------------------
export function runGate(state, phase, opts = {}) {
const gate = GATES[phase];
if (!gate) return { ok: true, reasons: [], summary: 'no mechanical gate' };
return gate(state, opts);
}
export function advance(state, { force = false, reason = null, gateOpts = {} } = {}) {
const phase = state.phase;
const idx = PHASES.indexOf(phase);
if (idx === -1 || phase === 'review') return { ok: false, reasons: [`phase ${phase} cannot advance; use finish`] };
const p = state.phases[phase];
p.attempts += 1;
const gate = runGate(state, phase, gateOpts);
const { plates: _p, ...gateRecord } = gate;
p.gate = { ...gateRecord, at: now() };
if (!gate.ok && !force) { p.status = 'open'; return { ok: false, phase, reasons: gate.reasons, gate }; }
if (!gate.ok && force) p.forced = { at: now(), reason: reason || '(no reason given)', reasons: gate.reasons };
p.status = 'closed'; p.closedAt = now();
const next = PHASES[idx + 1];
state.phase = next;
state.phases[next].status = 'open'; state.phases[next].openedAt = now();
return { ok: true, phase, next, gate, forced: !!p.forced };
}
export function nextInstruction(state) {
switch (state.phase) {
case 'spec': return `Measure the comp: node comp-spec.mjs --comp ${state.comp} --grid, open ${path.join(BUILD_DIR, 'comp-grid.png')}, write regions.json (every illustration, photo, texture as its own plate region), run comp-spec.mjs --comp ${state.comp} --regions regions.json, then build-phase.mjs advance.`;
case 'plates': return 'Produce every plate in the spec (comp-spec.mjs --print lists them): comp-spec.mjs --crop <id>, then generate-image.mjs --plate <id> (or the harness image tool with the crop as reference and the comp-spec plate prompt). Then build-phase.mjs advance. Write no page code before this passes.';
case 'hero': return `Build only the first viewport at ${state.breakpoint || 'the comp size'} using the plates and the spec's boxes and palette; capture it into ${HERO_REPRO}; run build-phase.mjs advance. Fix the worst regions it names and re-run; do not build past the hero until it passes.`;
case 'sections': return 'Build the remaining sections inside the spec system (same corner language, rules, and palette; nothing the comp does not show). Then build-phase.mjs advance.';
case 'motion': return 'Add the signature interaction, reveals, and motion. Then build-phase.mjs advance.';
case 'responsive': return 'Build the other viewports (mobile first if the surface is mobile). Capture desktop.png and mobile.png into .impeccable/review/. Then build-phase.mjs advance.';
case 'review': return 'Spawn the finish reviewer with the state file, the hero diff report, and the captures; record its disposition with build-phase.mjs finish --disposition <word>.';
default: return '';
}
}
export function renderStatus(state) {
const lines = [`BUILD-PHASE ${state.phase.toUpperCase()} comp ${state.comp}${state.breakpoint ? ` breakpoint ${state.breakpoint}` : ''}`];
for (const p of PHASES) {
const s = state.phases[p];
let line = ` ${p.padEnd(11)} ${s.status.padEnd(8)}`;
if (s.gate && s.gate.summary) line += ` ${s.gate.summary}`;
if (s.attempts > 1) line += ` (${s.attempts} attempts)`;
if (s.forced) line += ` FORCED: ${s.forced.reason}`;
lines.push(line);
}
if (state.finish) lines.push(` finish ${state.finish.disposition} at ${state.finish.at}`);
lines.push(`NEXT ${nextInstruction(state)}`);
return lines.join('\n');
}
async function main() {
const cmd = process.argv[2];
if (!cmd || flag('help')) {
console.error('usage: build-phase.mjs start --comp <png> [--breakpoint WxH] | status [--json] | advance [--force --reason "..."] | record hero --build <png> | note "<text>" | finish --disposition <word>');
process.exit(1);
}
if (cmd === 'start') {
const comp = arg('comp');
if (!comp || !fs.existsSync(comp)) { console.error('build-phase: --comp <approved comp png> is required and must exist'); process.exit(1); }
let breakpoint = arg('breakpoint');
if (!breakpoint) { try { const i = decodePng(fs.readFileSync(comp)); breakpoint = `${i.width}x${i.height}`; } catch { /* leave null */ } }
const existing = loadState();
if (existing && !flag('reset')) {
console.log(`build-phase: state exists (phase ${existing.phase}); pass --reset to start over`);
console.log(renderStatus(existing));
return;
}
const state = newState({ comp, breakpoint });
saveState(state);
console.log(renderStatus(state));
return;
}
const state = loadState();
if (!state) { console.error(`build-phase: no state at ${STATE_PATH}; run build-phase.mjs start --comp <approved comp>`); process.exit(1); }
if (cmd === 'status') {
if (flag('json')) console.log(JSON.stringify(state, null, 2)); else console.log(renderStatus(state));
return;
}
if (cmd === 'note') {
const text = process.argv.slice(3).filter((a) => !a.startsWith('--')).join(' ');
state.phases[state.phase].notes.push({ at: now(), text });
saveState(state);
console.log(`noted on ${state.phase}`);
return;
}
if (cmd === 'record') {
const which = process.argv[3];
if (which !== 'hero') { console.error('build-phase: record hero --build <png>'); process.exit(1); }
const gate = gateHero(state, { buildPath: arg('build', HERO_REPRO), min: arg('min') ? parseFloat(arg('min')) : HERO_MIN });
state.phases.hero.attempts += 1;
state.phases.hero.gate = { ...gate, at: now() };
saveState(state);
console.log(`${gate.ok ? 'PASS' : 'FAIL'} ${gate.summary || ''}`);
for (const r of gate.reasons) console.log(` - ${r}`);
if (gate.worst) console.log(` worst: ${gate.worst.join('; ')}`);
if (gate.sideBySide) console.log(` open ${gate.sideBySide}`);
process.exit(gate.ok ? 0 : 2);
}
if (cmd === 'advance') {
const gateOpts = {};
if (arg('build')) gateOpts.buildPath = arg('build');
if (arg('min')) gateOpts.min = parseFloat(arg('min'));
const res = advance(state, { force: flag('force'), reason: arg('reason'), gateOpts });
saveState(state);
if (!res.ok) {
console.log(`GATE ${res.phase ? res.phase.toUpperCase() : ''} FAILED (state unchanged)`);
for (const r of res.reasons) console.log(` - ${r}`);
if (res.gate && res.gate.worst) console.log(` worst: ${res.gate.worst.join('; ')}`);
if (res.gate && res.gate.sideBySide) console.log(` open ${res.gate.sideBySide} and the worst region pairs before editing`);
process.exit(2);
}
console.log(`ADVANCED ${res.phase} -> ${res.next}${res.forced ? ' (FORCED; recorded)' : ''}${res.gate.summary ? ` ${res.gate.summary}` : ''}`);
console.log(`NEXT ${nextInstruction(state)}`);
return;
}
if (cmd === 'finish') {
const disposition = arg('disposition');
if (!['ship', 'fix', 'rebuild', 'recapture'].includes(disposition)) { console.error('build-phase: finish --disposition ship|fix|rebuild|recapture'); process.exit(1); }
state.finish = { disposition, at: now(), phaseAtFinish: state.phase };
if (state.phase === 'review') { state.phases.review.status = 'closed'; state.phases.review.closedAt = now(); }
saveState(state);
console.log(renderStatus(state));
return;
}
console.error(`build-phase: unknown command ${cmd}`);
process.exit(1);
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
if (isMain) main();
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env node
/**
* comp-diff: measure a build screenshot against its approved comp and produce
* the evidence a reviewer (human or model) needs to judge fidelity without
* trusting anyone's memory of the image.
*
* node comp-diff.mjs --comp .impeccable/mocks/approved.png --build .impeccable/review/hero-repro.png
* node comp-diff.mjs --comp comp.png --build desktop.png --spec .impeccable/build/spec.json --out-dir .impeccable/review/diff
* node comp-diff.mjs ... --json # machine-readable report on stdout
* node comp-diff.mjs ... --threshold 0.75 # exit 3 when the overall score is below
*
* Inputs: two PNGs. The build capture may be taller than the comp (a full-page
* screenshot); it is scaled to the comp's width and the top comp-height rows
* are compared, because the comp is the first viewport. `--align stretch`
* squashes the whole build onto the comp instead, for a comp that covers a
* whole page.
*
* Outputs (in --out-dir, default .impeccable/review/diff):
* side-by-side.png comp | build, same size, labeled, with the score
* heatmap.png build with the difference painted over it (red = wrong)
* regions/<id>.png paired crops per region at legible scale, scored
* report.json every number below, plus per-region rows
*
* Scores (0..1): structure (blurred SSIM: is the composition the same?),
* color (histogram + dominant palette: is it the same palette at the same
* coverage?), detail (high-frequency energy ratio: did the material survive,
* or did an illustration become a gradient?), bands (do the horizontal
* sections line up?). `overall` weights them 0.35 / 0.25 / 0.25 / 0.15.
*
* Regions come from --spec (comp-spec.mjs output: normalized boxes) or, with
* none, from the comp's own horizontal bands, so the per-region crops exist
* either way. Every region row carries the same four scores plus `verdict`:
* match (>= 0.8), drift (>= 0.6), missing (detail ratio < 0.35 with structure
* < 0.6), or contradicted (everything else). The words are the finish
* reviewer's fidelity vocabulary on purpose.
*
* Exit codes: 0 measured (and above threshold when one is given), 1 usage or
* unreadable input, 3 below threshold.
*/
import fs from 'node:fs';
import path from 'node:path';
import { decodePng, encodePng } from './lib/png.mjs';
import { crop, resize, fit, blit, createImage, fillRect, strokeRect, drawLabel } from './lib/raster.mjs';
import { structureScore, colorScore, detailScore, diffMap, horizontalBands, bandScore, dominantColors } from './lib/image-metrics.mjs';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = process.argv[i + 1];
return v && !v.startsWith('--') ? v : fallback;
}
const flag = (name) => process.argv.includes(`--${name}`);
export function readPng(file) {
return decodePng(fs.readFileSync(file));
}
/** Scale the build to the comp's width; take the top comp-height rows (align=top) or squash (align=stretch). */
export function alignBuild(comp, build, align = 'top') {
if (align === 'stretch') return resize(build, comp.width, comp.height);
const scaled = build.width === comp.width ? build : resize(build, comp.width, Math.round((build.height / build.width) * comp.width));
if (scaled.height === comp.height) return scaled;
if (scaled.height > comp.height) return crop(scaled, 0, 0, comp.width, comp.height);
// shorter than the comp: pad with white so a short page reads as missing content, not as a resize
const out = createImage(comp.width, comp.height, [255, 255, 255, 255]);
blit(out, scaled, 0, 0);
return out;
}
/** Weights per region kind: what a region is made of decides what losing it looks like. */
const WEIGHTS = {
default: { structure: 0.35, color: 0.25, detail: 0.25, bands: 0.15 },
plate: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 },
image: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 },
texture: { structure: 0.15, color: 0.35, detail: 0.5, bands: 0 },
text: { structure: 0.5, color: 0.25, detail: 0.15, bands: 0.1 },
control: { structure: 0.45, color: 0.35, detail: 0.2, bands: 0 },
};
export function scorePair(a, b, kind = null) {
const structure = structureScore(a, b);
const color = colorScore(a, b);
const detail = detailScore(a, b);
const bandsA = horizontalBands(a), bandsB = horizontalBands(b);
const bands = bandScore(bandsA, bandsB);
const w = WEIGHTS[kind] || WEIGHTS.default;
const overall = w.structure * structure + w.color * color.score + w.detail * detail.score + w.bands * bands;
return {
overall: r4(overall),
structure: r4(structure),
color: r4(color.score),
colorIntersection: r4(color.intersection),
paletteMatch: r4(color.paletteMatch),
detail: r4(detail.score),
detailAdded: r4(detail.addedFraction),
bands: r4(bands),
_detail: detail,
_bands: { comp: bandsA, build: bandsB },
};
}
export function verdictFor(s, kind = null) {
const painted = kind === 'plate' || kind === 'image' || kind === 'texture';
if (painted && s.detail < 0.5) return 'missing';
if (s.detail < 0.35 && s.structure < 0.6) return 'missing';
if (s.overall >= 0.8) return 'match';
if (s.overall >= 0.6) return 'drift';
return 'contradicted';
}
const r4 = (v) => Math.round(v * 10000) / 10000;
/** Regions from a spec (normalized boxes) or derived from the comp's bands. */
export function resolveRegions(comp, spec) {
const regions = [];
if (spec && Array.isArray(spec.regions) && spec.regions.length) {
for (const r of spec.regions) {
const box = r.box || r;
if ([box.x, box.y, box.w, box.h].some((v) => typeof v !== 'number')) continue;
regions.push({ id: r.id || `region-${regions.length + 1}`, x: box.x, y: box.y, w: box.w, h: box.h, kind: r.kind || null });
}
if (regions.length) return regions;
}
const bands = horizontalBands(comp).filter((b) => b.strength > 0.2);
const cuts = [0, ...bands.map((b) => b.y), 1].filter((v, i, arr) => i === 0 || v - arr[i - 1] > 0.06);
if (cuts[cuts.length - 1] !== 1) cuts.push(1);
for (let i = 0; i + 1 < cuts.length; i++) {
regions.push({ id: `band-${i + 1}`, x: 0, y: cuts[i], w: 1, h: cuts[i + 1] - cuts[i], kind: 'band' });
}
if (regions.length < 2) {
return [
{ id: 'top', x: 0, y: 0, w: 1, h: 0.5, kind: 'band' },
{ id: 'bottom', x: 0, y: 0.5, w: 1, h: 0.5, kind: 'band' },
];
}
return regions;
}
/** Crop a normalized region; regions thinner than 48px in either axis are grown to that so tiny strips do not swing on subpixel noise. */
function regionCrop(img, r) {
const minPx = 48;
let x = r.x * img.width, y = r.y * img.height, w = r.w * img.width, h = r.h * img.height;
if (h < minPx) { y -= (minPx - h) / 2; h = minPx; }
if (w < minPx) { x -= (minPx - w) / 2; w = minPx; }
return crop(img, x, y, w, h);
}
const HEAT_LABEL = { match: [40, 160, 80, 255], drift: [220, 160, 30, 255], missing: [200, 40, 40, 255], contradicted: [200, 40, 40, 255] };
export function renderSideBySide(comp, build, label, score) {
const gap = 24, pad = 48;
const targetW = Math.min(comp.width, 1400);
const a = fit(comp, targetW, 100000), b = resize(build, a.width, a.height);
const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 24, [24, 24, 28, 255]);
blit(out, a, pad, pad + 24);
blit(out, b, pad + a.width + gap, pad + 24);
drawLabel(out, 'COMP', pad, pad - 4, { scale: 2 });
drawLabel(out, `BUILD ${label ? label.toUpperCase() : ''}`.trim(), pad + a.width + gap, pad - 4, { scale: 2 });
const s = `OVERALL ${(score.overall * 100).toFixed(0)}% STRUCT ${(score.structure * 100).toFixed(0)}% COLOR ${(score.color * 100).toFixed(0)}% DETAIL ${(score.detail * 100).toFixed(0)}% BANDS ${(score.bands * 100).toFixed(0)}%`;
drawLabel(out, s, pad, out.height - pad + 8, { scale: 2, bg: HEAT_LABEL[verdictFor(score)] });
return out;
}
export function renderHeatmap(comp, build) {
const map = diffMap(comp, build);
const base = resize(build, map.width, map.height);
const out = { width: base.width, height: base.height, data: new Uint8Array(base.data) };
for (let i = 0, p = 0; i < map.data.length; i++, p += 4) {
const d = map.data[i];
if (d < 0.12) { // dim what matches so wrong stands out
out.data[p] = out.data[p] * 0.55 + 255 * 0.45 * 0.2; out.data[p + 1] = out.data[p + 1] * 0.55; out.data[p + 2] = out.data[p + 2] * 0.55; continue;
}
const a = Math.min(1, (d - 0.12) / 0.5);
out.data[p] = out.data[p] * (1 - a) + 235 * a; out.data[p + 1] = out.data[p + 1] * (1 - a) + 40 * a; out.data[p + 2] = out.data[p + 2] * (1 - a) + 40 * a;
}
const scaled = resize(out, comp.width, comp.height);
drawLabel(scaled, 'DIFF: RED = DIFFERS FROM COMP', 12, 12, { scale: 2 });
return scaled;
}
export function renderRegionPair(compCrop, buildCrop, id, score) {
const gap = 16, pad = 12;
const maxW = 700;
const a = fit(compCrop, maxW, 700, true), b = resize(buildCrop, a.width, a.height);
const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 30, [24, 24, 28, 255]);
blit(out, a, pad, pad + 30);
blit(out, b, pad + a.width + gap, pad + 30);
const v = verdictFor(score);
drawLabel(out, `${id.toUpperCase()} COMP`, pad, pad, { scale: 2 });
drawLabel(out, `BUILD ${v.toUpperCase()} ${(score.overall * 100).toFixed(0)}%`, pad + a.width + gap, pad, { scale: 2, bg: HEAT_LABEL[v] });
return out;
}
export function compare({ comp, build, spec = null, align = 'top', label = '' }) {
const aligned = alignBuild(comp, build, align);
const whole = scorePair(comp, aligned);
const regions = resolveRegions(comp, spec).map((r) => {
const a = regionCrop(comp, r), b = regionCrop(aligned, r);
const s = scorePair(a, b, r.kind);
return { ...r, score: strip(s), verdict: verdictFor(s, r.kind), _a: a, _b: b };
});
const compPalette = dominantColors(comp), buildPalette = dominantColors(aligned);
return { label, align, whole: strip(whole), regions, aligned, compPalette, buildPalette, _whole: whole };
}
function strip(s) {
const { _detail, _bands, ...rest } = s;
return rest;
}
export function writeArtifacts(result, comp, outDir) {
fs.mkdirSync(path.join(outDir, 'regions'), { recursive: true });
const side = renderSideBySide(comp, result.aligned, result.label, result.whole);
fs.writeFileSync(path.join(outDir, 'side-by-side.png'), encodePng(side));
fs.writeFileSync(path.join(outDir, 'heatmap.png'), encodePng(renderHeatmap(comp, result.aligned)));
const regionFiles = [];
for (const r of result.regions) {
const file = path.join(outDir, 'regions', `${r.id}.png`);
fs.writeFileSync(file, encodePng(renderRegionPair(r._a, r._b, r.id, r.score)));
regionFiles.push(file);
}
return { sideBySide: path.join(outDir, 'side-by-side.png'), heatmap: path.join(outDir, 'heatmap.png'), regionFiles };
}
export function buildReport(result, files, meta) {
return {
tool: 'comp-diff',
version: 1,
createdAt: new Date().toISOString(),
...meta,
align: result.align,
overall: result.whole.overall,
verdict: verdictFor(result.whole),
scores: result.whole,
palette: { comp: result.compPalette.map(({ hex, coverage }) => ({ hex, coverage })), build: result.buildPalette.map(({ hex, coverage }) => ({ hex, coverage })) },
regions: result.regions.map(({ _a, _b, ...r }) => r),
files,
};
}
function summarize(report) {
const lines = [];
lines.push(`COMP-DIFF ${report.label ? `[${report.label}] ` : ''}overall ${(report.overall * 100).toFixed(0)}% (${report.verdict}) structure ${(report.scores.structure * 100).toFixed(0)}% color ${(report.scores.color * 100).toFixed(0)}% detail ${(report.scores.detail * 100).toFixed(0)}% bands ${(report.scores.bands * 100).toFixed(0)}%`);
lines.push(`PALETTE comp ${report.palette.comp.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
lines.push(`PALETTE build ${report.palette.build.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
for (const r of report.regions) {
lines.push(`REGION ${r.id.padEnd(18)} ${r.verdict.padEnd(12)} ${(r.score.overall * 100).toFixed(0).padStart(3)}% structure ${(r.score.structure * 100).toFixed(0).padStart(3)}% color ${(r.score.color * 100).toFixed(0).padStart(3)}% detail ${(r.score.detail * 100).toFixed(0).padStart(3)}%${r.score.detailAdded > 0.25 ? ' +invented detail' : ''}`);
}
if (report.files) {
lines.push(`FILES side-by-side ${report.files.sideBySide}`);
lines.push(`FILES heatmap ${report.files.heatmap}`);
lines.push(`FILES regions ${report.files.regionFiles.length} under ${path.dirname(report.files.regionFiles[0] || report.files.heatmap)}`);
}
const worst = [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3);
if (worst.length) lines.push(`WORST ${worst.map((r) => `${r.id} (${r.verdict}, ${(r.score.overall * 100).toFixed(0)}%)`).join('; ')}`);
lines.push('OPEN the side-by-side and the worst region pairs before deciding anything; the numbers rank, the crops decide.');
return lines.join('\n');
}
async function main() {
const compPath = arg('comp'), buildPath = arg('build');
if (!compPath || !buildPath) {
console.error('usage: comp-diff.mjs --comp <png> --build <png> [--spec spec.json] [--out-dir dir] [--align top|stretch] [--label name] [--threshold 0.75] [--json]');
process.exit(1);
}
let comp, build;
try { comp = readPng(compPath); } catch (e) { console.error(`comp-diff: cannot read comp ${compPath}: ${e.message}`); process.exit(1); }
try { build = readPng(buildPath); } catch (e) { console.error(`comp-diff: cannot read build ${buildPath}: ${e.message}`); process.exit(1); }
let spec = null;
const specPath = arg('spec');
if (specPath) {
try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); } catch (e) { console.error(`comp-diff: cannot read spec ${specPath}: ${e.message}`); process.exit(1); }
}
const outDir = arg('out-dir', path.join(path.dirname(buildPath), 'diff'));
const label = arg('label', path.basename(buildPath, '.png'));
const result = compare({ comp, build, spec, align: arg('align', 'top'), label });
const files = flag('no-files') ? null : writeArtifacts(result, comp, outDir);
const report = buildReport(result, files, { label, comp: compPath, build: buildPath, spec: specPath || null, compSize: `${comp.width}x${comp.height}`, buildSize: `${build.width}x${build.height}` });
if (files) fs.writeFileSync(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2));
if (flag('json')) console.log(JSON.stringify(report, null, 2));
else console.log(summarize(report));
const threshold = arg('threshold') ? parseFloat(arg('threshold')) : null;
if (threshold != null && report.overall < threshold) {
if (!flag('json')) console.log(`BELOW THRESHOLD ${(threshold * 100).toFixed(0)}%: the reproduction is not done. Fix the worst regions and re-run; do not build past the hero.`);
process.exit(3);
}
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
if (isMain) main();
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env node
/**
* comp-spec: turn an approved comp into a measured build spec, so the build
* codes against numbers and crops instead of a memory of the image.
*
* Step 1, look at the comp with a coordinate grid on it:
* node comp-spec.mjs --comp .impeccable/mocks/approved.png --grid
* writes .impeccable/build/comp-grid.png (10x10 labeled grid, A-J / 0-9)
* and prints the measured palette and horizontal bands. Open the grid
* image and name every salient region by its grid span.
*
* Step 2, write the regions file (JSON) and measure it:
* node comp-spec.mjs --comp <comp> --regions regions.json
* regions.json: { "regions": [ { "id": "exploded-plate", "kind": "plate",
* "grid": "E0:J4", "note": "exploded carburetor line drawing" }, ... ] }
* `grid` is "<colrow>:<colrow>" inclusive (A0 top-left cell to J9 bottom
* right); `box` { x, y, w, h } normalized 0..1 is accepted instead. `kind`
* is one of plate | image | texture | text | control | chrome | band.
* Writes .impeccable/build/spec.json: every region with its normalized
* box, pixel box, sampled palette, detail energy, and its medium: raster
* for plate / image / texture (produced as a plate, never CSS), semantic
* for text / control / chrome. `--auto` proposes band regions from the
* comp itself when you have no regions file yet.
*
* Step 3, use it:
* node comp-spec.mjs --print # compact spec for the build thread
* node comp-spec.mjs --crop exploded-plate --out tmp/plate-src.png [--scale 2]
* crops the region from the comp (reference for a plate regeneration; a
* crop is never a shipping asset, its resolution is comp grade)
* node comp-spec.mjs --plate-prompt exploded-plate # the regeneration prompt for that region
*
* comp-diff.mjs reads the same spec (`--spec`) so its region rows and this
* file's rows are the same rows.
*/
import fs from 'node:fs';
import path from 'node:path';
import { decodePng, encodePng } from './lib/png.mjs';
import { crop, resize, fillRect, strokeRect, drawLabel, drawText } from './lib/raster.mjs';
import { dominantColors, horizontalBands, detailGrid } from './lib/image-metrics.mjs';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = process.argv[i + 1];
return v && !v.startsWith('--') ? v : fallback;
}
const flag = (name) => process.argv.includes(`--${name}`);
export const BUILD_DIR = path.join('.impeccable', 'build');
export const SPEC_PATH = path.join(BUILD_DIR, 'spec.json');
export const GRID_PATH = path.join(BUILD_DIR, 'comp-grid.png');
export const PLATES_DIR = path.join('assets', 'plates');
export const RASTER_KINDS = new Set(['plate', 'image', 'texture']);
export const KINDS = new Set(['plate', 'image', 'texture', 'text', 'control', 'chrome', 'band']);
const COLS = 'ABCDEFGHIJ';
/** "E0:J4" -> normalized box (inclusive cell span on a 10x10 grid). */
export function gridToBox(span) {
const m = /^([A-J])(\d):([A-J])(\d)$/i.exec(String(span).trim());
if (!m) throw new Error(`grid span "${span}" is not <colrow>:<colrow>, e.g. E0:J4`);
const c0 = COLS.indexOf(m[1].toUpperCase()), r0 = +m[2], c1 = COLS.indexOf(m[3].toUpperCase()), r1 = +m[4];
const x0 = Math.min(c0, c1), x1 = Math.max(c0, c1), y0 = Math.min(r0, r1), y1 = Math.max(r0, r1);
return { x: x0 / 10, y: y0 / 10, w: (x1 - x0 + 1) / 10, h: (y1 - y0 + 1) / 10 };
}
export function renderGrid(comp) {
const targetW = Math.min(1536, comp.width);
const img = resize(comp, targetW, Math.round((comp.height / comp.width) * targetW));
const cw = img.width / 10, ch = img.height / 10;
const line = [255, 40, 40, 200];
for (let i = 1; i < 10; i++) {
fillRect(img, Math.round(i * cw), 0, 1, img.height, line);
fillRect(img, 0, Math.round(i * ch), img.width, 1, line);
}
for (let r = 0; r < 10; r++) for (let c = 0; c < 10; c++) {
drawLabel(img, `${COLS[c]}${r}`, Math.round(c * cw) + 3, Math.round(r * ch) + 3, { scale: 2, bg: [0, 0, 0, 170], fg: [255, 230, 120, 255] });
}
return img;
}
function paletteOf(img) {
return dominantColors(img, 5).map(({ hex, coverage }) => ({ hex, coverage }));
}
function energyOf(img) {
const g = detailGrid(img, 4, 4, 256);
let s = 0; for (const v of g.cells) s += v;
return s / g.cells.length;
}
export function measureRegions(comp, regionsInput, compPath) {
const regions = [];
const seen = new Set();
for (const raw of regionsInput.regions || []) {
if (!raw.id) throw new Error('every region needs an id');
if (seen.has(raw.id)) throw new Error(`duplicate region id ${raw.id}`);
seen.add(raw.id);
const kind = raw.kind && KINDS.has(raw.kind) ? raw.kind : 'band';
const box = raw.box && typeof raw.box.x === 'number' ? raw.box : gridToBox(raw.grid);
const px = { x: Math.round(box.x * comp.width), y: Math.round(box.y * comp.height), w: Math.round(box.w * comp.width), h: Math.round(box.h * comp.height) };
const c = crop(comp, px.x, px.y, px.w, px.h);
const energy = energyOf(c);
const raster = RASTER_KINDS.has(kind);
regions.push({
id: raw.id,
kind,
note: raw.note || null,
box: { x: r4(box.x), y: r4(box.y), w: r4(box.w), h: r4(box.h) },
px,
aspect: r4(px.w / px.h),
palette: paletteOf(c),
detail: { energy: r4(energy) },
medium: raw.medium || (raster ? 'raster' : 'semantic'),
plate: raster ? (raw.plate || path.join(PLATES_DIR, `${raw.id}.png`)) : null,
text: raw.text || null,
});
}
return {
tool: 'comp-spec',
version: 1,
createdAt: new Date().toISOString(),
comp: compPath,
compSize: { width: comp.width, height: comp.height },
aspect: r4(comp.width / comp.height),
orientation: comp.width >= comp.height ? 'landscape' : 'portrait',
palette: paletteOf(comp),
bands: horizontalBands(comp).filter((b) => b.strength > 0.2).map((b) => ({ y: r4(b.y), strength: r4(b.strength) })),
regions,
};
}
/** Propose regions from the comp's bands when no regions file exists yet. */
export function autoRegions(comp) {
const bands = horizontalBands(comp).filter((b) => b.strength > 0.2);
const cuts = [0, ...bands.map((b) => b.y), 1].filter((v, i, arr) => i === 0 || v - arr[i - 1] > 0.06);
if (cuts[cuts.length - 1] !== 1) cuts.push(1);
const regions = [];
for (let i = 0; i + 1 < cuts.length; i++) regions.push({ id: `band-${i + 1}`, kind: 'band', box: { x: 0, y: cuts[i], w: 1, h: cuts[i + 1] - cuts[i] } });
return { regions };
}
const r4 = (v) => Math.round(v * 10000) / 10000;
export function platePrompt(spec, region) {
const world = spec.palette.slice(0, 3).map((c) => c.hex).join(', ');
const kindLine = region.kind === 'texture'
? 'This is a seamless surface texture. Output a tileable texture plate with no objects, no text, no vignette.'
: region.kind === 'image'
? 'This is a photographic or illustrated image region. Output the same subject, same framing, same lighting.'
: 'This is a designed illustration plate. Output the same drawing, same style, same line weight and shading.';
return [
'Use the provided crop as the approved visual reference and recreate it as a clean production asset at the target aspect ratio.',
kindLine,
`Preserve silhouette, composition, perspective, palette (${world}), lighting, material, and texture exactly.`,
'Remove every piece of UI text, label, caption, button, and interface chrome that is not part of the artwork itself.',
'Remove letterboxing, borders, card corners, drop shadows, and any layout background that the page will draw in code.',
'Do not add objects. Do not change the concept. Do not restyle. Fill the whole frame; no margins.',
region.note ? `Region: ${region.note}.` : '',
].filter(Boolean).join(' ');
}
export function printSpec(spec) {
const lines = [];
lines.push(`SPEC comp ${spec.comp} ${spec.compSize.width}x${spec.compSize.height} ${spec.orientation}`);
lines.push(`PALETTE ${spec.palette.map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
lines.push(`BANDS ${spec.bands.map((b) => `${Math.round(b.y * 100)}%`).join(' ') || 'none'}`);
for (const r of spec.regions) {
const b = r.box;
lines.push(`REGION ${r.id.padEnd(18)} ${r.kind.padEnd(8)} ${r.medium.padEnd(8)} box x${Math.round(b.x * 100)}% y${Math.round(b.y * 100)}% w${Math.round(b.w * 100)}% h${Math.round(b.h * 100)}% (${r.px.w}x${r.px.h}px, ${r.aspect}:1) palette ${r.palette.slice(0, 3).map((c) => c.hex).join(' ')}${r.plate ? ` plate ${r.plate}` : ''}${r.note ? ` # ${r.note}` : ''}`);
}
const plates = spec.regions.filter((r) => r.medium === 'raster');
lines.push(`PLATES ${plates.length} to produce: ${plates.map((r) => r.id).join(', ') || 'none'}`);
lines.push('RULE anything not in this list does not exist on the page: no borders, rules, chrome, or containers the comp does not show. Every raster region ships as its plate, never as CSS.');
return lines.join('\n');
}
export function loadSpec(specPath = SPEC_PATH) {
if (!fs.existsSync(specPath)) return null;
return JSON.parse(fs.readFileSync(specPath, 'utf8'));
}
async function main() {
const specPath = arg('spec', SPEC_PATH);
if (flag('print')) {
const spec = loadSpec(specPath);
if (!spec) { console.error(`comp-spec: no spec at ${specPath}; run with --comp <png> --regions <json> first`); process.exit(1); }
console.log(printSpec(spec));
return;
}
if (arg('plate-prompt')) {
const spec = loadSpec(specPath);
if (!spec) { console.error(`comp-spec: no spec at ${specPath}`); process.exit(1); }
const region = spec.regions.find((r) => r.id === arg('plate-prompt'));
if (!region) { console.error(`comp-spec: no region ${arg('plate-prompt')}`); process.exit(1); }
console.log(platePrompt(spec, region));
return;
}
if (arg('crop')) {
const spec = loadSpec(specPath);
if (!spec) { console.error(`comp-spec: no spec at ${specPath}`); process.exit(1); }
const region = spec.regions.find((r) => r.id === arg('crop'));
if (!region) { console.error(`comp-spec: no region ${arg('crop')}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); }
const comp = decodePng(fs.readFileSync(spec.comp));
let c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h);
const scale = parseFloat(arg('scale', '1'));
if (scale > 1) c = resize(c, c.width * scale, c.height * scale);
const out = arg('out', path.join(BUILD_DIR, 'crops', `${region.id}.png`));
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, encodePng(c, { text: { 'impeccable:crop-of': `${spec.comp}#${region.id}` } }));
console.log(`CROP ${out} (${c.width}x${c.height}) region ${region.id} of ${spec.comp}. Reference only: regenerate the plate from it, never ship it.`);
return;
}
const compPath = arg('comp');
if (!compPath) {
console.error('usage: comp-spec.mjs --comp <png> (--grid | --regions <json> | --auto) [--spec out.json]\n comp-spec.mjs --print | --crop <id> [--out file] [--scale n] | --plate-prompt <id>');
process.exit(1);
}
let comp;
try { comp = decodePng(fs.readFileSync(compPath)); } catch (e) { console.error(`comp-spec: cannot read ${compPath}: ${e.message}`); process.exit(1); }
if (flag('grid')) {
fs.mkdirSync(path.dirname(GRID_PATH), { recursive: true });
fs.writeFileSync(GRID_PATH, encodePng(renderGrid(comp)));
console.log(`GRID ${GRID_PATH} (${comp.width}x${comp.height} comp; cells A0 top-left to J9 bottom-right)`);
console.log(`PALETTE ${paletteOf(comp).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
console.log(`BANDS ${horizontalBands(comp).filter((b) => b.strength > 0.2).map((b) => `${Math.round(b.y * 100)}%`).join(' ') || 'none'}`);
console.log('NEXT open the grid image, then write regions.json naming every salient region by grid span (e.g. "E0:J4") with kind plate|image|texture|text|control|chrome and a one-line note, and run --regions regions.json. Name every illustration, photo, and texture as its own region: those become plates.');
return;
}
let regionsInput;
if (arg('regions')) {
try { regionsInput = JSON.parse(fs.readFileSync(arg('regions'), 'utf8')); } catch (e) { console.error(`comp-spec: cannot read regions ${arg('regions')}: ${e.message}`); process.exit(1); }
} else if (flag('auto')) {
regionsInput = autoRegions(comp);
} else {
console.error('comp-spec: pass --grid to get the coordinate grid, then --regions <json> (or --auto for band regions)');
process.exit(1);
}
let spec;
try { spec = measureRegions(comp, regionsInput, compPath); } catch (e) { console.error(`comp-spec: ${e.message}`); process.exit(1); }
fs.mkdirSync(path.dirname(specPath), { recursive: true });
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
console.log(`WROTE ${specPath}`);
console.log(printSpec(spec));
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
if (isMain) main();
+302
View File
@@ -0,0 +1,302 @@
/**
* Perceptual measures for comparing a comp with a build screenshot. Pure
* functions over `{ width, height, data }` RGBA images; no I/O.
*
* Three families, because a build fails a comp in three separable ways:
*
* - structure: is the composition the same? Measured as SSIM over a blurred
* grayscale downsample, which forgives font hinting and a few pixels of
* drift and punishes a moved, missing, or invented region.
* - color: is the palette and its distribution the same? Histogram
* intersection in a coarse quantized space plus a dominant-color extraction,
* so a navy page built from a bone comp fails even if the shapes match.
* - detail: is the material there? Local high-frequency energy per cell. A
* comp with an illustration, texture, or photograph carries energy a flat
* CSS stand-in does not; the ratio build/comp per region is the most direct
* measure of "the plate got replaced by a gradient".
*/
import { resize } from './raster.mjs';
export function toGray(img) {
const g = new Float32Array(img.width * img.height);
for (let i = 0, p = 0; i < g.length; i++, p += 4) {
const a = img.data[p + 3] / 255;
// composite over white so transparent regions read as the page ground
const r = img.data[p] * a + 255 * (1 - a), gg = img.data[p + 1] * a + 255 * (1 - a), b = img.data[p + 2] * a + 255 * (1 - a);
g[i] = 0.2126 * r + 0.7152 * gg + 0.0722 * b;
}
return { width: img.width, height: img.height, data: g };
}
/** Separable box blur on a float gray image, radius r. */
export function blurGray(gray, r) {
if (r <= 0) return gray;
const { width, height, data } = gray;
const tmp = new Float32Array(data.length), out = new Float32Array(data.length);
const win = 2 * r + 1;
for (let y = 0; y < height; y++) {
let acc = 0;
for (let x = -r; x <= r; x++) acc += data[y * width + Math.min(width - 1, Math.max(0, x))];
for (let x = 0; x < width; x++) {
tmp[y * width + x] = acc / win;
const outX = x - r, inX = x + r + 1;
acc += data[y * width + Math.min(width - 1, inX)] - data[y * width + Math.max(0, outX)];
}
}
for (let x = 0; x < width; x++) {
let acc = 0;
for (let y = -r; y <= r; y++) acc += tmp[Math.min(height - 1, Math.max(0, y)) * width + x];
for (let y = 0; y < height; y++) {
out[y * width + x] = acc / win;
const outY = y - r, inY = y + r + 1;
acc += tmp[Math.min(height - 1, inY) * width + x] - tmp[Math.max(0, outY) * width + x];
}
}
return { width, height, data: out };
}
/** Global SSIM between two same-size gray images using an 8x8 window grid. */
export function ssim(a, b, win = 8) {
if (a.width !== b.width || a.height !== b.height) throw new Error('ssim: size mismatch');
const C1 = (0.01 * 255) ** 2, C2 = (0.03 * 255) ** 2;
let total = 0, n = 0;
for (let y = 0; y + win <= a.height; y += win) {
for (let x = 0; x + win <= a.width; x += win) {
let ma = 0, mb = 0;
for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; ma += a.data[i]; mb += b.data[i]; }
ma /= win * win; mb /= win * win;
let va = 0, vb = 0, cov = 0;
for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; const da = a.data[i] - ma, db = b.data[i] - mb; va += da * da; vb += db * db; cov += da * db; }
va /= win * win - 1; vb /= win * win - 1; cov /= win * win - 1;
total += ((2 * ma * mb + C1) * (2 * cov + C2)) / ((ma * ma + mb * mb + C1) * (va + vb + C2));
n++;
}
}
return n ? total / n : 1;
}
/** SSIM of `a` against `b` shifted by (dx, dy); the overlap is compared, edges dropped. */
function ssimShifted(a, b, dx, dy, win) {
const w = a.width - Math.abs(dx), h = a.height - Math.abs(dy);
if (w < win || h < win) return 0;
const sa = { width: w, height: h, data: new Float32Array(w * h) };
const sb = { width: w, height: h, data: new Float32Array(w * h) };
const ax = Math.max(0, -dx), ay = Math.max(0, -dy), bx = Math.max(0, dx), by = Math.max(0, dy);
for (let y = 0; y < h; y++) {
sa.data.set(a.data.subarray((y + ay) * a.width + ax, (y + ay) * a.width + ax + w), y * w);
sb.data.set(b.data.subarray((y + by) * b.width + bx, (y + by) * b.width + bx + w), y * w);
}
return ssim(sa, sb, win);
}
/**
* Structure score 0..1: SSIM over blurred grayscale at a fixed working width,
* taking the best of a small translation search so a composition that sits a
* few pixels off (a different masthead height, a scrollbar) is not read as a
* different composition. Shifts up to ~4% of the width are forgiven; a moved
* region is not.
*/
export function structureScore(imgA, imgB, workWidth = 256) {
const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth));
const a = blurGray(toGray(resize(imgA, workWidth, h)), 2);
const b = blurGray(toGray(resize(imgB, workWidth, h)), 2);
const win = Math.min(8, Math.max(2, Math.floor(Math.min(workWidth, h) / 8)));
let best = ssim(a, b, win);
const maxShift = Math.max(2, Math.round(workWidth * 0.04));
for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
if (!dx && !dy) continue;
best = Math.max(best, ssimShifted(a, b, Math.round(dx), Math.round(dy), win));
}
}
return Math.max(0, Math.min(1, best));
}
// ---- color -----------------------------------------------------------------
function rgbToLab(r, g, b) {
const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const R = lin(r), G = lin(g), B = lin(b);
const X = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
const Y = (R * 0.2126 + G * 0.7152 + B * 0.0722) / 1.0;
const Z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
const fx = f(X), fy = f(Y), fz = f(Z);
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
}
export function deltaE(lab1, lab2) {
return Math.hypot(lab1[0] - lab2[0], lab1[1] - lab2[1], lab1[2] - lab2[2]);
}
/** Quantized color histogram (4 bits per channel = 4096 bins), normalized. */
export function colorHistogram(img, sampleStep = 2) {
const bins = new Float32Array(4096);
let n = 0;
for (let y = 0; y < img.height; y += sampleStep) {
for (let x = 0; x < img.width; x += sampleStep) {
const p = (y * img.width + x) * 4;
if (img.data[p + 3] < 16) continue;
const key = ((img.data[p] >> 4) << 8) | ((img.data[p + 1] >> 4) << 4) | (img.data[p + 2] >> 4);
bins[key]++; n++;
}
}
if (n) for (let i = 0; i < bins.length; i++) bins[i] /= n;
return bins;
}
export function histogramIntersection(h1, h2) {
let s = 0;
for (let i = 0; i < h1.length; i++) s += Math.min(h1[i], h2[i]);
return s;
}
/**
* Dominant colors: merge histogram bins greedily by Lab distance into up to
* `k` clusters and return them sorted by coverage.
*/
export function dominantColors(img, k = 6, sampleStep = 3) {
const hist = colorHistogram(img, sampleStep);
const entries = [];
for (let i = 0; i < hist.length; i++) if (hist[i] > 0.0005) entries.push({ key: i, w: hist[i] });
entries.sort((a, b) => b.w - a.w);
const clusters = [];
for (const e of entries) {
const r = ((e.key >> 8) & 15) * 16 + 8, g = ((e.key >> 4) & 15) * 16 + 8, b = (e.key & 15) * 16 + 8;
const lab = rgbToLab(r, g, b);
let best = null, bestD = Infinity;
for (const c of clusters) { const d = deltaE(c.lab, lab); if (d < bestD) { bestD = d; best = c; } }
if (best && bestD < 14) {
const tw = best.w + e.w;
best.rgb = [(best.rgb[0] * best.w + r * e.w) / tw, (best.rgb[1] * best.w + g * e.w) / tw, (best.rgb[2] * best.w + b * e.w) / tw];
best.lab = rgbToLab(...best.rgb); best.w = tw;
} else clusters.push({ rgb: [r, g, b], lab, w: e.w });
}
clusters.sort((a, b) => b.w - a.w);
const top = clusters.slice(0, k);
const covered = top.reduce((s, c) => s + c.w, 0) || 1;
return top.map((c) => ({ hex: toHex(c.rgb), coverage: +(c.w / covered).toFixed(4), lab: c.lab }));
}
export function toHex(rgb) {
return '#' + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
}
/**
* Palette match 0..1: for each dominant comp color, coverage-weighted best
* Lab match in the build's dominant set (dE 0 -> 1, dE >= 40 -> 0).
*/
export function paletteMatch(compColors, buildColors) {
if (!compColors.length) return 1;
let s = 0, wsum = 0;
for (const c of compColors) {
let best = Infinity;
for (const b of buildColors) best = Math.min(best, deltaE(c.lab, b.lab));
s += c.coverage * Math.max(0, 1 - best / 40); wsum += c.coverage;
}
return wsum ? s / wsum : 1;
}
/** Color score 0..1: blend of histogram intersection and dominant-palette match. */
export function colorScore(imgA, imgB) {
const inter = histogramIntersection(colorHistogram(imgA), colorHistogram(imgB));
const pm = paletteMatch(dominantColors(imgA), dominantColors(imgB));
return { score: 0.35 * inter + 0.65 * pm, intersection: inter, paletteMatch: pm };
}
// ---- detail ----------------------------------------------------------------
/** Mean absolute gradient (Sobel-lite) per cell over a cols x rows grid. */
export function detailGrid(img, cols = 12, rows = 8, workWidth = 512) {
const h = Math.max(rows, Math.round((img.height / img.width) * workWidth));
const g = toGray(resize(img, workWidth, h));
const grid = new Float32Array(cols * rows);
const counts = new Float32Array(cols * rows);
for (let y = 1; y < g.height - 1; y++) {
const cy = Math.min(rows - 1, Math.floor((y / g.height) * rows));
for (let x = 1; x < g.width - 1; x++) {
const cx = Math.min(cols - 1, Math.floor((x / g.width) * cols));
const i = y * g.width + x;
const gx = Math.abs(g.data[i + 1] - g.data[i - 1]);
const gy = Math.abs(g.data[i + g.width] - g.data[i - g.width]);
grid[cy * cols + cx] += gx + gy; counts[cy * cols + cx]++;
}
}
for (let i = 0; i < grid.length; i++) grid[i] = counts[i] ? grid[i] / counts[i] : 0;
return { cols, rows, cells: grid };
}
/**
* Detail score 0..1 and per-cell ratio. Cells where the comp is nearly flat
* are ignored (nothing to lose); the score is the coverage-weighted mean of
* min(1, build/comp) over cells with comp energy, so extra detail in the
* build (invented chrome) is reported separately as `added`.
*/
export function detailScore(imgA, imgB, cols = 12, rows = 8) {
const a = detailGrid(imgA, cols, rows), b = detailGrid(imgB, cols, rows);
const floor = 1.5; // energy below this is a flat field at the 512px working width
let s = 0, w = 0, added = 0, addedW = 0;
const ratios = new Float32Array(cols * rows);
for (let i = 0; i < a.cells.length; i++) {
const ca = a.cells[i], cb = b.cells[i];
ratios[i] = ca > floor ? cb / ca : (cb > floor ? Infinity : 1);
if (ca > floor) { s += Math.min(1, cb / ca) * ca; w += ca; }
if (cb > ca * 1.8 && cb > floor * 2) { added += 1; }
addedW += 1;
}
return { score: w ? s / w : 1, addedFraction: addedW ? added / addedW : 0, comp: a, build: b, ratios };
}
// ---- pixel diff -----------------------------------------------------------
/** Per-pixel Lab-ish difference map (0..1) at a working width; blurred a little. */
export function diffMap(imgA, imgB, workWidth = 384) {
const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth));
const a = resize(imgA, workWidth, h), b = resize(imgB, workWidth, h);
const out = new Float32Array(workWidth * h);
for (let i = 0, p = 0; i < out.length; i++, p += 4) {
const dr = a.data[p] - b.data[p], dg = a.data[p + 1] - b.data[p + 1], db = a.data[p + 2] - b.data[p + 2];
out[i] = Math.min(1, Math.sqrt(dr * dr + dg * dg + db * db) / 200);
}
return blurGray({ width: workWidth, height: h, data: out }, 1);
}
// ---- bands (horizontal layout structure) ---------------------------------
/**
* Detect horizontal band boundaries: rows where the mean color changes
* sharply. Returns normalized y positions (0..1) with strengths. This is the
* "layout grid" read of a page: header / hero / index / footer as bands.
*/
export function horizontalBands(img, workWidth = 128, minGap = 0.02) {
const h = Math.max(16, Math.round((img.height / img.width) * workWidth));
const s = resize(img, workWidth, h);
const rowMean = new Float32Array(h * 3);
for (let y = 0; y < h; y++) {
let r = 0, g = 0, b = 0;
for (let x = 0; x < workWidth; x++) { const p = (y * workWidth + x) * 4; r += s.data[p]; g += s.data[p + 1]; b += s.data[p + 2]; }
rowMean[y * 3] = r / workWidth; rowMean[y * 3 + 1] = g / workWidth; rowMean[y * 3 + 2] = b / workWidth;
}
const edges = [];
for (let y = 1; y < h; y++) {
const d = Math.hypot(rowMean[y * 3] - rowMean[(y - 1) * 3], rowMean[y * 3 + 1] - rowMean[(y - 1) * 3 + 1], rowMean[y * 3 + 2] - rowMean[(y - 1) * 3 + 2]);
if (d > 18) edges.push({ y: y / h, strength: Math.min(1, d / 120) });
}
// merge close edges
const merged = [];
for (const e of edges) {
const last = merged[merged.length - 1];
if (last && e.y - last.y < minGap) { if (e.strength > last.strength) { last.y = e.y; last.strength = e.strength; } }
else merged.push({ ...e });
}
return merged;
}
/** Band agreement 0..1: fraction of comp bands with a build band within tolerance, and vice versa. */
export function bandScore(bandsA, bandsB, tol = 0.04) {
if (!bandsA.length && !bandsB.length) return 1;
const match = (from, to) => from.filter((a) => to.some((b) => Math.abs(a.y - b.y) <= tol)).length;
const recall = bandsA.length ? match(bandsA, bandsB) / bandsA.length : 1;
const precision = bandsB.length ? match(bandsB, bandsA) / bandsB.length : 1;
return 0.6 * recall + 0.4 * precision;
}
+250
View File
@@ -0,0 +1,250 @@
/**
* Dependency-free PNG decode/encode for the skill scripts.
*
* decodePng(buffer) -> { width, height, data } where data is RGBA8 (Uint8Array,
* width*height*4). Handles every color type (0, 2, 3, 4, 6), bit depths 1-16
* (16-bit is reduced to 8), all five filters, and Adam7 interlacing.
*
* encodePng({ width, height, data }) -> Buffer, RGBA8 in, 8-bit RGBA PNG out.
*
* Kept small on purpose: the skill scripts ship without npm dependencies, and
* comps (gpt-image PNGs) and screenshots (Playwright / harness PNGs) are the
* only formats the comp-fidelity tooling has to read.
*/
import zlib from 'node:zlib';
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const crcTable = (() => {
const t = new Uint32Array(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 >>> 0;
}
return t;
})();
function crc32(data) {
let c = 0xffffffff;
for (let i = 0; i < data.length; i++) c = crcTable[(c ^ data[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
export function isPng(buf) {
return buf && buf.length > 8 && buf.subarray(0, 8).equals(SIGNATURE);
}
function readChunks(buf) {
const chunks = [];
let pos = 8;
while (pos + 8 <= buf.length) {
const length = buf.readUInt32BE(pos);
const type = buf.toString('latin1', pos + 4, pos + 8);
const data = buf.subarray(pos + 8, pos + 8 + length);
chunks.push({ type, data });
pos += 12 + length;
if (type === 'IEND') break;
}
return chunks;
}
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
function paeth(a, b, c) {
const p = a + b - c;
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
/** Unfilter one pass of scanlines in place; returns the raw (unfiltered) bytes. */
function unfilter(raw, width, height, bpp, bitDepth, channels) {
const stride = Math.ceil((width * channels * bitDepth) / 8);
const out = new Uint8Array(stride * height);
let inPos = 0;
let prev = null;
for (let y = 0; y < height; y++) {
const filter = raw[inPos++];
const line = out.subarray(y * stride, (y + 1) * stride);
line.set(raw.subarray(inPos, inPos + stride));
inPos += stride;
switch (filter) {
case 0: break;
case 1: for (let i = bpp; i < stride; i++) line[i] = (line[i] + line[i - bpp]) & 0xff; break;
case 2: if (prev) for (let i = 0; i < stride; i++) line[i] = (line[i] + prev[i]) & 0xff; break;
case 3:
for (let i = 0; i < stride; i++) {
const left = i >= bpp ? line[i - bpp] : 0;
const up = prev ? prev[i] : 0;
line[i] = (line[i] + ((left + up) >> 1)) & 0xff;
}
break;
case 4:
for (let i = 0; i < stride; i++) {
const left = i >= bpp ? line[i - bpp] : 0;
const up = prev ? prev[i] : 0;
const ul = prev && i >= bpp ? prev[i - bpp] : 0;
line[i] = (line[i] + paeth(left, up, ul)) & 0xff;
}
break;
default: throw new Error(`png: unknown filter ${filter} on row ${y}`);
}
prev = line;
}
return { bytes: out, stride, consumed: inPos };
}
/** Read sample `index` (0-based across the row) from a packed scanline. */
function sampleReader(bitDepth) {
if (bitDepth === 8) return (line, i) => line[i];
if (bitDepth === 16) return (line, i) => line[i * 2]; // high byte
const perByte = 8 / bitDepth;
const mask = (1 << bitDepth) - 1;
const scale = 255 / mask;
return (line, i) => {
const byte = line[(i / perByte) | 0];
const shift = 8 - bitDepth * ((i % perByte) + 1);
return Math.round(((byte >> shift) & mask) * scale);
};
}
function writePixels(dst, dstWidth, bytes, stride, passWidth, passHeight, colorType, bitDepth, palette, trns, mapX, mapY) {
const channels = CHANNELS[colorType];
const read = sampleReader(bitDepth);
const rawIndex = bitDepth < 8 ? (line, i) => {
const perByte = 8 / bitDepth;
const mask = (1 << bitDepth) - 1;
const byte = line[(i / perByte) | 0];
const shift = 8 - bitDepth * ((i % perByte) + 1);
return (byte >> shift) & mask;
} : read;
for (let y = 0; y < passHeight; y++) {
const line = bytes.subarray(y * stride, (y + 1) * stride);
const dy = mapY(y);
for (let x = 0; x < passWidth; x++) {
const dx = mapX(x);
const o = (dy * dstWidth + dx) * 4;
let r, g, b, a = 255;
switch (colorType) {
case 0: {
r = g = b = read(line, x);
if (trns && trns.gray === rawIndex(line, x)) a = 0;
break;
}
case 2: {
r = read(line, x * 3); g = read(line, x * 3 + 1); b = read(line, x * 3 + 2);
break;
}
case 3: {
const idx = rawIndex(line, x);
r = palette[idx * 3]; g = palette[idx * 3 + 1]; b = palette[idx * 3 + 2];
if (trns && trns.alpha && idx < trns.alpha.length) a = trns.alpha[idx];
break;
}
case 4: {
r = g = b = read(line, x * 2); a = read(line, x * 2 + 1);
break;
}
case 6: {
r = read(line, x * 4); g = read(line, x * 4 + 1); b = read(line, x * 4 + 2); a = read(line, x * 4 + 3);
break;
}
default: throw new Error(`png: unsupported color type ${colorType}`);
}
dst[o] = r; dst[o + 1] = g; dst[o + 2] = b; dst[o + 3] = a;
}
}
return channels;
}
export function decodePng(buf) {
if (!isPng(buf)) throw new Error('png: not a PNG (bad signature)');
const chunks = readChunks(buf);
const ihdr = chunks.find((c) => c.type === 'IHDR');
if (!ihdr) throw new Error('png: missing IHDR');
const width = ihdr.data.readUInt32BE(0);
const height = ihdr.data.readUInt32BE(4);
const bitDepth = ihdr.data[8];
const colorType = ihdr.data[9];
const interlace = ihdr.data[12];
const channels = CHANNELS[colorType];
if (!channels) throw new Error(`png: unsupported color type ${colorType}`);
const palChunk = chunks.find((c) => c.type === 'PLTE');
const palette = palChunk ? palChunk.data : null;
const trnsChunk = chunks.find((c) => c.type === 'tRNS');
let trns = null;
if (trnsChunk) {
if (colorType === 3) trns = { alpha: trnsChunk.data };
else if (colorType === 0) trns = { gray: trnsChunk.data.readUInt16BE(0) >> (bitDepth === 16 ? 8 : 0) };
}
const idat = Buffer.concat(chunks.filter((c) => c.type === 'IDAT').map((c) => c.data));
const raw = zlib.inflateSync(idat);
const bpp = Math.max(1, Math.ceil((channels * bitDepth) / 8));
const data = new Uint8Array(width * height * 4);
const text = {};
for (const c of chunks) {
if (c.type === 'tEXt') {
const z = c.data.indexOf(0);
if (z > 0) text[c.data.toString('latin1', 0, z)] = c.data.toString('utf8', z + 1);
}
}
if (interlace === 0) {
const { bytes, stride } = unfilter(raw, width, height, bpp, bitDepth, channels);
writePixels(data, width, bytes, stride, width, height, colorType, bitDepth, palette, trns, (x) => x, (y) => y);
} else {
// Adam7
const passes = [
[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2],
];
let offset = 0;
for (const [sx, sy, dx, dy] of passes) {
const pw = Math.ceil((width - sx) / dx);
const ph = Math.ceil((height - sy) / dy);
if (pw <= 0 || ph <= 0) continue;
const { bytes, stride, consumed } = unfilter(raw.subarray(offset), pw, ph, bpp, bitDepth, channels);
offset += consumed;
writePixels(data, width, bytes, stride, pw, ph, colorType, bitDepth, palette, trns, (x) => sx + x * dx, (y) => sy + y * dy);
}
}
return { width, height, data, text };
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'latin1');
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crc]);
}
/**
* Encode RGBA8 to PNG. `text` (optional) is a map of tEXt keyword -> value.
* Uses filter type 0 on every row: comps and screenshots compress fine and the
* encoder stays trivial.
*/
export function encodePng({ width, height, data }, { text = null, level = 6 } = {}) {
if (data.length !== width * height * 4) throw new Error(`png: data length ${data.length} != ${width}x${height}x4`);
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (stride + 1)] = 0;
raw.set(data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
const parts = [SIGNATURE, chunk('IHDR', ihdr)];
if (text) {
for (const [k, v] of Object.entries(text)) {
parts.push(chunk('tEXt', Buffer.concat([Buffer.from(k, 'latin1'), Buffer.from([0]), Buffer.from(String(v), 'utf8')])));
}
}
parts.push(chunk('IDAT', zlib.deflateSync(raw, { level })));
parts.push(chunk('IEND', Buffer.alloc(0)));
return Buffer.concat(parts);
}
+194
View File
@@ -0,0 +1,194 @@
/**
* Small RGBA raster toolkit shared by the comp-fidelity scripts: crop, resize
* (area-averaging down, bilinear up), composite, fills, rectangles, and a
* bitmap-font label so composites can be captioned without a font stack.
*
* An image is `{ width, height, data }` with RGBA8 data (Uint8Array).
*/
export function createImage(width, height, fill = [0, 0, 0, 0]) {
const data = new Uint8Array(width * height * 4);
if (fill[0] || fill[1] || fill[2] || fill[3]) {
for (let i = 0; i < data.length; i += 4) { data[i] = fill[0]; data[i + 1] = fill[1]; data[i + 2] = fill[2]; data[i + 3] = fill[3]; }
}
return { width, height, data };
}
export function clampRect(img, x, y, w, h) {
const x0 = Math.max(0, Math.min(img.width, Math.round(x)));
const y0 = Math.max(0, Math.min(img.height, Math.round(y)));
const x1 = Math.max(x0, Math.min(img.width, Math.round(x + w)));
const y1 = Math.max(y0, Math.min(img.height, Math.round(y + h)));
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
export function crop(img, x, y, w, h) {
const r = clampRect(img, x, y, w, h);
const out = createImage(Math.max(1, r.w), Math.max(1, r.h));
for (let yy = 0; yy < r.h; yy++) {
const src = ((r.y + yy) * img.width + r.x) * 4;
out.data.set(img.data.subarray(src, src + r.w * 4), yy * out.width * 4);
}
return out;
}
/** Resize with area averaging when shrinking and bilinear when growing. */
export function resize(img, width, height) {
width = Math.max(1, Math.round(width));
height = Math.max(1, Math.round(height));
if (width === img.width && height === img.height) return { width, height, data: new Uint8Array(img.data) };
const out = createImage(width, height);
const sx = img.width / width, sy = img.height / height;
if (sx >= 1 && sy >= 1) {
for (let y = 0; y < height; y++) {
const y0 = Math.floor(y * sy), y1 = Math.min(img.height, Math.max(y0 + 1, Math.floor((y + 1) * sy)));
for (let x = 0; x < width; x++) {
const x0 = Math.floor(x * sx), x1 = Math.min(img.width, Math.max(x0 + 1, Math.floor((x + 1) * sx)));
let r = 0, g = 0, b = 0, a = 0, n = 0;
for (let yy = y0; yy < y1; yy++) {
let p = (yy * img.width + x0) * 4;
for (let xx = x0; xx < x1; xx++, p += 4) { r += img.data[p]; g += img.data[p + 1]; b += img.data[p + 2]; a += img.data[p + 3]; n++; }
}
const o = (y * width + x) * 4;
out.data[o] = r / n; out.data[o + 1] = g / n; out.data[o + 2] = b / n; out.data[o + 3] = a / n;
}
}
return out;
}
for (let y = 0; y < height; y++) {
const fy = Math.min(img.height - 1, (y + 0.5) * sy - 0.5);
const y0 = Math.max(0, Math.floor(fy)), y1 = Math.min(img.height - 1, y0 + 1), wy = fy - y0;
for (let x = 0; x < width; x++) {
const fx = Math.min(img.width - 1, (x + 0.5) * sx - 0.5);
const x0 = Math.max(0, Math.floor(fx)), x1 = Math.min(img.width - 1, x0 + 1), wx = fx - x0;
const o = (y * width + x) * 4;
for (let c = 0; c < 4; c++) {
const p00 = img.data[(y0 * img.width + x0) * 4 + c], p10 = img.data[(y0 * img.width + x1) * 4 + c];
const p01 = img.data[(y1 * img.width + x0) * 4 + c], p11 = img.data[(y1 * img.width + x1) * 4 + c];
out.data[o + c] = (p00 * (1 - wx) + p10 * wx) * (1 - wy) + (p01 * (1 - wx) + p11 * wx) * wy;
}
}
}
return out;
}
/** Scale to fit inside (maxW x maxH) preserving aspect; never upscale unless `allowUpscale`. */
export function fit(img, maxW, maxH, allowUpscale = false) {
const s = Math.min(maxW / img.width, maxH / img.height);
if (s >= 1 && !allowUpscale) return img;
return resize(img, img.width * s, img.height * s);
}
/** Alpha-composite `src` onto `dst` at (x, y). */
export function blit(dst, src, x, y) {
x = Math.round(x); y = Math.round(y);
for (let yy = 0; yy < src.height; yy++) {
const dy = y + yy; if (dy < 0 || dy >= dst.height) continue;
for (let xx = 0; xx < src.width; xx++) {
const dx = x + xx; if (dx < 0 || dx >= dst.width) continue;
const s = (yy * src.width + xx) * 4, d = (dy * dst.width + dx) * 4;
const a = src.data[s + 3] / 255;
if (a >= 1) { dst.data[d] = src.data[s]; dst.data[d + 1] = src.data[s + 1]; dst.data[d + 2] = src.data[s + 2]; dst.data[d + 3] = 255; continue; }
if (a <= 0) continue;
const da = dst.data[d + 3] / 255, oa = a + da * (1 - a);
for (let c = 0; c < 3; c++) dst.data[d + c] = (src.data[s + c] * a + dst.data[d + c] * da * (1 - a)) / (oa || 1);
dst.data[d + 3] = oa * 255;
}
}
}
export function fillRect(img, x, y, w, h, rgba) {
const r = clampRect(img, x, y, w, h);
const a = (rgba[3] ?? 255) / 255;
for (let yy = r.y; yy < r.y + r.h; yy++) {
for (let xx = r.x; xx < r.x + r.w; xx++) {
const o = (yy * img.width + xx) * 4;
if (a >= 1) { img.data[o] = rgba[0]; img.data[o + 1] = rgba[1]; img.data[o + 2] = rgba[2]; img.data[o + 3] = 255; }
else { for (let c = 0; c < 3; c++) img.data[o + c] = rgba[c] * a + img.data[o + c] * (1 - a); img.data[o + 3] = Math.max(img.data[o + 3], a * 255); }
}
}
}
export function strokeRect(img, x, y, w, h, rgba, thickness = 2) {
fillRect(img, x, y, w, thickness, rgba);
fillRect(img, x, y + h - thickness, w, thickness, rgba);
fillRect(img, x, y, thickness, h, rgba);
fillRect(img, x + w - thickness, y, thickness, h, rgba);
}
// 5x7 bitmap font, uppercase + digits + a little punctuation. Enough for labels.
const GLYPHS = {
A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'],
X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
0: ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
2: ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
3: ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
4: ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
5: ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
6: ['00110', '01000', '10000', '11110', '10001', '10001', '01110'],
7: ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
8: ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
9: ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
'.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'],
':': ['00000', '01100', '01100', '00000', '01100', '01100', '00000'],
'-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
'/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'],
'%': ['11001', '11010', '00010', '00100', '01000', '01011', '10011'],
'(': ['00010', '00100', '01000', '01000', '01000', '00100', '00010'],
')': ['01000', '00100', '00010', '00010', '00010', '00100', '01000'],
'#': ['01010', '01010', '11111', '01010', '11111', '01010', '01010'],
'_': ['00000', '00000', '00000', '00000', '00000', '00000', '11111'],
'?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'],
'=': ['00000', '00000', '11111', '00000', '11111', '00000', '00000'],
'+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'],
',': ['00000', '00000', '00000', '00000', '01100', '00100', '01000'],
};
export function textWidth(text, scale = 2) {
return text.length * 6 * scale;
}
/** Draw uppercase bitmap text. Returns width drawn. */
export function drawText(img, text, x, y, rgba, scale = 2) {
let cx = Math.round(x);
for (const chRaw of String(text).toUpperCase()) {
const g = GLYPHS[chRaw] || GLYPHS['?'];
for (let r = 0; r < 7; r++) for (let c = 0; c < 5; c++) if (g[r][c] === '1') fillRect(img, cx + c * scale, y + r * scale, scale, scale, rgba);
cx += 6 * scale;
}
return cx - x;
}
/** Draw a label with a background pill. */
export function drawLabel(img, text, x, y, { fg = [255, 255, 255, 255], bg = [0, 0, 0, 220], scale = 2, pad = 4 } = {}) {
const w = textWidth(text, scale) + pad * 2, h = 7 * scale + pad * 2;
fillRect(img, x, y, w, h, bg);
drawText(img, text, x + pad, y + pad, fg, scale);
return { w, h };
}
+168
View File
@@ -0,0 +1,168 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { encodePng } from '../skill/scripts/lib/png.mjs';
import { createImage, fillRect, blit, resize } from '../skill/scripts/lib/raster.mjs';
import { gridToBox, measureRegions, platePrompt } from '../skill/scripts/comp-spec.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const SPEC_SCRIPT = path.join(ROOT, 'skill', 'scripts', 'comp-spec.mjs');
const PHASE_SCRIPT = path.join(ROOT, 'skill', 'scripts', 'build-phase.mjs');
function lcg(seed) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0xffffffff); }
function makeComp(w = 640, h = 400) {
const img = createImage(w, h, [240, 237, 226, 255]);
fillRect(img, 0, 0, w, 40, [19, 33, 48, 255]);
fillRect(img, 20, 60, 220, 20, [19, 33, 48, 255]);
fillRect(img, 20, 90, 180, 20, [19, 33, 48, 255]);
const rnd = lcg(3);
for (let y = 40; y < 200; y++) for (let x = 320; x < 640; x++) {
const v = 100 + Math.floor(rnd() * 140);
const p = (y * w + x) * 4; img.data[p] = v; img.data[p + 1] = v; img.data[p + 2] = v;
}
for (let i = 0; i < 3; i++) { fillRect(img, 0, 220 + i * 50, w, 1, [19, 33, 48, 255]); fillRect(img, 20, 232 + i * 50, 300, 12, [19, 33, 48, 255]); }
return img;
}
function run(script, args, cwd) {
return spawnSync(process.execPath, [script, ...args], { cwd, encoding: 'utf8' });
}
describe('comp-spec', () => {
it('parses grid spans into normalized boxes', () => {
assert.deepEqual(gridToBox('A0:A0'), { x: 0, y: 0, w: 0.1, h: 0.1 });
assert.deepEqual(gridToBox('E0:J4'), { x: 0.4, y: 0, w: 0.6, h: 0.5 });
assert.deepEqual(gridToBox('j4:e0'), { x: 0.4, y: 0, w: 0.6, h: 0.5 });
assert.throws(() => gridToBox('K0:A1'));
});
it('measures regions with palette, pixel box, medium, and plate path', () => {
const comp = makeComp();
const spec = measureRegions(comp, { regions: [
{ id: 'masthead', kind: 'chrome', grid: 'A0:J0' },
{ id: 'art', kind: 'plate', grid: 'F1:J4', note: 'noise plate' },
] }, 'comp.png');
assert.equal(spec.regions.length, 2);
const art = spec.regions.find((r) => r.id === 'art');
assert.equal(art.medium, 'raster');
assert.equal(art.plate, path.join('assets', 'plates', 'art.png'));
assert.equal(art.px.x, 320);
assert.ok(art.palette.length > 0);
assert.equal(spec.regions[0].medium, 'semantic');
assert.equal(spec.orientation, 'landscape');
assert.match(platePrompt(spec, art), /noise plate/);
});
it('rejects duplicate ids and missing ids', () => {
const comp = makeComp();
assert.throws(() => measureRegions(comp, { regions: [{ id: 'a', grid: 'A0:A0' }, { id: 'a', grid: 'B0:B0' }] }, 'c.png'), /duplicate/);
assert.throws(() => measureRegions(comp, { regions: [{ grid: 'A0:A0' }] }, 'c.png'), /id/);
});
});
describe('build-phase state machine (CLI)', () => {
let dir;
before(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-'));
fs.writeFileSync(path.join(dir, 'comp.png'), encodePng(makeComp()));
fs.writeFileSync(path.join(dir, 'regions.json'), JSON.stringify({ regions: [
{ id: 'masthead', kind: 'chrome', grid: 'A0:J0' },
{ id: 'headline', kind: 'text', grid: 'A1:D2' },
{ id: 'art', kind: 'plate', grid: 'F1:J4', note: 'noise plate' },
{ id: 'list', kind: 'control', grid: 'A5:J9' },
] }));
});
after(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
it('start writes state at spec and prints NEXT', () => {
const res = run(PHASE_SCRIPT, ['start', '--comp', 'comp.png'], dir);
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /BUILD-PHASE SPEC/);
assert.match(res.stdout, /NEXT Measure the comp/);
assert.ok(fs.existsSync(path.join(dir, '.impeccable', 'build', 'state.json')));
});
it('spec gate fails without a spec, passes once comp-spec wrote one', () => {
let res = run(PHASE_SCRIPT, ['advance'], dir);
assert.equal(res.status, 2);
assert.match(res.stdout, /GATE SPEC FAILED/);
res = run(SPEC_SCRIPT, ['--comp', 'comp.png', '--grid'], dir);
assert.equal(res.status, 0, res.stderr);
assert.ok(fs.existsSync(path.join(dir, '.impeccable', 'build', 'comp-grid.png')));
res = run(SPEC_SCRIPT, ['--comp', 'comp.png', '--regions', 'regions.json'], dir);
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /PLATES 1 to produce: art/);
res = run(PHASE_SCRIPT, ['advance'], dir);
assert.equal(res.status, 0, res.stdout + res.stderr);
assert.match(res.stdout, /ADVANCED spec -> plates/);
});
it('plates gate names the missing plate, rejects a comp-size crop, accepts a 2x plate', () => {
let res = run(PHASE_SCRIPT, ['advance'], dir);
assert.equal(res.status, 2);
assert.match(res.stdout, /plate missing for art/);
// comp-size crop: too small
res = run(SPEC_SCRIPT, ['--crop', 'art', '--out', 'assets/plates/art.png'], dir);
assert.equal(res.status, 0, res.stderr);
res = run(PHASE_SCRIPT, ['advance'], dir);
assert.equal(res.status, 2);
assert.match(res.stdout, /needs at least 1.5x/);
// 2x crop passes size and similarity
res = run(SPEC_SCRIPT, ['--crop', 'art', '--scale', '2', '--out', 'assets/plates/art.png'], dir);
assert.equal(res.status, 0, res.stderr);
res = run(PHASE_SCRIPT, ['advance'], dir);
assert.equal(res.status, 0, res.stdout);
assert.match(res.stdout, /ADVANCED plates -> hero/);
});
it('hero gate fails on a flat build and passes on a faithful one, recording attempts', () => {
const comp = makeComp();
const flat = createImage(comp.width, comp.height, [240, 237, 226, 255]);
fillRect(flat, 0, 0, comp.width, 40, [19, 33, 48, 255]);
fs.mkdirSync(path.join(dir, '.impeccable', 'review'), { recursive: true });
fs.writeFileSync(path.join(dir, '.impeccable', 'review', 'hero-repro.png'), encodePng(flat));
let res = run(PHASE_SCRIPT, ['advance'], dir);
assert.equal(res.status, 2, res.stdout);
assert.match(res.stdout, /GATE HERO FAILED/);
assert.match(res.stdout, /region art is missing/);
assert.ok(fs.existsSync(path.join(dir, '.impeccable', 'review', 'diff', 'hero', 'side-by-side.png')));
// faithful: the comp shifted by a few px, captured at 1.5x width
const shifted = createImage(comp.width, comp.height, [240, 237, 226, 255]);
blit(shifted, comp, 3, 2);
fs.writeFileSync(path.join(dir, '.impeccable', 'review', 'hero-repro.png'), encodePng(resize(shifted, comp.width * 1.5, comp.height * 1.5)));
res = run(PHASE_SCRIPT, ['advance'], dir);
assert.equal(res.status, 0, res.stdout);
assert.match(res.stdout, /ADVANCED hero -> sections/);
const state = JSON.parse(fs.readFileSync(path.join(dir, '.impeccable', 'build', 'state.json'), 'utf8'));
assert.equal(state.phases.hero.attempts, 2);
assert.ok(state.phases.hero.gate.score >= 0.72);
});
it('later phases advance without a gate; force is recorded; finish records the disposition', () => {
for (const from of ['sections', 'motion']) {
const res = run(PHASE_SCRIPT, ['advance'], dir);
assert.equal(res.status, 0, res.stdout);
assert.match(res.stdout, new RegExp(`ADVANCED ${from}`));
}
let res = run(PHASE_SCRIPT, ['advance', '--force', '--reason', 'test'], dir);
assert.equal(res.status, 0);
res = run(PHASE_SCRIPT, ['finish', '--disposition', 'fix'], dir);
assert.equal(res.status, 0);
assert.match(res.stdout, /finish fix/);
res = run(PHASE_SCRIPT, ['status', '--json'], dir);
const state = JSON.parse(res.stdout);
assert.equal(state.phase, 'review');
assert.equal(state.finish.disposition, 'fix');
});
it('refuses a bad disposition and an unknown command', () => {
assert.equal(run(PHASE_SCRIPT, ['finish', '--disposition', 'great'], dir).status, 1);
assert.equal(run(PHASE_SCRIPT, ['dance'], dir).status, 1);
});
});
+225
View File
@@ -0,0 +1,225 @@
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { decodePng, encodePng } from '../skill/scripts/lib/png.mjs';
import { createImage, fillRect, blit, crop, resize, drawText } from '../skill/scripts/lib/raster.mjs';
import { compare, verdictFor, alignBuild } from '../skill/scripts/comp-diff.mjs';
import { dominantColors, structureScore, detailScore } from '../skill/scripts/lib/image-metrics.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const SCRIPT = path.join(ROOT, 'skill', 'scripts', 'comp-diff.mjs');
// A synthetic "comp": bone ground, navy masthead, a red-gutter table, and a
// noisy "illustration" region on the right of the fold. Deterministic noise.
function lcg(seed) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0xffffffff); }
function makeComp(w = 768, h = 512) {
const img = createImage(w, h, [240, 237, 226, 255]);
fillRect(img, 0, 0, w, 32, [19, 33, 48, 255]); // masthead
drawText(img, 'CARBURETOR CLUB', 12, 8, [240, 237, 226, 255], 2);
// headline block
fillRect(img, 24, 70, 280, 22, [19, 33, 48, 255]);
fillRect(img, 24, 100, 240, 22, [19, 33, 48, 255]);
fillRect(img, 24, 136, 90, 4, [176, 40, 32, 255]);
// illustration: high-frequency noise plate
const rnd = lcg(7);
for (let y = 48; y < 240; y++) for (let x = 340; x < 740; x++) {
const v = 120 + Math.floor(rnd() * 120);
const p = (y * w + x) * 4; img.data[p] = v; img.data[p + 1] = v; img.data[p + 2] = v + 10;
}
// table with red gutter
fillRect(img, 0, 260, w, 2, [19, 33, 48, 255]);
for (let i = 0; i < 3; i++) {
const y = 270 + i * 60;
fillRect(img, 0, y, 10, 50, i === 0 ? [176, 40, 32, 255] : [19, 33, 48, 255]);
fillRect(img, 30, y + 10, 300, 14, [19, 33, 48, 255]);
fillRect(img, 30, y + 30, 200, 8, [120, 120, 120, 255]);
fillRect(img, 0, y + 56, w, 1, [19, 33, 48, 255]);
}
// CTA
fillRect(img, 24, 460, 160, 36, [19, 33, 48, 255]);
return img;
}
function flattenIllustration(comp) {
const img = { ...comp, data: new Uint8Array(comp.data) };
fillRect(img, 340, 48, 400, 192, [200, 200, 205, 255]); // a flat gray box where the plate was
return img;
}
function recolor(comp) {
const img = { ...comp, data: new Uint8Array(comp.data) };
for (let i = 0; i < img.data.length; i += 4) {
if (img.data[i] > 220 && img.data[i + 1] > 210) { img.data[i] = 20; img.data[i + 1] = 40; img.data[i + 2] = 60; }
}
return img;
}
function shifted(comp, dx, dy) {
const img = createImage(comp.width, comp.height, [240, 237, 226, 255]);
blit(img, comp, dx, dy);
return img;
}
function tallPage(comp) {
// a full-page screenshot: comp on top, then more page below
const img = createImage(comp.width, comp.height * 3, [240, 237, 226, 255]);
blit(img, comp, 0, 0);
fillRect(img, 0, comp.height + 40, comp.width, 200, [19, 33, 48, 255]);
return img;
}
const SPEC = {
regions: [
{ id: 'masthead', kind: 'control', box: { x: 0, y: 0, w: 1, h: 32 / 512 } },
{ id: 'headline', kind: 'text', box: { x: 0.02, y: 60 / 512, w: 0.4, h: 100 / 512 } },
{ id: 'plate', kind: 'plate', box: { x: 340 / 768, y: 48 / 512, w: 400 / 768, h: 192 / 512 } },
{ id: 'table', kind: 'control', box: { x: 0, y: 260 / 512, w: 1, h: 190 / 512 } },
],
};
describe('png codec', () => {
it('round-trips RGBA through encode/decode', () => {
const img = createImage(20, 10, [10, 20, 30, 255]);
img.data.set([200, 100, 50, 128], (2 * 20 + 2) * 4);
const back = decodePng(encodePng(img, { text: { 'impeccable:prompt': 'hello' } }));
assert.equal(back.width, 20); assert.equal(back.height, 10);
assert.deepEqual([...back.data.subarray(0, 4)], [10, 20, 30, 255]);
assert.deepEqual([...back.data.subarray((2 * 20 + 2) * 4, (2 * 20 + 2) * 4 + 4)], [200, 100, 50, 128]);
assert.equal(back.text['impeccable:prompt'], 'hello');
});
it('decodes a real gpt-image / Playwright style PNG when one is on disk (skips otherwise)', () => {
const sample = path.join(ROOT, 'tests', 'fixtures', 'comp-fidelity', 'sample.png');
if (!fs.existsSync(sample)) return;
const img = decodePng(fs.readFileSync(sample));
assert.ok(img.width > 0 && img.height > 0);
});
});
describe('image metrics', () => {
const comp = makeComp();
it('identity scores 1', () => {
assert.ok(structureScore(comp, comp) > 0.999);
assert.ok(detailScore(comp, comp).score > 0.999);
});
it('dominant colors find the ground and the ink', () => {
const cols = dominantColors(comp).map((c) => c.hex);
assert.ok(cols.some((h) => h.startsWith('#f') || h.startsWith('#e')), `ground missing in ${cols}`);
assert.ok(cols.some((h) => h.startsWith('#1') || h.startsWith('#0') || h.startsWith('#2')), `ink missing in ${cols}`);
});
it('a flattened plate loses detail', () => {
const d = detailScore(comp, flattenIllustration(comp));
assert.ok(d.score < 0.85, `expected detail loss, got ${d.score}`);
});
});
describe('comp-diff compare', () => {
const comp = makeComp();
it('scores the comp against itself as a match everywhere', () => {
const r = compare({ comp, build: comp, spec: SPEC });
assert.equal(r.whole.overall, 1);
for (const region of r.regions) assert.equal(region.verdict, 'match', region.id);
});
it('forgives a small translation', () => {
const r = compare({ comp, build: shifted(comp, 6, 4), spec: SPEC });
assert.ok(r.whole.overall >= 0.8, `overall ${r.whole.overall}`);
assert.equal(verdictFor(r.whole), 'match');
});
it('reads the top of a full-page screenshot as the first viewport', () => {
const r = compare({ comp, build: tallPage(comp), spec: SPEC });
assert.equal(r.aligned.height, comp.height);
assert.ok(r.whole.overall > 0.95, `overall ${r.whole.overall}`);
});
it('flags a flattened plate region as missing while the rest matches', () => {
const r = compare({ comp, build: flattenIllustration(comp), spec: SPEC });
const plate = r.regions.find((x) => x.id === 'plate');
assert.equal(plate.verdict, 'missing');
assert.equal(r.regions.find((x) => x.id === 'table').verdict, 'match');
assert.equal(r.regions.find((x) => x.id === 'masthead').verdict, 'match');
});
it('fails a recolored page on color and structure', () => {
const r = compare({ comp, build: recolor(comp), spec: SPEC });
assert.ok(r.whole.color < 0.7, `color ${r.whole.color}`);
assert.ok(r.whole.overall < 0.6, `overall ${r.whole.overall}`);
assert.notEqual(verdictFor(r.whole), 'match');
});
it('derives band regions when no spec is given', () => {
const r = compare({ comp, build: comp });
assert.ok(r.regions.length >= 2);
assert.ok(r.regions.every((x) => x.kind === 'band'));
});
it('pads a shorter build with white so a truncated page reads as missing content', () => {
const half = crop(comp, 0, 0, comp.width, comp.height / 2);
const aligned = alignBuild(comp, half);
assert.equal(aligned.height, comp.height);
const r = compare({ comp, build: half, spec: SPEC });
assert.notEqual(r.regions.find((x) => x.id === 'table').verdict, 'match');
});
it('scales a build captured at a different width onto the comp', () => {
const wide = resize(comp, comp.width * 1.5, comp.height * 1.5);
const r = compare({ comp, build: wide, spec: SPEC });
assert.ok(r.whole.overall > 0.9, `overall ${r.whole.overall}`);
});
});
describe('comp-diff CLI', () => {
let dir;
before(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'comp-diff-'));
const comp = makeComp();
fs.writeFileSync(path.join(dir, 'comp.png'), encodePng(comp));
fs.writeFileSync(path.join(dir, 'flat.png'), encodePng(flattenIllustration(comp)));
fs.writeFileSync(path.join(dir, 'spec.json'), JSON.stringify(SPEC));
});
it('writes side-by-side, heatmap, region pairs, and report.json', () => {
const out = path.join(dir, 'diff');
const res = spawnSync(process.execPath, [SCRIPT, '--comp', path.join(dir, 'comp.png'), '--build', path.join(dir, 'flat.png'), '--spec', path.join(dir, 'spec.json'), '--out-dir', out], { encoding: 'utf8' });
assert.equal(res.status, 0, res.stderr + res.stdout);
assert.match(res.stdout, /^COMP-DIFF/m);
assert.match(res.stdout, /REGION plate\s+missing/);
for (const f of ['side-by-side.png', 'heatmap.png', 'report.json', path.join('regions', 'plate.png')]) {
assert.ok(fs.existsSync(path.join(out, f)), `${f} missing`);
}
const report = JSON.parse(fs.readFileSync(path.join(out, 'report.json'), 'utf8'));
assert.equal(report.tool, 'comp-diff');
assert.equal(report.regions.length, 4);
assert.ok(report.palette.comp.length > 0);
// artifacts decode
const side = decodePng(fs.readFileSync(path.join(out, 'side-by-side.png')));
assert.ok(side.width > 768);
});
it('exits 3 below --threshold and prints the instruction', () => {
const res = spawnSync(process.execPath, [SCRIPT, '--comp', path.join(dir, 'comp.png'), '--build', path.join(dir, 'flat.png'), '--no-files', '--threshold', '0.99'], { encoding: 'utf8' });
assert.equal(res.status, 3);
assert.match(res.stdout, /BELOW THRESHOLD/);
});
it('--json prints the report', () => {
const res = spawnSync(process.execPath, [SCRIPT, '--comp', path.join(dir, 'comp.png'), '--build', path.join(dir, 'comp.png'), '--no-files', '--json'], { encoding: 'utf8' });
assert.equal(res.status, 0);
const report = JSON.parse(res.stdout);
assert.equal(report.verdict, 'match');
});
it('exits 1 with usage on missing args', () => {
const res = spawnSync(process.execPath, [SCRIPT], { encoding: 'utf8' });
assert.equal(res.status, 1);
assert.match(res.stderr, /usage/);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB