claudecode-omc 5.6.6 → 5.6.7

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.
Files changed (58) hide show
  1. package/.local/skills/h5-to-swiftui/SKILL.md +201 -0
  2. package/.local/skills/h5-to-swiftui/assets/calibration/README.md +176 -0
  3. package/.local/skills/h5-to-swiftui/assets/calibration/h5-twin/index.html +52 -0
  4. package/.local/skills/h5-to-swiftui/assets/calibration/h5-twin/style.css +133 -0
  5. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin/Package.swift +26 -0
  6. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin/Sources/CalibrationScreen/CalibrationScreen.swift +142 -0
  7. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin-divergent/Package.swift +32 -0
  8. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin-divergent/Sources/CalibrationScreenDivergent/CalibrationScreenDivergent.swift +122 -0
  9. package/.local/skills/h5-to-swiftui/assets/calibration/tokens.json +42 -0
  10. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/index.html +14 -0
  11. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/package.json +20 -0
  12. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/public/api/articles/001.json +96 -0
  13. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/public/api/articles/index.json +89 -0
  14. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/App.jsx +22 -0
  15. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/App.module.css +11 -0
  16. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/ArticleCard.jsx +53 -0
  17. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/ArticleCard.module.css +139 -0
  18. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/NavBar.jsx +37 -0
  19. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/NavBar.module.css +72 -0
  20. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TagCloud.jsx +30 -0
  21. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TagCloud.module.css +50 -0
  22. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TrendChart.jsx +159 -0
  23. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TrendChart.module.css +21 -0
  24. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/main.jsx +12 -0
  25. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/ArticleScreen.jsx +182 -0
  26. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/ArticleScreen.module.css +294 -0
  27. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/FeedScreen.jsx +147 -0
  28. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/FeedScreen.module.css +161 -0
  29. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/styles/global.css +50 -0
  30. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/styles/tokens.css +103 -0
  31. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/vite.config.js +6 -0
  32. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/data/tasks.js +67 -0
  33. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/index.html +26 -0
  34. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/router.js +73 -0
  35. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/detail.js +164 -0
  36. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/home.js +53 -0
  37. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/list.js +87 -0
  38. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/styles/app.css +342 -0
  39. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/styles/tokens.css +68 -0
  40. package/.local/skills/h5-to-swiftui/references/css-to-swiftui-map.md +205 -0
  41. package/.local/skills/h5-to-swiftui/references/design-token-extraction.md +209 -0
  42. package/.local/skills/h5-to-swiftui/references/high-risk-triage.md +209 -0
  43. package/.local/skills/h5-to-swiftui/references/render-equivalence-calibration.md +193 -0
  44. package/.local/skills/h5-to-swiftui/references/stack-detection.md +160 -0
  45. package/.local/skills/h5-to-swiftui/references/visual-diff-loop-protocol.md +365 -0
  46. package/.local/skills/h5-to-swiftui/scripts/_calib-consts.mjs +150 -0
  47. package/.local/skills/h5-to-swiftui/scripts/_imglib.mjs +547 -0
  48. package/.local/skills/h5-to-swiftui/scripts/_provenance.mjs +123 -0
  49. package/.local/skills/h5-to-swiftui/scripts/calibrate-render.mjs +625 -0
  50. package/.local/skills/h5-to-swiftui/scripts/capture-reference.mjs +386 -0
  51. package/.local/skills/h5-to-swiftui/scripts/detect-stack.mjs +305 -0
  52. package/.local/skills/h5-to-swiftui/scripts/evaluate-convergence.mjs +1093 -0
  53. package/.local/skills/h5-to-swiftui/scripts/extract-tokens.mjs +600 -0
  54. package/.local/skills/h5-to-swiftui/scripts/mark-overlay.mjs +379 -0
  55. package/.local/skills/h5-to-swiftui/scripts/pixel-diff.mjs +530 -0
  56. package/.local/skills/h5-to-swiftui/scripts/sim-screenshot.sh +544 -0
  57. package/bundled/manifest.json +1 -1
  58. package/package.json +1 -1
