mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
committed by
Abdul Wahab
co-authored by
Claude
parent
f86473ba7d
commit
b0fc2e8801
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
Reference in New Issue
Block a user