quiver-cli 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/.agents/skills/agent-browser/SKILL.md +1 -0
- package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
- package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
- package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
- package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
- package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
- package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
- package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
- package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
- package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
- package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
- package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
- package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
- package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
- package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
- package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
- package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
- package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
- package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
- package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
- package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
- package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
- package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
- package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
- package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
- package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
- package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
- package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
- package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
- package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
- package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
- package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
- package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
- package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
- package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
- package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
- package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
- package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
- package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
- package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
- package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
- package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
- package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
- package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
- package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
- package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
- package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
- package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
- package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
- package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
- package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
- package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
- package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
- package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
- package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
- package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
- package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
- package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
- package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
- package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
- package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
- package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
- package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
- package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
- package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
- package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
- package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
- package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
- package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
- package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
- package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
- package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
- package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
- package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
- package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
- package/template/.agents/skills/hono/SKILL.md +17 -3
- package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
- package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
- package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
- package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
- package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
- package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
- package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
- package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
- package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
- package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
- package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
- package/template/.agents/skills/supabase/SKILL.md +5 -1
- package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
- package/template/.agents/skills/writing/humanizer/README.md +209 -0
- package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
- package/template/.agents/upstreams.json +21 -22
- package/template/.agents/skills/design/impeccable/scripts/live/ui-core.mjs +0 -180
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* comp-diff: measure a build screenshot against its approved comp and produce
|
|
4
|
+
* the evidence a reviewer (human or model) needs to judge fidelity without
|
|
5
|
+
* trusting anyone's memory of the image.
|
|
6
|
+
*
|
|
7
|
+
* node comp-diff.mjs --comp .impeccable/mocks/approved.png --build .impeccable/review/hero-repro.png
|
|
8
|
+
* node comp-diff.mjs --comp comp.png --build desktop.png --spec .impeccable/build/spec.json --out-dir .impeccable/review/diff
|
|
9
|
+
* node comp-diff.mjs ... --json # machine-readable report on stdout
|
|
10
|
+
* node comp-diff.mjs ... --threshold 0.75 # exit 3 when the overall score is below
|
|
11
|
+
*
|
|
12
|
+
* Inputs: two PNGs. The build capture may be taller than the comp (a full-page
|
|
13
|
+
* screenshot); it is scaled to the comp's width and the top comp-height rows
|
|
14
|
+
* are compared, because the comp is the first viewport. `--align stretch`
|
|
15
|
+
* squashes the whole build onto the comp instead, for a comp that covers a
|
|
16
|
+
* whole page.
|
|
17
|
+
*
|
|
18
|
+
* Outputs (in --out-dir, default .impeccable/review/diff):
|
|
19
|
+
* side-by-side.png comp | build, same size, labeled, with the score
|
|
20
|
+
* heatmap.png build with the difference painted over it (red = wrong)
|
|
21
|
+
* regions/<id>.png paired crops per region at legible scale, scored
|
|
22
|
+
* report.json every number below, plus per-region rows
|
|
23
|
+
*
|
|
24
|
+
* Scores (0..1): structure (blurred SSIM: is the composition the same?),
|
|
25
|
+
* color (histogram + dominant palette: is it the same palette at the same
|
|
26
|
+
* coverage?), detail (high-frequency energy ratio: did the material survive,
|
|
27
|
+
* or did an illustration become a gradient?), bands (do the horizontal
|
|
28
|
+
* sections line up?). `overall` weights them 0.35 / 0.25 / 0.25 / 0.15.
|
|
29
|
+
*
|
|
30
|
+
* Regions come from --spec (comp-spec.mjs output: normalized boxes) or, with
|
|
31
|
+
* none, from the comp's own horizontal bands, so the per-region crops exist
|
|
32
|
+
* either way. Every region row carries the same four scores plus `verdict`:
|
|
33
|
+
* match (>= 0.8), drift (>= 0.6), missing (detail ratio < 0.35 with structure
|
|
34
|
+
* < 0.6), or contradicted (everything else). The words are the finish
|
|
35
|
+
* reviewer's fidelity vocabulary on purpose.
|
|
36
|
+
*
|
|
37
|
+
* Exit codes: 0 measured (and above threshold when one is given), 1 usage or
|
|
38
|
+
* unreadable input, 3 below threshold.
|
|
39
|
+
*/
|
|
40
|
+
import fs from 'node:fs';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
import { decodePng, encodePng, loadRaster } from './lib/png.mjs';
|
|
44
|
+
import { crop, resize, fit, blit, createImage, fillRect, strokeRect, drawLabel } from './lib/raster.mjs';
|
|
45
|
+
import { structureScore, colorScore, detailScore, diffMap, horizontalBands, bandScore, dominantColors, toGray, blurGray, ssimShifted } from './lib/image-metrics.mjs';
|
|
46
|
+
|
|
47
|
+
function arg(name, fallback = null) {
|
|
48
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
49
|
+
if (i === -1) return fallback;
|
|
50
|
+
const v = process.argv[i + 1];
|
|
51
|
+
return v && !v.startsWith('--') ? v : fallback;
|
|
52
|
+
}
|
|
53
|
+
const flag = (name) => process.argv.includes(`--${name}`);
|
|
54
|
+
|
|
55
|
+
export function readPng(file) {
|
|
56
|
+
return loadRaster(file).image;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Scale the build to the comp's width; take the top comp-height rows
|
|
61
|
+
* (align=top), squash the whole build onto the comp (align=stretch), or scale
|
|
62
|
+
* to cover and center-crop (align=cover, the way `object-fit: cover` will show
|
|
63
|
+
* a plate whose aspect differs from its region).
|
|
64
|
+
*/
|
|
65
|
+
export function alignBuild(comp, build, align = 'top') {
|
|
66
|
+
if (align === 'stretch') return resize(build, comp.width, comp.height);
|
|
67
|
+
if (align === 'cover') {
|
|
68
|
+
const s = Math.max(comp.width / build.width, comp.height / build.height);
|
|
69
|
+
const scaled = resize(build, build.width * s, build.height * s);
|
|
70
|
+
return crop(scaled, (scaled.width - comp.width) / 2, (scaled.height - comp.height) / 2, comp.width, comp.height);
|
|
71
|
+
}
|
|
72
|
+
const scaled = build.width === comp.width ? build : resize(build, comp.width, Math.round((build.height / build.width) * comp.width));
|
|
73
|
+
if (scaled.height === comp.height) return scaled;
|
|
74
|
+
if (scaled.height > comp.height) return crop(scaled, 0, 0, comp.width, comp.height);
|
|
75
|
+
// shorter than the comp: pad with white so a short page reads as missing content, not as a resize
|
|
76
|
+
const out = createImage(comp.width, comp.height, [255, 255, 255, 255]);
|
|
77
|
+
blit(out, scaled, 0, 0);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Weights per region kind: what a region is made of decides what losing it looks like. */
|
|
82
|
+
const WEIGHTS = {
|
|
83
|
+
default: { structure: 0.35, color: 0.25, detail: 0.25, bands: 0.15 },
|
|
84
|
+
plate: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 },
|
|
85
|
+
image: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 },
|
|
86
|
+
texture: { structure: 0.15, color: 0.35, detail: 0.5, bands: 0 },
|
|
87
|
+
text: { structure: 0.5, color: 0.25, detail: 0.15, bands: 0.1 },
|
|
88
|
+
control: { structure: 0.45, color: 0.35, detail: 0.2, bands: 0 },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function scorePair(a, b, kind = null) {
|
|
92
|
+
const structure = structureScore(a, b);
|
|
93
|
+
const color = colorScore(a, b);
|
|
94
|
+
const detail = detailScore(a, b);
|
|
95
|
+
const bandsA = horizontalBands(a), bandsB = horizontalBands(b);
|
|
96
|
+
const bands = bandScore(bandsA, bandsB);
|
|
97
|
+
const w = WEIGHTS[kind] || WEIGHTS.default;
|
|
98
|
+
const overall = w.structure * structure + w.color * color.score + w.detail * detail.score + w.bands * bands;
|
|
99
|
+
return {
|
|
100
|
+
overall: r4(overall),
|
|
101
|
+
structure: r4(structure),
|
|
102
|
+
color: r4(color.score),
|
|
103
|
+
colorIntersection: r4(color.intersection),
|
|
104
|
+
paletteMatch: r4(color.paletteMatch),
|
|
105
|
+
detail: r4(detail.score),
|
|
106
|
+
detailRaw: r4(detail.rawScore ?? detail.score),
|
|
107
|
+
detailAdded: r4(detail.addedFraction),
|
|
108
|
+
bands: r4(bands),
|
|
109
|
+
_detail: detail,
|
|
110
|
+
_bands: { comp: bandsA, build: bandsB },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Kinds that carry the direction: a wrong one is the wrong page, whatever the mean says. */
|
|
115
|
+
export const DIRECTION_KINDS = new Set(['plate', 'image', 'text']);
|
|
116
|
+
|
|
117
|
+
/** Best small global translation (build relative to comp), in pixels, by blurred-gray SSIM. */
|
|
118
|
+
export function bestShift(comp, build, workWidth = 256) {
|
|
119
|
+
const h = Math.max(8, Math.round((comp.height / comp.width) * workWidth));
|
|
120
|
+
const a = blurGray(toGray(resize(comp, workWidth, h)), 2);
|
|
121
|
+
const b = blurGray(toGray(resize(build, workWidth, h)), 2);
|
|
122
|
+
const maxShift = Math.max(2, Math.round(workWidth * 0.04));
|
|
123
|
+
let best = { dx: 0, dy: 0, score: ssimShifted(a, b, 0, 0) };
|
|
124
|
+
for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
|
|
125
|
+
for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
|
|
126
|
+
const sc = ssimShifted(a, b, Math.round(dx), Math.round(dy));
|
|
127
|
+
if (sc > best.score + 0.01) best = { dx: Math.round(dx), dy: Math.round(dy), score: sc };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const scale = comp.width / workWidth;
|
|
131
|
+
return { dx: Math.round(best.dx * scale), dy: Math.round(best.dy * scale), score: best.score };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function verdictFor(s, kind = null) {
|
|
135
|
+
const painted = kind === 'plate' || kind === 'image' || kind === 'texture';
|
|
136
|
+
// Nothing drawn where the comp drew something is missing whatever the
|
|
137
|
+
// palette says: a footer strip the build pushed below the fold read as
|
|
138
|
+
// 'drift' on ground colour alone (detail 4%, structure 92%). detailRaw is
|
|
139
|
+
// 1 when the comp region itself is calm, so a low value already means the
|
|
140
|
+
// comp had material there.
|
|
141
|
+
if (s.detailRaw != null && s.detailRaw < 0.15) return 'missing';
|
|
142
|
+
if (painted && s.detail < 0.5) return 'missing';
|
|
143
|
+
// For text, chrome, and controls "missing" means the build has nothing
|
|
144
|
+
// there, not that a thin strip sits a few pixels off: require the build's
|
|
145
|
+
// own energy to be near zero relative to the comp (rawScore, before the
|
|
146
|
+
// added-detail penalty), and drift for a mere misalignment.
|
|
147
|
+
if (!painted && s.detail < 0.35 && s.structure < 0.6) {
|
|
148
|
+
if (s.detailRaw != null && s.detailRaw < 0.2) return 'missing';
|
|
149
|
+
// low detail with structure and palette both holding is grain the build
|
|
150
|
+
// renders flatter (a spine of rotated type on textured red), not a
|
|
151
|
+
// different composition
|
|
152
|
+
if (s.structure >= 0.5 && s.color >= 0.5) return 'drift';
|
|
153
|
+
return 'contradicted';
|
|
154
|
+
}
|
|
155
|
+
if (s.detail < 0.35 && s.structure < 0.6) return 'missing';
|
|
156
|
+
// Structure is the one thing a wrong-but-busy region cannot fake: noise,
|
|
157
|
+
// a mirrored crop, a swapped column, a tile shuffle all keep color and
|
|
158
|
+
// energy and lose structure. Below the floor it is contradicted whatever
|
|
159
|
+
// the weighted mean says; painted regions with invented detail likewise.
|
|
160
|
+
if (s.structure < 0.3) return 'contradicted';
|
|
161
|
+
if (painted && (s.structure < 0.45 || s.detailAdded > 0.4)) return 'contradicted';
|
|
162
|
+
// Text is set in a substitute face at a slightly different metric almost
|
|
163
|
+
// always, and blurred SSIM reads glyph shape; a text region with its
|
|
164
|
+
// structure above the swap floor and its palette intact is drift at worst.
|
|
165
|
+
// Chasing it past that point is what burned eight to thirteen hero attempts
|
|
166
|
+
// per build in the first simulated round.
|
|
167
|
+
if (kind === 'text' && s.color >= 0.5) return s.overall >= 0.8 ? 'match' : 'drift';
|
|
168
|
+
// Chrome and controls are thin strips whose "detail" is mostly ground grain
|
|
169
|
+
// (a paper texture the build renders flatter, a scanline). When their
|
|
170
|
+
// structure and palette hold, low detail is drift, not contradiction.
|
|
171
|
+
if ((kind === 'chrome' || kind === 'control') && s.structure >= 0.5 && s.color >= 0.5) return s.overall >= 0.8 ? 'match' : 'drift';
|
|
172
|
+
if (s.overall >= 0.8) return 'match';
|
|
173
|
+
if (s.overall >= 0.6) return 'drift';
|
|
174
|
+
return 'contradicted';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const r4 = (v) => Math.round(v * 10000) / 10000;
|
|
178
|
+
|
|
179
|
+
/** Regions from a spec (normalized boxes) or derived from the comp's bands. */
|
|
180
|
+
export function resolveRegions(comp, spec) {
|
|
181
|
+
const regions = [];
|
|
182
|
+
if (spec && Array.isArray(spec.regions) && spec.regions.length) {
|
|
183
|
+
for (const r of spec.regions) {
|
|
184
|
+
const box = r.box || r;
|
|
185
|
+
if ([box.x, box.y, box.w, box.h].some((v) => typeof v !== 'number')) continue;
|
|
186
|
+
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 });
|
|
187
|
+
}
|
|
188
|
+
if (regions.length) return regions;
|
|
189
|
+
}
|
|
190
|
+
const bands = horizontalBands(comp).filter((b) => b.strength > 0.2);
|
|
191
|
+
const cuts = [0, ...bands.map((b) => b.y), 1].filter((v, i, arr) => i === 0 || v - arr[i - 1] > 0.06);
|
|
192
|
+
if (cuts[cuts.length - 1] !== 1) cuts.push(1);
|
|
193
|
+
for (let i = 0; i + 1 < cuts.length; i++) {
|
|
194
|
+
regions.push({ id: `band-${i + 1}`, x: 0, y: cuts[i], w: 1, h: cuts[i + 1] - cuts[i], kind: 'band' });
|
|
195
|
+
}
|
|
196
|
+
if (regions.length < 2) {
|
|
197
|
+
return [
|
|
198
|
+
{ id: 'top', x: 0, y: 0, w: 1, h: 0.5, kind: 'band' },
|
|
199
|
+
{ id: 'bottom', x: 0, y: 0.5, w: 1, h: 0.5, kind: 'band' },
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
return regions;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Crop a normalized region; regions thinner than 48px in either axis are grown to that so tiny strips do not swing on subpixel noise. */
|
|
206
|
+
/** Bounding box of ink (pixels darker/lighter than the region's ground by a margin) within a crop, in px. */
|
|
207
|
+
export function inkBox(img) {
|
|
208
|
+
const g = toGray(img);
|
|
209
|
+
// ground = median gray; ink = |v - ground| > 48
|
|
210
|
+
const sample = []; for (let i = 0; i < g.data.length; i += Math.max(1, Math.floor(g.data.length / 4000))) sample.push(g.data[i]);
|
|
211
|
+
sample.sort((p, q) => p - q); const ground = sample[Math.floor(sample.length / 2)] || 255;
|
|
212
|
+
let x0 = img.width, y0 = img.height, x1 = -1, y1 = -1;
|
|
213
|
+
for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) {
|
|
214
|
+
if (Math.abs(g.data[y * img.width + x] - ground) > 48) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
|
|
215
|
+
}
|
|
216
|
+
if (x1 < 0) return null;
|
|
217
|
+
return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function regionCrop(img, r) {
|
|
221
|
+
const minPx = 48;
|
|
222
|
+
let x = r.x * img.width, y = r.y * img.height, w = r.w * img.width, h = r.h * img.height;
|
|
223
|
+
if (h < minPx) { y -= (minPx - h) / 2; h = minPx; }
|
|
224
|
+
if (w < minPx) { x -= (minPx - w) / 2; w = minPx; }
|
|
225
|
+
return crop(img, x, y, w, h);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const HEAT_LABEL = { match: [40, 160, 80, 255], drift: [220, 160, 30, 255], missing: [200, 40, 40, 255], contradicted: [200, 40, 40, 255] };
|
|
229
|
+
|
|
230
|
+
export function renderSideBySide(comp, build, label, score) {
|
|
231
|
+
const gap = 24, pad = 48;
|
|
232
|
+
const targetW = Math.min(comp.width, 1400);
|
|
233
|
+
const a = fit(comp, targetW, 100000), b = resize(build, a.width, a.height);
|
|
234
|
+
const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 24, [24, 24, 28, 255]);
|
|
235
|
+
blit(out, a, pad, pad + 24);
|
|
236
|
+
blit(out, b, pad + a.width + gap, pad + 24);
|
|
237
|
+
drawLabel(out, 'COMP', pad, pad - 4, { scale: 2 });
|
|
238
|
+
drawLabel(out, `BUILD ${label ? label.toUpperCase() : ''}`.trim(), pad + a.width + gap, pad - 4, { scale: 2 });
|
|
239
|
+
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)}%`;
|
|
240
|
+
drawLabel(out, s, pad, out.height - pad + 8, { scale: 2, bg: HEAT_LABEL[verdictFor(score)] });
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function renderHeatmap(comp, build) {
|
|
245
|
+
const map = diffMap(comp, build);
|
|
246
|
+
const base = resize(build, map.width, map.height);
|
|
247
|
+
const out = { width: base.width, height: base.height, data: new Uint8Array(base.data) };
|
|
248
|
+
for (let i = 0, p = 0; i < map.data.length; i++, p += 4) {
|
|
249
|
+
const d = map.data[i];
|
|
250
|
+
if (d < 0.12) { // dim what matches so wrong stands out
|
|
251
|
+
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;
|
|
252
|
+
}
|
|
253
|
+
const a = Math.min(1, (d - 0.12) / 0.5);
|
|
254
|
+
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;
|
|
255
|
+
}
|
|
256
|
+
const scaled = resize(out, comp.width, comp.height);
|
|
257
|
+
drawLabel(scaled, 'DIFF: RED = DIFFERS FROM COMP', 12, 12, { scale: 2 });
|
|
258
|
+
return scaled;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function renderRegionPair(compCrop, buildCrop, id, score) {
|
|
262
|
+
const gap = 16, pad = 12;
|
|
263
|
+
const maxW = 700;
|
|
264
|
+
const a = fit(compCrop, maxW, 700, true), b = resize(buildCrop, a.width, a.height);
|
|
265
|
+
const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 30, [24, 24, 28, 255]);
|
|
266
|
+
blit(out, a, pad, pad + 30);
|
|
267
|
+
blit(out, b, pad + a.width + gap, pad + 30);
|
|
268
|
+
const v = verdictFor(score);
|
|
269
|
+
drawLabel(out, `${id.toUpperCase()} COMP`, pad, pad, { scale: 2 });
|
|
270
|
+
drawLabel(out, `BUILD ${v.toUpperCase()} ${(score.overall * 100).toFixed(0)}%`, pad + a.width + gap, pad, { scale: 2, bg: HEAT_LABEL[v] });
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function compare({ comp, build, spec = null, align = 'top', label = '', kind = null }) {
|
|
275
|
+
let aligned = alignBuild(comp, build, align);
|
|
276
|
+
const whole = scorePair(comp, aligned, kind);
|
|
277
|
+
// Region crops are taken at fixed boxes, so a small global offset (a
|
|
278
|
+
// taller masthead, a scrollbar) would read every thin region as
|
|
279
|
+
// contradicted while the whole-image search forgives it. Find the best
|
|
280
|
+
// global translation once and shift the aligned build by it before
|
|
281
|
+
// cropping regions; the whole score above stays as measured.
|
|
282
|
+
// The side-by-side and heatmap show the build as captured; only the
|
|
283
|
+
// region crops read the shifted copy. (The shifted copy used to be what the
|
|
284
|
+
// side-by-side drew, and its padding read as a white "letterbox" on the
|
|
285
|
+
// build in every human review.)
|
|
286
|
+
const asCaptured = aligned;
|
|
287
|
+
const shift = bestShift(comp, aligned);
|
|
288
|
+
if (shift.dx || shift.dy) {
|
|
289
|
+
const shifted = createImage(aligned.width, aligned.height, [255, 255, 255, 255]);
|
|
290
|
+
blit(shifted, aligned, -shift.dx, -shift.dy);
|
|
291
|
+
aligned = shifted;
|
|
292
|
+
}
|
|
293
|
+
const regions = resolveRegions(comp, spec).map((r) => {
|
|
294
|
+
const a = regionCrop(comp, r), b = regionCrop(aligned, r);
|
|
295
|
+
const s = scorePair(a, b, r.kind);
|
|
296
|
+
return { ...r, score: strip(s), verdict: verdictFor(s, r.kind), inkBox: { comp: inkBox(a), build: inkBox(b) }, _a: a, _b: b };
|
|
297
|
+
});
|
|
298
|
+
const compPalette = dominantColors(comp), buildPalette = dominantColors(aligned);
|
|
299
|
+
return { label, align, whole: strip(whole), regions, aligned: asCaptured, alignedShifted: aligned, shift, compPalette, buildPalette, _whole: whole };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function strip(s) {
|
|
303
|
+
const { _detail, _bands, ...rest } = s;
|
|
304
|
+
return rest;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function writeArtifacts(result, comp, outDir) {
|
|
308
|
+
fs.mkdirSync(path.join(outDir, 'regions'), { recursive: true });
|
|
309
|
+
const side = renderSideBySide(comp, result.aligned, result.label, result.whole);
|
|
310
|
+
fs.writeFileSync(path.join(outDir, 'side-by-side.png'), encodePng(side));
|
|
311
|
+
fs.writeFileSync(path.join(outDir, 'heatmap.png'), encodePng(renderHeatmap(comp, result.aligned)));
|
|
312
|
+
const regionFiles = [];
|
|
313
|
+
for (const r of result.regions) {
|
|
314
|
+
const file = path.join(outDir, 'regions', `${r.id}.png`);
|
|
315
|
+
fs.writeFileSync(file, encodePng(renderRegionPair(r._a, r._b, r.id, r.score)));
|
|
316
|
+
regionFiles.push(file);
|
|
317
|
+
}
|
|
318
|
+
return { sideBySide: path.join(outDir, 'side-by-side.png'), heatmap: path.join(outDir, 'heatmap.png'), regionFiles };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function buildReport(result, files, meta) {
|
|
322
|
+
return {
|
|
323
|
+
tool: 'comp-diff',
|
|
324
|
+
version: 1,
|
|
325
|
+
createdAt: new Date().toISOString(),
|
|
326
|
+
...meta,
|
|
327
|
+
align: result.align,
|
|
328
|
+
overall: result.whole.overall,
|
|
329
|
+
verdict: verdictFor(result.whole),
|
|
330
|
+
scores: result.whole,
|
|
331
|
+
palette: { comp: result.compPalette.map(({ hex, coverage }) => ({ hex, coverage })), build: result.buildPalette.map(({ hex, coverage }) => ({ hex, coverage })) },
|
|
332
|
+
regions: result.regions.map(({ _a, _b, ...r }) => r),
|
|
333
|
+
files,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function summarize(report) {
|
|
338
|
+
const lines = [];
|
|
339
|
+
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)}%`);
|
|
340
|
+
lines.push(`PALETTE comp ${report.palette.comp.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
|
|
341
|
+
lines.push(`PALETTE build ${report.palette.build.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
|
|
342
|
+
for (const r of report.regions) {
|
|
343
|
+
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' : ''}`);
|
|
344
|
+
}
|
|
345
|
+
if (report.files) {
|
|
346
|
+
lines.push(`FILES side-by-side ${report.files.sideBySide}`);
|
|
347
|
+
lines.push(`FILES heatmap ${report.files.heatmap}`);
|
|
348
|
+
lines.push(`FILES regions ${report.files.regionFiles.length} under ${path.dirname(report.files.regionFiles[0] || report.files.heatmap)}`);
|
|
349
|
+
}
|
|
350
|
+
const worst = [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3);
|
|
351
|
+
if (worst.length) lines.push(`WORST ${worst.map((r) => `${r.id} (${r.verdict}, ${(r.score.overall * 100).toFixed(0)}%)`).join('; ')}`);
|
|
352
|
+
lines.push('OPEN the side-by-side and the worst region pairs before deciding anything; the numbers rank, the crops decide.');
|
|
353
|
+
return lines.join('\n');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function main() {
|
|
357
|
+
const compPath = arg('comp'), buildPath = arg('build');
|
|
358
|
+
if (!compPath || !buildPath) {
|
|
359
|
+
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]');
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
let comp, build;
|
|
363
|
+
try { comp = readPng(compPath); } catch (e) { console.error(`comp-diff: cannot read comp ${compPath}: ${e.message}`); process.exit(1); }
|
|
364
|
+
try { build = readPng(buildPath); } catch (e) { console.error(`comp-diff: cannot read build ${buildPath}: ${e.message}`); process.exit(1); }
|
|
365
|
+
let spec = null;
|
|
366
|
+
const specPath = arg('spec');
|
|
367
|
+
if (specPath) {
|
|
368
|
+
try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); } catch (e) { console.error(`comp-diff: cannot read spec ${specPath}: ${e.message}`); process.exit(1); }
|
|
369
|
+
}
|
|
370
|
+
const outDir = arg('out-dir', path.join(path.dirname(buildPath), 'diff'));
|
|
371
|
+
const label = arg('label', path.basename(buildPath, '.png'));
|
|
372
|
+
const result = compare({ comp, build, spec, align: arg('align', 'top'), label });
|
|
373
|
+
const files = flag('no-files') ? null : writeArtifacts(result, comp, outDir);
|
|
374
|
+
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}` });
|
|
375
|
+
if (files) fs.writeFileSync(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2));
|
|
376
|
+
if (flag('json')) console.log(JSON.stringify(report, null, 2));
|
|
377
|
+
else console.log(summarize(report));
|
|
378
|
+
const threshold = arg('threshold') ? parseFloat(arg('threshold')) : null;
|
|
379
|
+
if (threshold != null && report.overall < threshold) {
|
|
380
|
+
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.`);
|
|
381
|
+
process.exit(3);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// realpath on both sides: a skill mounted through a symlink (Cursor, a
|
|
386
|
+
// worktree, an eval stage) must still run as a CLI.
|
|
387
|
+
const isMain = (() => {
|
|
388
|
+
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
|
|
389
|
+
catch { return !!process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); }
|
|
390
|
+
})();
|
|
391
|
+
if (isMain) main();
|