@@ -0,0 +1,150 @@
1
+ /**
2
+ * _calib-consts.mjs — Single source of truth for the calibration sanity
3
+ * envelope shared by the PRODUCER (`calibrate-render.mjs`) and the CONSUMER
4
+ * (`evaluate-convergence.mjs`).
5
+ *
6
+ * Why this module exists (audit #3 CRITICAL-1):
7
+ * `calibration_source` binds the *identity* of the bundled twin source
8
+ * files but NOT the *measured floor values*. Without this module an
9
+ * attacker could write an absurdly loose `floor`
10
+ * (e.g. ssim_nontext:0.05, deltaE_p95:200), copy the REAL public bundled
11
+ * twin source hashes into `calibration_source`, let the gate recompute
12
+ * from that absurd floor, and grade visually-broken output as converged
13
+ * with ZERO shipped files altered.
14
+ *
15
+ * `calibrate-render.mjs` ALREADY refuses to EMIT a `floor` that fails its
16
+ * own sanity bound — it writes `blocked.json` instead of
17
+ * `calibration.json`. Therefore a `calibration.json` whose `floor`
18
+ * violates that same bound *could not have been produced by an honest
19
+ * `calibrate-render.mjs` run*. The grader may reject it on
20
+ * consistency grounds. This module hoists the EXACT constants
21
+ * `calibrate-render.mjs` already enforces so producer and consumer use a
22
+ * SINGLE source of truth (mirrors how `_provenance.mjs` shares hashing).
23
+ *
24
+ * Provenance of each bound (NO new lenient constant is invented here — every
25
+ * number is exactly what `calibrate-render.mjs` already enforces, cited):
26
+ *
27
+ * - SSIM_NONTEXT_MIN = 0.95
28
+ * Source: `calibrate-render.mjs` `const SSIM_NONTEXT_MIN = 0.95;`
29
+ * (the ssim_nontext sanity floor it refuses to emit below) and
30
+ * `references/render-equivalence-calibration.md` §"Sanity bound"
31
+ * (`ssim_nontext ≥ 0.95`), itself derived from `findings.md` RQ4
32
+ * ("SSIM ... < 0.92 = fail"; 0.95 is the conservative measurable
33
+ * floor, not the 0.995 same-renderer regression value §1.1 warns
34
+ * against). An honest calibrate-render run NEVER emits
35
+ * floor.ssim_nontext < 0.95.
36
+ *
37
+ * - TEXT_IOU_MIN = 0.9
38
+ * Source: `calibrate-render.mjs` `const TEXT_IOU_MIN = 0.9;` (the
39
+ * text_iou sanity floor it enforces *when text_iou is non-null*;
40
+ * skipped when null because no bbox map exists at calibration time)
41
+ * and `references/render-equivalence-calibration.md` §"Sanity bound"
42
+ * (`text_iou ≥ 0.9`). An honest calibrate-render run NEVER emits a
43
+ * non-null floor.text_iou < 0.9.
44
+ *
45
+ * - deltaE_p95: there is DELIBERATELY no shared maximum here.
46
+ * `calibrate-render.mjs`'s sanity bound (its `ssimFails`/`textIouFails`
47
+ * /`flatFails` block) does NOT bound deltaE_p95 from above — it only
48
+ * gates ssim_nontext, text_iou, and the flat-image variance guard.
49
+ * Inventing a deltaE ceiling here would be a NEW lenient constant with
50
+ * no producer-side counterpart, which the task explicitly forbids
51
+ * ("Derive any envelope numbers ONLY from what calibrate-render
52
+ * already enforces ... do NOT invent a new lenient constant").
53
+ * The only deltaE assertion the consumer may make is the
54
+ * metric-validity one below (finite and ≥ 0) — a CIEDE2000 ΔE is a
55
+ * non-negative real; a negative/NaN/Infinity value could not be a real
56
+ * `ciede2000Region` output. This is a validity check, not a lenient
57
+ * tolerance, so it is principled.
58
+ *
59
+ * No npm dependencies — plain constants + a pure validator.
60
+ */
61
+
62
+ // ── Sanity-envelope constants (verbatim from calibrate-render.mjs) ────────────
63
+ //
64
+ // calibrate-render.mjs line ~430: `const SSIM_NONTEXT_MIN = 0.95;`
65
+ export const SSIM_NONTEXT_MIN = 0.95;
66
+ // calibrate-render.mjs line ~431: `const TEXT_IOU_MIN = 0.9;`
67
+ export const TEXT_IOU_MIN = 0.9;
68
+
69
+ /**
70
+ * Validate a `calibration.json` `floor` against the SAME sanity envelope
71
+ * `calibrate-render.mjs` enforces before it is willing to EMIT a
72
+ * `calibration.json` (vs writing `blocked.json`).
73
+ *
74
+ * Returns { ok:true } or { ok:false, reasons:[...] }. Pure; no I/O.
75
+ *
76
+ * What is asserted (all derived from calibrate-render's own behavior):
77
+ * 1. ssim_nontext is a finite number in [0,1] AND ≥ SSIM_NONTEXT_MIN.
78
+ * (calibrate-render writes blocked.json, not calibration.json, when
79
+ * ssim_nontext < SSIM_NONTEXT_MIN — so a real run never emits below it.)
80
+ * 2. deltaE_p95 is a finite number ≥ 0. (A CIEDE2000 ΔE is a non-negative
81
+ * real; calibrate-render imposes no UPPER deltaE bound, so neither does
82
+ * the grader — only metric validity, not a lenient tolerance.)
83
+ * 3. text_iou is null OR a finite number in [0,1]; when non-null it must be
84
+ * ≥ TEXT_IOU_MIN (calibrate-render's textIouFails gate, skipped on null).
85
+ *
86
+ * A `floor` failing ANY of these could not have been produced by an honest
87
+ * `calibrate-render.mjs` run (it would have written blocked.json), so the
88
+ * grader rejects it as `floor-implausible` rather than recomputing a gate
89
+ * from a fabricated floor.
90
+ */
91
+ export function floorWithinCalibrateEnvelope(floor) {
92
+ const reasons = [];
93
+
94
+ if (!floor || typeof floor !== 'object') {
95
+ return { ok: false, reasons: ['floor is absent or not an object'] };
96
+ }
97
+
98
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
99
+
100
+ // 1. ssim_nontext — calibrate-render's SSIM_NONTEXT_MIN sanity floor.
101
+ const s = floor.ssim_nontext;
102
+ if (!finite(s)) {
103
+ reasons.push(
104
+ `floor.ssim_nontext=${JSON.stringify(s)} is not a finite number ` +
105
+ `(SSIM is a real in [0,1]; not a producible calibrate-render output)`);
106
+ } else if (s < 0 || s > 1) {
107
+ reasons.push(
108
+ `floor.ssim_nontext=${s} is outside the valid SSIM range [0,1] ` +
109
+ `(not a producible calibrate-render output)`);
110
+ } else if (s < SSIM_NONTEXT_MIN) {
111
+ reasons.push(
112
+ `floor.ssim_nontext=${s} < calibrate-render sanity minimum ` +
113
+ `${SSIM_NONTEXT_MIN} — calibrate-render writes blocked.json (not ` +
114
+ `calibration.json) below this, so this floor could not be a real run`);
115
+ }
116
+
117
+ // 2. deltaE_p95 — metric validity only (no producer-side upper bound).
118
+ const d = floor.deltaE_p95;
119
+ if (!finite(d)) {
120
+ reasons.push(
121
+ `floor.deltaE_p95=${JSON.stringify(d)} is not a finite number ` +
122
+ `(CIEDE2000 ΔE is a finite non-negative real; not a producible output)`);
123
+ } else if (d < 0) {
124
+ reasons.push(
125
+ `floor.deltaE_p95=${d} is negative — a CIEDE2000 ΔE is non-negative ` +
126
+ `(not a producible calibrate-render output)`);
127
+ }
128
+
129
+ // 3. text_iou — null is valid (no bbox map); when non-null,
130
+ // calibrate-render's TEXT_IOU_MIN sanity floor applies.
131
+ const t = floor.text_iou;
132
+ if (t === null) {
133
+ // valid — calibrate-render emits null when no bbox map exists.
134
+ } else if (!finite(t)) {
135
+ reasons.push(
136
+ `floor.text_iou=${JSON.stringify(t)} is neither null nor a finite ` +
137
+ `number (not a producible calibrate-render output)`);
138
+ } else if (t < 0 || t > 1) {
139
+ reasons.push(
140
+ `floor.text_iou=${t} is outside the valid IoU range [0,1] ` +
141
+ `(not a producible calibrate-render output)`);
142
+ } else if (t < TEXT_IOU_MIN) {
143
+ reasons.push(
144
+ `floor.text_iou=${t} < calibrate-render sanity minimum ` +
145
+ `${TEXT_IOU_MIN} — calibrate-render writes blocked.json below this ` +
146
+ `for a non-null text_iou, so this floor could not be a real run`);
147
+ }
148
+
149
+ return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
150
+ }
@@ -0,0 +1,547 @@
1
+ /**
2
+ * _imglib.mjs — Shared image primitives for h5-to-swiftui scripts
3
+ *
4
+ * Provides:
5
+ * loadPng(path) → { width, height, data: Uint8Array (RGBA) }
6
+ * savePng(path, img) → void (writes RGBA raster as PNG)
7
+ * pHash(img) → BigInt (DCT 8x8 64-bit perceptual hash)
8
+ * hammingDistance(a, b) → number (0–64)
9
+ * ssimWindow(imgA, imgB, roi?, windowSize?) → number (0–1)
10
+ * ciede2000Region(imgA, imgB, roi?) → { p50, p95, mean }
11
+ * resampleLanczos3(img, targetW, targetH) → img
12
+ * p3ToSrgb(img) → img (in-place sRGB conversion, relative colorimetric)
13
+ * cropRect(img, x, y, w, h) → img
14
+ * drawRect(img, x, y, w, h, r, g, b, a) → void (in-place)
15
+ * drawFilledRect(img, x, y, w, h, r, g, b, alpha) → void (in-place, alpha blend)
16
+ * bboxIoU(a, b) → number (Intersection-over-Union for {x,y,w,h} rects)
17
+ *
18
+ * PNG backend: pngjs if installed; otherwise exits 2 with an actionable hint.
19
+ *
20
+ * All pixel data is RGBA (4 bytes per pixel, row-major).
21
+ * Coordinate system: (0,0) = top-left.
22
+ */
23
+
24
+ // ── PNG backend ───────────────────────────────────────────────────────────────
25
+
26
+ let PNG;
27
+
28
+ /**
29
+ * Load the PNG codec. Tries pngjs first; if unavailable prints a hint and
30
+ * exits 2 so callers can distinguish "image dep missing" from other errors.
31
+ */
32
+ export async function requirePng() {
33
+ if (PNG) return PNG;
34
+ try {
35
+ const mod = await import('pngjs');
36
+ PNG = mod.PNG;
37
+ return PNG;
38
+ } catch {
39
+ console.error(
40
+ '\nError: image dependency "pngjs" is not installed.\n\n' +
41
+ 'pixel-diff.mjs, calibrate-render.mjs, and mark-overlay.mjs require it.\n\n' +
42
+ 'Install it in the SKILL directory (or your project root) so that Node\n' +
43
+ "ESM `import('pngjs')` can resolve it via normal node_modules lookup\n" +
44
+ '(NODE_PATH is ignored by ESM):\n\n' +
45
+ ' cd <h5-to-swiftui skill dir> # the dir containing scripts/\n' +
46
+ ' npm install pngjs\n\n' +
47
+ 'Then re-run the script.'
48
+ );
49
+ process.exit(2);
50
+ }
51
+ }
52
+
53
+ // ── I/O ───────────────────────────────────────────────────────────────────────
54
+
55
+ import { readFileSync, writeFileSync } from 'node:fs';
56
+
57
+ /**
58
+ * Load a PNG file into { width, height, data: Uint8Array (RGBA) }.
59
+ * Calls requirePng() internally — call it once at startup if you want the
60
+ * dependency error to surface early.
61
+ */
62
+ export async function loadPng(filePath) {
63
+ const Png = await requirePng();
64
+ const buf = readFileSync(filePath);
65
+ return new Promise((resolve, reject) => {
66
+ const png = new Png();
67
+ png.parse(buf, (err, img) => {
68
+ if (err) return reject(err);
69
+ resolve({ width: img.width, height: img.height, data: new Uint8Array(img.data.buffer) });
70
+ });
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Write RGBA raster to a PNG file.
76
+ */
77
+ export async function savePng(filePath, img) {
78
+ const Png = await requirePng();
79
+ const png = new Png({ width: img.width, height: img.height });
80
+ png.data = Buffer.from(img.data.buffer);
81
+ const buf = Png.sync.write(png);
82
+ writeFileSync(filePath, buf);
83
+ }
84
+
85
+ // ── Perceptual hash (DCT 8x8) ─────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Compute a 64-bit perceptual hash using the standard DCT-hash approach:
89
+ * resize to 32×32 grayscale → 32×32 DCT → top-left 8×8 AC → median → bitmask.
90
+ * Returns a BigInt (64 bits).
91
+ */
92
+ export function pHash(img) {
93
+ const SIZE = 32;
94
+ const small = resampleLanczos3(img, SIZE, SIZE);
95
+ // Grayscale (luma BT.601)
96
+ const gray = new Float64Array(SIZE * SIZE);
97
+ for (let i = 0; i < SIZE * SIZE; i++) {
98
+ const o = i * 4;
99
+ gray[i] = 0.299 * small.data[o] + 0.587 * small.data[o + 1] + 0.114 * small.data[o + 2];
100
+ }
101
+ // 2-D DCT-II (separable)
102
+ const dct = dct2d(gray, SIZE, SIZE);
103
+ // Take top-left 8×8 (skip DC at [0,0])
104
+ const ac = [];
105
+ for (let row = 0; row < 8; row++) {
106
+ for (let col = 0; col < 8; col++) {
107
+ if (row === 0 && col === 0) continue;
108
+ ac.push(dct[row * SIZE + col]);
109
+ }
110
+ }
111
+ const median = quantile(ac, 0.5);
112
+ // Build 64-bit hash (include DC slot as 0 for consistent length)
113
+ let hash = 0n;
114
+ let bit = 63n;
115
+ for (let row = 0; row < 8; row++) {
116
+ for (let col = 0; col < 8; col++) {
117
+ const val = row === 0 && col === 0 ? 0 : dct[row * SIZE + col];
118
+ if (val > median) hash |= (1n << bit);
119
+ bit--;
120
+ }
121
+ }
122
+ return hash;
123
+ }
124
+
125
+ /** Hamming distance between two BigInt hashes (0–64). */
126
+ export function hammingDistance(a, b) {
127
+ let xor = a ^ b;
128
+ let count = 0;
129
+ while (xor) { xor &= xor - 1n; count++; }
130
+ return count;
131
+ }
132
+
133
+ // ── SSIM ──────────────────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Compute SSIM over an optional ROI {x, y, w, h} (defaults to full image).
137
+ * Uses an 8-pixel sliding window as per the calibration reference.
138
+ * Returns a value in [0, 1].
139
+ */
140
+ export function ssimWindow(imgA, imgB, roi, windowSize = 8) {
141
+ const { x = 0, y = 0, w = imgA.width, h = imgA.height } = roi ?? {};
142
+
143
+ const C1 = (0.01 * 255) ** 2;
144
+ const C2 = (0.03 * 255) ** 2;
145
+ let ssimSum = 0;
146
+ let count = 0;
147
+
148
+ const stepX = Math.max(1, Math.floor(windowSize / 2));
149
+ const stepY = Math.max(1, Math.floor(windowSize / 2));
150
+
151
+ for (let wy = y; wy + windowSize <= y + h; wy += stepY) {
152
+ for (let wx = x; wx + windowSize <= x + w; wx += stepX) {
153
+ let sumA = 0, sumB = 0, sumA2 = 0, sumB2 = 0, sumAB = 0, n = 0;
154
+ for (let py = wy; py < wy + windowSize; py++) {
155
+ for (let px = wx; px < wx + windowSize; px++) {
156
+ const ia = (py * imgA.width + px) * 4;
157
+ const ib = (py * imgB.width + px) * 4;
158
+ const la = imgLuma(imgA.data, ia);
159
+ const lb = imgLuma(imgB.data, ib);
160
+ sumA += la; sumB += lb;
161
+ sumA2 += la * la; sumB2 += lb * lb; sumAB += la * lb;
162
+ n++;
163
+ }
164
+ }
165
+ const muA = sumA / n, muB = sumB / n;
166
+ const sigA2 = sumA2 / n - muA * muA;
167
+ const sigB2 = sumB2 / n - muB * muB;
168
+ const sigAB = sumAB / n - muA * muB;
169
+ const num = (2 * muA * muB + C1) * (2 * sigAB + C2);
170
+ const den = (muA * muA + muB * muB + C1) * (sigA2 + sigB2 + C2);
171
+ ssimSum += den > 0 ? num / den : 1;
172
+ count++;
173
+ }
174
+ }
175
+ return count > 0 ? ssimSum / count : 1;
176
+ }
177
+
178
+ function imgLuma(data, offset) {
179
+ return 0.299 * data[offset] + 0.587 * data[offset + 1] + 0.114 * data[offset + 2];
180
+ }
181
+
182
+ // ── CIEDE2000 ─────────────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Compute CIEDE2000 ΔE statistics over an optional ROI.
186
+ * Returns { p50, p95, mean }.
187
+ */
188
+ export function ciede2000Region(imgA, imgB, roi) {
189
+ const { x = 0, y = 0, w = imgA.width, h = imgA.height } = roi ?? {};
190
+ const deltas = [];
191
+
192
+ for (let py = y; py < y + h; py++) {
193
+ for (let px = x; px < x + w; px++) {
194
+ const ia = (py * imgA.width + px) * 4;
195
+ const ib = (py * imgB.width + px) * 4;
196
+ const labA = srgbToLab(imgA.data[ia], imgA.data[ia + 1], imgA.data[ia + 2]);
197
+ const labB = srgbToLab(imgB.data[ib], imgB.data[ib + 1], imgB.data[ib + 2]);
198
+ deltas.push(ciede2000(labA, labB));
199
+ }
200
+ }
201
+
202
+ deltas.sort((a, b) => a - b);
203
+ const n = deltas.length;
204
+ return {
205
+ p50: n > 0 ? quantile(deltas, 0.5) : 0,
206
+ p95: n > 0 ? quantile(deltas, 0.95) : 0,
207
+ mean: n > 0 ? deltas.reduce((s, v) => s + v, 0) / n : 0,
208
+ };
209
+ }
210
+
211
+ // sRGB → XYZ D65 → Lab
212
+ function srgbToLab(r, g, b) {
213
+ const lin = (v) => {
214
+ const n = v / 255;
215
+ return n <= 0.04045 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
216
+ };
217
+ const lr = lin(r), lg = lin(g), lb = lin(b);
218
+ // sRGB D65 matrix
219
+ const X = 0.4124564 * lr + 0.3575761 * lg + 0.1804375 * lb;
220
+ const Y = 0.2126729 * lr + 0.7151522 * lg + 0.0721750 * lb;
221
+ const Z = 0.0193339 * lr + 0.1191920 * lg + 0.9503041 * lb;
222
+ // D65 white point
223
+ const xn = 0.95047, yn = 1.00000, zn = 1.08883;
224
+ const f = (t) => t > 0.008856 ? t ** (1 / 3) : 7.787 * t + 16 / 116;
225
+ const L = 116 * f(Y / yn) - 16;
226
+ const a = 500 * (f(X / xn) - f(Y / yn));
227
+ const bv = 200 * (f(Y / yn) - f(Z / zn));
228
+ return { L, a, b: bv };
229
+ }
230
+
231
+ // CIEDE2000 formula
232
+ function ciede2000(lab1, lab2) {
233
+ const { L: L1, a: a1, b: b1 } = lab1;
234
+ const { L: L2, a: a2, b: b2 } = lab2;
235
+ const avgL = (L1 + L2) / 2;
236
+ const C1 = Math.sqrt(a1 * a1 + b1 * b1);
237
+ const C2 = Math.sqrt(a2 * a2 + b2 * b2);
238
+ const avgC = (C1 + C2) / 2;
239
+ const avgC7 = avgC ** 7;
240
+ const g = 0.5 * (1 - Math.sqrt(avgC7 / (avgC7 + 25 ** 7)));
241
+ const a1p = a1 * (1 + g);
242
+ const a2p = a2 * (1 + g);
243
+ const C1p = Math.sqrt(a1p * a1p + b1 * b1);
244
+ const C2p = Math.sqrt(a2p * a2p + b2 * b2);
245
+ const h1p = Math.atan2(b1, a1p) * (180 / Math.PI) + (Math.atan2(b1, a1p) < 0 ? 360 : 0);
246
+ const h2p = Math.atan2(b2, a2p) * (180 / Math.PI) + (Math.atan2(b2, a2p) < 0 ? 360 : 0);
247
+ const dLp = L2 - L1;
248
+ const dCp = C2p - C1p;
249
+ let dhp = h2p - h1p;
250
+ if (C1p * C2p === 0) dhp = 0;
251
+ else if (Math.abs(dhp) > 180) dhp += dhp < 0 ? 360 : -360;
252
+ const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp * Math.PI / 180) / 2);
253
+ const avgLp = (L1 + L2) / 2;
254
+ const avgCp = (C1p + C2p) / 2;
255
+ let avghp = (h1p + h2p) / 2;
256
+ if (C1p * C2p !== 0 && Math.abs(h1p - h2p) > 180) {
257
+ avghp += avghp < 180 ? 180 : -180;
258
+ }
259
+ const T =
260
+ 1 -
261
+ 0.17 * Math.cos((avghp - 30) * Math.PI / 180) +
262
+ 0.24 * Math.cos(2 * avghp * Math.PI / 180) +
263
+ 0.32 * Math.cos((3 * avghp + 6) * Math.PI / 180) -
264
+ 0.20 * Math.cos((4 * avghp - 63) * Math.PI / 180);
265
+ const SL = 1 + 0.015 * (avgLp - 50) ** 2 / Math.sqrt(20 + (avgLp - 50) ** 2);
266
+ const SC = 1 + 0.045 * avgCp;
267
+ const SH = 1 + 0.015 * avgCp * T;
268
+ const avgCp7 = avgCp ** 7;
269
+ const RC = 2 * Math.sqrt(avgCp7 / (avgCp7 + 25 ** 7));
270
+ const dTheta = 30 * Math.exp(-(((avghp - 275) / 25) ** 2));
271
+ const RT = -Math.sin(2 * dTheta * Math.PI / 180) * RC;
272
+ return Math.sqrt(
273
+ (dLp / SL) ** 2 +
274
+ (dCp / SC) ** 2 +
275
+ (dHp / SH) ** 2 +
276
+ RT * (dCp / SC) * (dHp / SH)
277
+ );
278
+ }
279
+
280
+ // ── P3 → sRGB ─────────────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Convert Display-P3 pixel values to sRGB in-place (relative colorimetric).
284
+ * Input/output: RGBA bytes (Uint8Array or Buffer).
285
+ * Returns the same img object.
286
+ */
287
+ export function p3ToSrgb(img) {
288
+ const data = img.data;
289
+ for (let i = 0; i < data.length; i += 4) {
290
+ const [r, g, b] = p3PixelToSrgb(data[i], data[i + 1], data[i + 2]);
291
+ data[i] = r; data[i + 1] = g; data[i + 2] = b;
292
+ }
293
+ return img;
294
+ }
295
+
296
+ function p3PixelToSrgb(r8, g8, b8) {
297
+ // Linearize P3 (same gamma as sRGB)
298
+ const lin = (v) => {
299
+ const n = v / 255;
300
+ return n <= 0.04045 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
301
+ };
302
+ const lr = lin(r8), lg = lin(g8), lb = lin(b8);
303
+ // P3 D65 → XYZ
304
+ const X = 0.4865709 * lr + 0.2656677 * lg + 0.1982173 * lb;
305
+ const Y = 0.2289946 * lr + 0.6917385 * lg + 0.0792669 * lb;
306
+ const Z = 0.0000000 * lr + 0.0451134 * lg + 1.0439444 * lb;
307
+ // XYZ → sRGB D65
308
+ const lR = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z;
309
+ const lG = -0.9692660 * X + 1.8760108 * Y + 0.0415560 * Z;
310
+ const lB = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z;
311
+ // Gamma-encode
312
+ const enc = (v) => {
313
+ const c = Math.max(0, Math.min(1, v));
314
+ return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055) * 255);
315
+ };
316
+ return [enc(lR), enc(lG), enc(lB)];
317
+ }
318
+
319
+ // ── Lanczos3 resampling ───────────────────────────────────────────────────────
320
+
321
+ /**
322
+ * Resample img to targetW × targetH using a Lanczos-3 filter.
323
+ * Returns a new image object.
324
+ */
325
+ export function resampleLanczos3(img, targetW, targetH) {
326
+ const out = {
327
+ width: targetW,
328
+ height: targetH,
329
+ data: new Uint8Array(targetW * targetH * 4),
330
+ };
331
+
332
+ const lanczos = (x) => {
333
+ if (x === 0) return 1;
334
+ if (Math.abs(x) >= 3) return 0;
335
+ const px = Math.PI * x;
336
+ return 3 * Math.sin(px) * Math.sin(px / 3) / (px * px);
337
+ };
338
+
339
+ const scaleX = img.width / targetW;
340
+ const scaleY = img.height / targetH;
341
+
342
+ for (let dy = 0; dy < targetH; dy++) {
343
+ for (let dx = 0; dx < targetW; dx++) {
344
+ const srcX = (dx + 0.5) * scaleX - 0.5;
345
+ const srcY = (dy + 0.5) * scaleY - 0.5;
346
+ const x0 = Math.floor(srcX) - 2;
347
+ const y0 = Math.floor(srcY) - 2;
348
+ let r = 0, g = 0, b = 0, a = 0, wSum = 0;
349
+ for (let ky = 0; ky < 6; ky++) {
350
+ const py = Math.max(0, Math.min(img.height - 1, y0 + ky));
351
+ const wy = lanczos(srcY - (y0 + ky));
352
+ for (let kx = 0; kx < 6; kx++) {
353
+ const px = Math.max(0, Math.min(img.width - 1, x0 + kx));
354
+ const wx = lanczos(srcX - (x0 + kx));
355
+ const w = wx * wy;
356
+ const off = (py * img.width + px) * 4;
357
+ r += w * img.data[off];
358
+ g += w * img.data[off + 1];
359
+ b += w * img.data[off + 2];
360
+ a += w * img.data[off + 3];
361
+ wSum += w;
362
+ }
363
+ }
364
+ const doff = (dy * targetW + dx) * 4;
365
+ out.data[doff] = Math.max(0, Math.min(255, Math.round(r / wSum)));
366
+ out.data[doff + 1] = Math.max(0, Math.min(255, Math.round(g / wSum)));
367
+ out.data[doff + 2] = Math.max(0, Math.min(255, Math.round(b / wSum)));
368
+ out.data[doff + 3] = Math.max(0, Math.min(255, Math.round(a / wSum)));
369
+ }
370
+ }
371
+ return out;
372
+ }
373
+
374
+ // ── Crop ─────────────────────────────────────────────────────────────────────
375
+
376
+ /** Extract a rectangular sub-image. Returns new image object. */
377
+ export function cropRect(img, x, y, w, h) {
378
+ const out = { width: w, height: h, data: new Uint8Array(w * h * 4) };
379
+ for (let row = 0; row < h; row++) {
380
+ const srcOff = ((y + row) * img.width + x) * 4;
381
+ const dstOff = row * w * 4;
382
+ out.data.set(img.data.subarray(srcOff, srcOff + w * 4), dstOff);
383
+ }
384
+ return out;
385
+ }
386
+
387
+ // ── Drawing primitives ────────────────────────────────────────────────────────
388
+
389
+ /** Draw a solid-colour border rectangle (outline only, 1px). In-place. */
390
+ export function drawRect(img, x, y, w, h, r, g, b, alpha = 255) {
391
+ const setPixel = (px, py) => {
392
+ if (px < 0 || px >= img.width || py < 0 || py >= img.height) return;
393
+ const o = (py * img.width + px) * 4;
394
+ img.data[o] = r; img.data[o + 1] = g; img.data[o + 2] = b; img.data[o + 3] = alpha;
395
+ };
396
+ for (let i = x; i < x + w; i++) { setPixel(i, y); setPixel(i, y + h - 1); }
397
+ for (let j = y; j < y + h; j++) { setPixel(x, j); setPixel(x + w - 1, j); }
398
+ }
399
+
400
+ /** Draw a translucent filled rectangle with alpha blending. In-place. */
401
+ export function drawFilledRect(img, x, y, w, h, r, g, b, alpha) {
402
+ const a01 = alpha / 255;
403
+ const ia = 1 - a01;
404
+ for (let py = Math.max(0, y); py < Math.min(img.height, y + h); py++) {
405
+ for (let px = Math.max(0, x); px < Math.min(img.width, x + w); px++) {
406
+ const o = (py * img.width + px) * 4;
407
+ img.data[o] = Math.round(r * a01 + img.data[o] * ia);
408
+ img.data[o + 1] = Math.round(g * a01 + img.data[o + 1] * ia);
409
+ img.data[o + 2] = Math.round(b * a01 + img.data[o + 2] * ia);
410
+ img.data[o + 3] = Math.min(255, img.data[o + 3] + alpha);
411
+ }
412
+ }
413
+ }
414
+
415
+ // ── IoU ───────────────────────────────────────────────────────────────────────
416
+
417
+ /** Intersection-over-Union for two {x, y, w, h} bounding boxes. */
418
+ export function bboxIoU(a, b) {
419
+ const ix = Math.max(a.x, b.x);
420
+ const iy = Math.max(a.y, b.y);
421
+ const ix2 = Math.min(a.x + a.w, b.x + b.w);
422
+ const iy2 = Math.min(a.y + a.h, b.y + b.h);
423
+ const inter = Math.max(0, ix2 - ix) * Math.max(0, iy2 - iy);
424
+ const union = a.w * a.h + b.w * b.h - inter;
425
+ return union > 0 ? inter / union : 0;
426
+ }
427
+
428
+ // ── Internal math ─────────────────────────────────────────────────────────────
429
+
430
+ /** Quantile of a sorted array (linear interpolation). */
431
+ export function quantile(sorted, q) {
432
+ if (sorted.length === 0) return 0;
433
+ if (sorted.length === 1) return sorted[0];
434
+ const pos = q * (sorted.length - 1);
435
+ const lo = Math.floor(pos);
436
+ const hi = Math.ceil(pos);
437
+ const frac = pos - lo;
438
+ return sorted[lo] * (1 - frac) + sorted[hi] * frac;
439
+ }
440
+
441
+ /** Separable 2-D DCT-II on a flat Float64Array of size rows×cols. */
442
+ function dct2d(signal, cols, rows) {
443
+ const out = new Float64Array(rows * cols);
444
+ // DCT-II row-wise
445
+ const tmp = new Float64Array(rows * cols);
446
+ for (let row = 0; row < rows; row++) {
447
+ dct1d(signal, tmp, row * cols, cols);
448
+ }
449
+ // DCT-II column-wise
450
+ const col1d = new Float64Array(rows);
451
+ const col1dOut = new Float64Array(rows);
452
+ for (let col = 0; col < cols; col++) {
453
+ for (let row = 0; row < rows; row++) col1d[row] = tmp[row * cols + col];
454
+ dct1d(col1d, col1dOut, 0, rows);
455
+ for (let row = 0; row < rows; row++) out[row * cols + col] = col1dOut[row];
456
+ }
457
+ return out;
458
+ }
459
+
460
+ function dct1d(input, output, offset, n) {
461
+ for (let k = 0; k < n; k++) {
462
+ let sum = 0;
463
+ for (let i = 0; i < n; i++) {
464
+ sum += input[offset + i] * Math.cos((Math.PI / n) * (i + 0.5) * k);
465
+ }
466
+ output[offset + k] = sum;
467
+ }
468
+ }
469
+
470
+ // ── AA-tolerant diff mask ─────────────────────────────────────────────────────
471
+
472
+ /**
473
+ * Build an AA-tolerant diff mask (pixelmatch YIQ / anti-aliasing detection).
474
+ * Returns { mask: img, diffPixelCount, totalPixels }.
475
+ *
476
+ * mask pixels: { r:255, g:0, b:0 } = differing; { r:255,g:255,b:0 } = AA-skip; rest = transparent.
477
+ */
478
+ export function buildDiffMask(imgA, imgB, threshold = 0.1) {
479
+ const { width, height } = imgA;
480
+ const mask = { width, height, data: new Uint8Array(width * height * 4) };
481
+ let diffCount = 0;
482
+
483
+ const yiq = (r, g, b) => 0.299 * r + 0.587 * g + 0.114 * b;
484
+
485
+ for (let py = 0; py < height; py++) {
486
+ for (let px = 0; px < width; px++) {
487
+ const ia = (py * width + px) * 4;
488
+ const ib = (py * imgB.width + px) * 4;
489
+ const dr = imgA.data[ia] - imgB.data[ib];
490
+ const dg = imgA.data[ia + 1] - imgB.data[ib + 1];
491
+ const db = imgA.data[ia + 2] - imgB.data[ib + 2];
492
+ const yDelta = Math.abs(yiq(dr, dg, db)) / 255;
493
+
494
+ const mo = (py * width + px) * 4;
495
+ if (yDelta <= threshold) {
496
+ // same — leave transparent
497
+ mask.data[mo + 3] = 0;
498
+ continue;
499
+ }
500
+
501
+ // Check anti-aliasing: if pixel is part of an AA edge in either image, skip
502
+ if (isAntialiased(imgA, px, py, width, height) ||
503
+ isAntialiased(imgB, px, py, imgB.width, imgB.height)) {
504
+ // AA skip — yellow
505
+ mask.data[mo] = 255;
506
+ mask.data[mo + 1] = 255;
507
+ mask.data[mo + 2] = 0;
508
+ mask.data[mo + 3] = 200;
509
+ continue;
510
+ }
511
+
512
+ // Real diff — red
513
+ mask.data[mo] = 255;
514
+ mask.data[mo + 1] = 0;
515
+ mask.data[mo + 2] = 0;
516
+ mask.data[mo + 3] = 255;
517
+ diffCount++;
518
+ }
519
+ }
520
+ return { mask, diffPixelCount: diffCount, totalPixels: width * height };
521
+ }
522
+
523
+ function isAntialiased(img, px, py, w, h) {
524
+ // Heuristic: check 3×3 neighbourhood for rapid luma transitions
525
+ const getLuma = (x, y) => {
526
+ if (x < 0 || x >= w || y < 0 || y >= h) return null;
527
+ const o = (y * w + x) * 4;
528
+ return 0.299 * img.data[o] + 0.587 * img.data[o + 1] + 0.114 * img.data[o + 2];
529
+ };
530
+ const center = getLuma(px, py);
531
+ let minDelta = Infinity, maxDelta = -Infinity;
532
+ let transitions = 0;
533
+ let prev = null;
534
+ for (let dy = -1; dy <= 1; dy++) {
535
+ for (let dx = -1; dx <= 1; dx++) {
536
+ if (dx === 0 && dy === 0) continue;
537
+ const v = getLuma(px + dx, py + dy);
538
+ if (v === null) continue;
539
+ const d = Math.abs(v - center);
540
+ if (d < minDelta) minDelta = d;
541
+ if (d > maxDelta) maxDelta = d;
542
+ if (prev !== null && Math.abs(v - prev) > 10) transitions++;
543
+ prev = v;
544
+ }
545
+ }
546
+ return maxDelta > 10 && minDelta < 10 && transitions >= 2;
547
+ }