@openprd/cli 0.1.10 → 0.1.18
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/.openprd/benchmarks/index.md +17 -15
- package/.openprd/benchmarks/sources.yaml +53 -1
- package/.openprd/changes/adaptive-project-framing/.openprd.yaml +2 -0
- package/.openprd/changes/adaptive-project-framing/design.md +47 -0
- package/.openprd/changes/adaptive-project-framing/proposal.md +35 -0
- package/.openprd/changes/adaptive-project-framing/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/adaptive-project-framing/task-events.jsonl +20 -0
- package/.openprd/changes/adaptive-project-framing/tasks.md +340 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/.openprd.yaml +2 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/design.md +34 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/proposal.md +23 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/task-events.jsonl +14 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/tasks.md +238 -0
- package/.openprd/changes/loop-isolated-worktree-commit/.openprd.yaml +2 -0
- package/.openprd/changes/loop-isolated-worktree-commit/design.md +53 -0
- package/.openprd/changes/loop-isolated-worktree-commit/proposal.md +38 -0
- package/.openprd/changes/loop-isolated-worktree-commit/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/loop-isolated-worktree-commit/task-events.jsonl +22 -0
- package/.openprd/changes/loop-isolated-worktree-commit/tasks.md +357 -0
- package/.openprd/changes/openprd/.openprd.yaml +2 -0
- package/.openprd/changes/openprd/design.md +46 -0
- package/.openprd/changes/openprd/proposal.md +36 -0
- package/.openprd/changes/openprd/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd/task-events.jsonl +23 -0
- package/.openprd/changes/openprd/tasks.md +242 -0
- package/.openprd/changes/pm-human-in-the-loop/.openprd.yaml +2 -0
- package/.openprd/changes/pm-human-in-the-loop/design.md +38 -0
- package/.openprd/changes/pm-human-in-the-loop/proposal.md +29 -0
- package/.openprd/changes/pm-human-in-the-loop/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/pm-human-in-the-loop/task-events.jsonl +18 -0
- package/.openprd/changes/pm-human-in-the-loop/tasks.md +306 -0
- package/.openprd/changes/test-strategy-router/.openprd.yaml +2 -0
- package/.openprd/changes/test-strategy-router/design.md +65 -0
- package/.openprd/changes/test-strategy-router/proposal.md +45 -0
- package/.openprd/changes/test-strategy-router/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/test-strategy-router/task-events.jsonl +1 -0
- package/.openprd/changes/test-strategy-router/tasks.md +132 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/.openprd.yaml +2 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/design.md +28 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/proposal.md +23 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/task-events.jsonl +11 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/tasks.md +66 -0
- package/.openprd/changes/vibe-coding-dev-check/.openprd.yaml +2 -0
- package/.openprd/changes/vibe-coding-dev-check/design.md +37 -0
- package/.openprd/changes/vibe-coding-dev-check/proposal.md +28 -0
- package/.openprd/changes/vibe-coding-dev-check/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/vibe-coding-dev-check/task-events.jsonl +18 -0
- package/.openprd/changes/vibe-coding-dev-check/tasks.md +306 -0
- package/.openprd/design/README.md +11 -6
- package/.openprd/design/README_EN.md +4 -1
- package/.openprd/design/active/asset-spec.md +6 -0
- package/.openprd/design/active/direction-plan.md +5 -5
- package/.openprd/design/active/selected-direction.md +2 -0
- package/.openprd/design/anti-slop.md +26 -1
- package/.openprd/design/assets/surface-presets.md +6 -0
- package/.openprd/design/checklists/ui-quality-gate.md +7 -0
- package/.openprd/design/lenses/frontend-lenses.md +28 -0
- package/.openprd/design/recipes/content-experience.md +8 -1
- package/.openprd/design/recipes/operational-tool.md +7 -0
- package/.openprd/design/recipes/product-launch.md +8 -1
- package/.openprd/design/templates/README.md +6 -3
- package/.openprd/design/templates/README_EN.md +6 -3
- package/.openprd/design/templates/ops-dashboard.html +8 -1
- package/.openprd/design/templates/product-launch.html +2 -1
- package/.openprd/design/themes/theme-catalog.json +23 -5
- package/.openprd/engagements/active/prd.md +103 -100
- package/.openprd/schema/prd.schema.yaml +15 -0
- package/.openprd/standards/config.json +7 -1
- package/.openprd/templates/base/intake.md +13 -0
- package/.openprd/templates/base/prd.md +15 -0
- package/AGENTS.md +14 -4
- package/README.md +107 -14
- package/README_EN.md +131 -13
- package/package.json +1 -1
- package/scripts/dev-check-wrapup-copy.mjs +46 -21
- package/skills/openprd-benchmark-router/SKILL.md +2 -1
- package/skills/openprd-diagram-review/SKILL.md +27 -2
- package/skills/openprd-diagram-review/references/explanation-svg-patterns.md +131 -0
- package/skills/openprd-discovery-loop/SKILL.md +2 -2
- package/skills/openprd-frontend-design/SKILL.md +44 -20
- package/skills/openprd-frontend-design/references/design-asset-contract.md +6 -0
- package/skills/openprd-frontend-design/references/direction-engine.md +29 -4
- package/skills/openprd-harness/SKILL.md +22 -16
- package/skills/openprd-quality/SKILL.md +10 -8
- package/skills/openprd-requirement-intake/SKILL.md +5 -3
- package/skills/openprd-requirement-intake/references/prd-template-lenses.md +2 -2
- package/skills/openprd-requirement-intake/references/startup-validation-lens.md +98 -13
- package/skills/openprd-router/SKILL.md +2 -1
- package/skills/openprd-shared/SKILL.md +27 -17
- package/skills/openprd-standards/SKILL.md +4 -2
- package/src/agent-canonical-content.js +808 -0
- package/src/agent-integration.js +259 -778
- package/src/brainstorm-artifacts.js +156 -8
- package/src/brainstorm-presentation.js +14 -4
- package/src/brainstorm.js +165 -2
- package/src/canvas-app.html.js +2910 -0
- package/src/canvas-i18n.js +274 -0
- package/src/canvas-workspace.js +2271 -0
- package/src/cli/args.js +72 -4
- package/src/cli/basic-print.js +3 -0
- package/src/cli/canvas-print.js +106 -0
- package/src/cli/doctor-print.js +20 -0
- package/src/cli/print.js +2 -0
- package/src/cli/quality-commands.js +222 -0
- package/src/cli/quality-print.js +37 -5
- package/src/cli/run-print.js +5 -0
- package/src/cli/workflow-print.js +3 -0
- package/src/codex-app-server-relay.js +251 -0
- package/src/codex-hook-runner-template.mjs +963 -65
- package/src/design-starter-support.js +2 -2
- package/src/design-starter.js +39 -3
- package/src/dev-standards.js +125 -10
- package/src/diagram-core.js +390 -171
- package/src/discovery.js +2 -8
- package/src/docs-compact.js +125 -0
- package/src/execution-strategy.js +1 -1
- package/src/fleet.js +9 -3
- package/src/guide.js +227 -0
- package/src/html-artifacts.js +825 -51
- package/src/knowledge.js +101 -20
- package/src/language-policy.js +63 -3
- package/src/learning-review.js +1 -1
- package/src/loop.js +1 -1
- package/src/openprd.js +68 -131
- package/src/openspec/change-validate.js +2 -2
- package/src/openspec/constants.js +6 -7
- package/src/openspec/execute.js +2 -2
- package/src/openspec/generate.js +318 -102
- package/src/openspec/migration.js +168 -0
- package/src/openspec/paths.js +1 -22
- package/src/prd-core.js +102 -3
- package/src/quality-html-artifact.js +30 -5
- package/src/quality-visual-review.js +257 -14
- package/src/quality.js +66 -18
- package/src/review-presentation.js +60 -0
- package/src/run-harness.js +41 -3
- package/src/session-evidence.js +363 -0
- package/src/standards.js +64 -0
- package/src/test-strategy.js +7 -4
- package/src/visual-compare-centering.js +622 -0
- package/src/visual-compare-core.js +1123 -0
- package/src/visual-compare.js +620 -698
- package/src/workspace-core.js +132 -4
- package/src/workspace-workflow.js +172 -10
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// Centering-board renderer: analyzes a single asset for canvas-center offset,
|
|
2
|
+
// subject bounding-box offset, and visual weight drift, then renders the board.
|
|
3
|
+
// Split from visual-compare.js.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
import { VISUAL_REVIEW_SCHEMA } from './quality-visual-review.js';
|
|
9
|
+
import { timestamp } from './time.js';
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_PANEL_WIDTH,
|
|
12
|
+
charCount,
|
|
13
|
+
compactLabel,
|
|
14
|
+
defaultOutputPath,
|
|
15
|
+
encodePipeline,
|
|
16
|
+
escapeXml,
|
|
17
|
+
labelSvg,
|
|
18
|
+
localeFromOptions,
|
|
19
|
+
metadataPathForOutput,
|
|
20
|
+
normalizeFormat,
|
|
21
|
+
normalizeImageSpec,
|
|
22
|
+
parsePositiveInteger,
|
|
23
|
+
parseQuality,
|
|
24
|
+
renderParallelCard,
|
|
25
|
+
resolveCropBox,
|
|
26
|
+
titleBlockSvg,
|
|
27
|
+
toWorkspacePath,
|
|
28
|
+
visualCopyForLocale,
|
|
29
|
+
} from './visual-compare-core.js';
|
|
30
|
+
|
|
31
|
+
function round1(value) {
|
|
32
|
+
const rounded = Math.round(Number(value) * 10) / 10;
|
|
33
|
+
return Object.is(rounded, -0) ? 0 : rounded;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatSignedPx(value) {
|
|
37
|
+
const rounded = round1(value);
|
|
38
|
+
return `${rounded >= 0 ? '+' : ''}${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}px`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function luminance(r, g, b) {
|
|
42
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function colorDistance(r, g, b, color) {
|
|
46
|
+
return Math.sqrt(
|
|
47
|
+
((r - color.r) ** 2)
|
|
48
|
+
+ ((g - color.g) ** 2)
|
|
49
|
+
+ ((b - color.b) ** 2),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseChannelRange(range, key) {
|
|
54
|
+
const raw = range?.[key];
|
|
55
|
+
if (raw === null || raw === undefined || raw === '') {
|
|
56
|
+
return [key === 'a' ? 1 : 0, 255];
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(raw)) {
|
|
59
|
+
return [
|
|
60
|
+
Math.max(0, Math.min(255, Number(raw[0] ?? 0))),
|
|
61
|
+
Math.max(0, Math.min(255, Number(raw[1] ?? 255))),
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
if (typeof raw === 'object') {
|
|
65
|
+
return [
|
|
66
|
+
Math.max(0, Math.min(255, Number(raw.min ?? raw.from ?? 0))),
|
|
67
|
+
Math.max(0, Math.min(255, Number(raw.max ?? raw.to ?? 255))),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
const value = Math.max(0, Math.min(255, Number(raw)));
|
|
71
|
+
return [value, value];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pixelInRange(r, g, b, a, range) {
|
|
75
|
+
const [rMin, rMax] = parseChannelRange(range, 'r');
|
|
76
|
+
const [gMin, gMax] = parseChannelRange(range, 'g');
|
|
77
|
+
const [bMin, bMax] = parseChannelRange(range, 'b');
|
|
78
|
+
const [aMin, aMax] = parseChannelRange(range, 'a');
|
|
79
|
+
return r >= rMin && r <= rMax
|
|
80
|
+
&& g >= gMin && g <= gMax
|
|
81
|
+
&& b >= bMin && b <= bMax
|
|
82
|
+
&& a >= aMin && a <= aMax;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseColorSpec(value, fallbackTolerance) {
|
|
86
|
+
if (!value) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (typeof value === 'string') {
|
|
90
|
+
const hex = value.trim().replace(/^#/u, '');
|
|
91
|
+
if (/^[0-9a-f]{6}$/iu.test(hex)) {
|
|
92
|
+
return {
|
|
93
|
+
r: Number.parseInt(hex.slice(0, 2), 16),
|
|
94
|
+
g: Number.parseInt(hex.slice(2, 4), 16),
|
|
95
|
+
b: Number.parseInt(hex.slice(4, 6), 16),
|
|
96
|
+
tolerance: fallbackTolerance,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (typeof value !== 'object') {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const fromHex = parseColorSpec(value.hex ?? value.color, fallbackTolerance);
|
|
105
|
+
if (fromHex) {
|
|
106
|
+
return {
|
|
107
|
+
...fromHex,
|
|
108
|
+
tolerance: Number(value.tolerance ?? value.threshold ?? fallbackTolerance),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const r = Number(value.r);
|
|
112
|
+
const g = Number(value.g);
|
|
113
|
+
const b = Number(value.b);
|
|
114
|
+
if (![r, g, b].every(Number.isFinite)) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
r: Math.max(0, Math.min(255, r)),
|
|
119
|
+
g: Math.max(0, Math.min(255, g)),
|
|
120
|
+
b: Math.max(0, Math.min(255, b)),
|
|
121
|
+
tolerance: Number(value.tolerance ?? value.threshold ?? fallbackTolerance),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeColorSpecs(subject, fallbackTolerance) {
|
|
126
|
+
const raw = subject.colors ?? subject.color ?? subject.includeColors;
|
|
127
|
+
const entries = Array.isArray(raw) ? raw : (raw ? [raw] : []);
|
|
128
|
+
return entries
|
|
129
|
+
.map((entry) => parseColorSpec(entry, fallbackTolerance))
|
|
130
|
+
.filter(Boolean);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeCenteringSubject(payload) {
|
|
134
|
+
const subject = (payload.subject && typeof payload.subject === 'object')
|
|
135
|
+
? payload.subject
|
|
136
|
+
: ((payload.mask && typeof payload.mask === 'object') ? payload.mask : {});
|
|
137
|
+
const mode = String(subject.mode ?? payload.maskMode ?? 'auto').trim().toLowerCase();
|
|
138
|
+
const colorDistanceThreshold = Number(subject.colorDistanceThreshold ?? subject.colorThreshold ?? payload.colorDistanceThreshold ?? 28);
|
|
139
|
+
const lumaThreshold = Number(subject.lumaThreshold ?? payload.lumaThreshold ?? 22);
|
|
140
|
+
return {
|
|
141
|
+
mode: ['alpha', 'range', 'ranges', 'color', 'colors'].includes(mode)
|
|
142
|
+
? (mode === 'ranges' ? 'range' : (mode === 'colors' ? 'color' : mode))
|
|
143
|
+
: 'auto',
|
|
144
|
+
alphaThreshold: Number(subject.alphaThreshold ?? payload.alphaThreshold ?? 8),
|
|
145
|
+
colorDistanceThreshold,
|
|
146
|
+
lumaThreshold,
|
|
147
|
+
weight: String(subject.weight ?? payload.weight ?? 'contrast').trim().toLowerCase(),
|
|
148
|
+
ranges: Array.isArray(subject.ranges ?? subject.include)
|
|
149
|
+
? (subject.ranges ?? subject.include)
|
|
150
|
+
: (subject.range ? [subject.range] : []),
|
|
151
|
+
colors: normalizeColorSpecs(subject, colorDistanceThreshold),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function estimateCornerBackground(data, width, height, channels) {
|
|
156
|
+
const sample = Math.max(2, Math.min(12, Math.floor(Math.min(width, height) * 0.06)));
|
|
157
|
+
const points = [];
|
|
158
|
+
for (const yStart of [0, Math.max(0, height - sample)]) {
|
|
159
|
+
for (const xStart of [0, Math.max(0, width - sample)]) {
|
|
160
|
+
for (let y = yStart; y < Math.min(height, yStart + sample); y += 1) {
|
|
161
|
+
for (let x = xStart; x < Math.min(width, xStart + sample); x += 1) {
|
|
162
|
+
points.push((y * width + x) * channels);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const totals = { r: 0, g: 0, b: 0, a: 0 };
|
|
168
|
+
for (const index of points) {
|
|
169
|
+
totals.r += data[index];
|
|
170
|
+
totals.g += data[index + 1];
|
|
171
|
+
totals.b += data[index + 2];
|
|
172
|
+
totals.a += data[index + 3] ?? 255;
|
|
173
|
+
}
|
|
174
|
+
const count = Math.max(points.length, 1);
|
|
175
|
+
return {
|
|
176
|
+
r: totals.r / count,
|
|
177
|
+
g: totals.g / count,
|
|
178
|
+
b: totals.b / count,
|
|
179
|
+
a: totals.a / count,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function centerPoint(width, height) {
|
|
184
|
+
return {
|
|
185
|
+
x: width / 2,
|
|
186
|
+
y: height / 2,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function centeringPixelActive(r, g, b, a, subject, background) {
|
|
191
|
+
if (a <= subject.alphaThreshold) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
if (subject.mode === 'alpha') {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
if (subject.mode === 'range') {
|
|
198
|
+
return subject.ranges.some((range) => pixelInRange(r, g, b, a, range));
|
|
199
|
+
}
|
|
200
|
+
if (subject.mode === 'color') {
|
|
201
|
+
return subject.colors.some((color) => colorDistance(r, g, b, color) <= color.tolerance);
|
|
202
|
+
}
|
|
203
|
+
if (background.a <= subject.alphaThreshold) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
return colorDistance(r, g, b, background) >= subject.colorDistanceThreshold
|
|
207
|
+
|| Math.abs(luminance(r, g, b) - luminance(background.r, background.g, background.b)) >= subject.lumaThreshold;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function centeringPixelWeight(r, g, b, a, subject, background) {
|
|
211
|
+
if (subject.weight === 'presence') {
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
if (subject.weight === 'alpha') {
|
|
215
|
+
return Math.max(a / 255, 0.01);
|
|
216
|
+
}
|
|
217
|
+
if (subject.weight === 'luma') {
|
|
218
|
+
return Math.max(luminance(r, g, b) / 255, 0.01);
|
|
219
|
+
}
|
|
220
|
+
return Math.max(colorDistance(r, g, b, background), 1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function loadCenteringRaw(sourcePath, box) {
|
|
224
|
+
const { data, info } = await sharp(sourcePath)
|
|
225
|
+
.rotate()
|
|
226
|
+
.extract({
|
|
227
|
+
left: box.x,
|
|
228
|
+
top: box.y,
|
|
229
|
+
width: box.width,
|
|
230
|
+
height: box.height,
|
|
231
|
+
})
|
|
232
|
+
.ensureAlpha()
|
|
233
|
+
.raw()
|
|
234
|
+
.toBuffer({ resolveWithObject: true });
|
|
235
|
+
return {
|
|
236
|
+
data,
|
|
237
|
+
width: info.width,
|
|
238
|
+
height: info.height,
|
|
239
|
+
channels: info.channels,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function extractCenteringTargetPanel(inputPath, box, outputWidth, sourceOriginal) {
|
|
244
|
+
const source = path.resolve(inputPath);
|
|
245
|
+
const { data, info } = await sharp(source)
|
|
246
|
+
.rotate()
|
|
247
|
+
.extract({
|
|
248
|
+
left: box.x,
|
|
249
|
+
top: box.y,
|
|
250
|
+
width: box.width,
|
|
251
|
+
height: box.height,
|
|
252
|
+
})
|
|
253
|
+
.resize({
|
|
254
|
+
width: outputWidth,
|
|
255
|
+
fit: 'inside',
|
|
256
|
+
withoutEnlargement: true,
|
|
257
|
+
})
|
|
258
|
+
.png()
|
|
259
|
+
.toBuffer({ resolveWithObject: true });
|
|
260
|
+
return {
|
|
261
|
+
input: data,
|
|
262
|
+
width: info.width,
|
|
263
|
+
height: info.height,
|
|
264
|
+
source,
|
|
265
|
+
absolute: box,
|
|
266
|
+
original: {
|
|
267
|
+
width: box.width,
|
|
268
|
+
height: box.height,
|
|
269
|
+
sourceWidth: sourceOriginal.width,
|
|
270
|
+
sourceHeight: sourceOriginal.height,
|
|
271
|
+
format: sourceOriginal.format ?? null,
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function analyzeCenteringTarget(sourcePath, box, payload, locale = OPENPRD_FALLBACK_LOCALE) {
|
|
277
|
+
const copy = visualCopyForLocale(locale);
|
|
278
|
+
const raw = await loadCenteringRaw(sourcePath, box);
|
|
279
|
+
const subject = normalizeCenteringSubject(payload);
|
|
280
|
+
if (subject.mode === 'range' && subject.ranges.length === 0) {
|
|
281
|
+
throw new Error('Centering board subject.mode=range requires subject.ranges or subject.include.');
|
|
282
|
+
}
|
|
283
|
+
if (subject.mode === 'color' && subject.colors.length === 0) {
|
|
284
|
+
throw new Error('Centering board subject.mode=color requires subject.colors or subject.color.');
|
|
285
|
+
}
|
|
286
|
+
const background = estimateCornerBackground(raw.data, raw.width, raw.height, raw.channels);
|
|
287
|
+
let minX = raw.width;
|
|
288
|
+
let minY = raw.height;
|
|
289
|
+
let maxX = -1;
|
|
290
|
+
let maxY = -1;
|
|
291
|
+
let activePixelCount = 0;
|
|
292
|
+
let weightSum = 0;
|
|
293
|
+
let weightedX = 0;
|
|
294
|
+
let weightedY = 0;
|
|
295
|
+
|
|
296
|
+
for (let y = 0; y < raw.height; y += 1) {
|
|
297
|
+
for (let x = 0; x < raw.width; x += 1) {
|
|
298
|
+
const index = (y * raw.width + x) * raw.channels;
|
|
299
|
+
const r = raw.data[index];
|
|
300
|
+
const g = raw.data[index + 1];
|
|
301
|
+
const b = raw.data[index + 2];
|
|
302
|
+
const a = raw.data[index + 3] ?? 255;
|
|
303
|
+
if (!centeringPixelActive(r, g, b, a, subject, background)) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
activePixelCount += 1;
|
|
307
|
+
minX = Math.min(minX, x);
|
|
308
|
+
minY = Math.min(minY, y);
|
|
309
|
+
maxX = Math.max(maxX, x);
|
|
310
|
+
maxY = Math.max(maxY, y);
|
|
311
|
+
const weight = centeringPixelWeight(r, g, b, a, subject, background);
|
|
312
|
+
weightSum += weight;
|
|
313
|
+
weightedX += (x + 0.5) * weight;
|
|
314
|
+
weightedY += (y + 0.5) * weight;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (activePixelCount === 0) {
|
|
319
|
+
throw new Error('Centering board could not isolate active subject pixels. Use subject.mode=range or subject.mode=color with explicit ranges/colors.');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const bounds = {
|
|
323
|
+
x: minX,
|
|
324
|
+
y: minY,
|
|
325
|
+
width: maxX - minX + 1,
|
|
326
|
+
height: maxY - minY + 1,
|
|
327
|
+
};
|
|
328
|
+
const canvasCenter = centerPoint(raw.width, raw.height);
|
|
329
|
+
const bboxCenter = {
|
|
330
|
+
x: bounds.x + bounds.width / 2,
|
|
331
|
+
y: bounds.y + bounds.height / 2,
|
|
332
|
+
};
|
|
333
|
+
const visualCentroid = {
|
|
334
|
+
x: weightedX / Math.max(weightSum, 1),
|
|
335
|
+
y: weightedY / Math.max(weightSum, 1),
|
|
336
|
+
};
|
|
337
|
+
const bboxCenterOffset = {
|
|
338
|
+
x: round1(bboxCenter.x - canvasCenter.x),
|
|
339
|
+
y: round1(bboxCenter.y - canvasCenter.y),
|
|
340
|
+
};
|
|
341
|
+
const visualCentroidOffset = {
|
|
342
|
+
x: round1(visualCentroid.x - canvasCenter.x),
|
|
343
|
+
y: round1(visualCentroid.y - canvasCenter.y),
|
|
344
|
+
};
|
|
345
|
+
const thresholdPx = Number(payload.thresholdPx ?? payload.threshold ?? 8);
|
|
346
|
+
const maxAbsOffset = Math.max(
|
|
347
|
+
Math.abs(bboxCenterOffset.x),
|
|
348
|
+
Math.abs(bboxCenterOffset.y),
|
|
349
|
+
Math.abs(visualCentroidOffset.x),
|
|
350
|
+
Math.abs(visualCentroidOffset.y),
|
|
351
|
+
);
|
|
352
|
+
const passed = maxAbsOffset <= thresholdPx;
|
|
353
|
+
return {
|
|
354
|
+
thresholdPx,
|
|
355
|
+
verdict: passed ? copy.passVerdict : copy.reworkVerdict,
|
|
356
|
+
passed,
|
|
357
|
+
maxAbsOffset: round1(maxAbsOffset),
|
|
358
|
+
canvas: {
|
|
359
|
+
width: raw.width,
|
|
360
|
+
height: raw.height,
|
|
361
|
+
},
|
|
362
|
+
canvasCenter: {
|
|
363
|
+
x: round1(canvasCenter.x),
|
|
364
|
+
y: round1(canvasCenter.y),
|
|
365
|
+
},
|
|
366
|
+
activeBounds: bounds,
|
|
367
|
+
bboxCenter: {
|
|
368
|
+
x: round1(bboxCenter.x),
|
|
369
|
+
y: round1(bboxCenter.y),
|
|
370
|
+
},
|
|
371
|
+
bboxCenterOffset,
|
|
372
|
+
visualCentroid: {
|
|
373
|
+
x: round1(visualCentroid.x),
|
|
374
|
+
y: round1(visualCentroid.y),
|
|
375
|
+
},
|
|
376
|
+
visualCentroidOffset,
|
|
377
|
+
activePixelCount,
|
|
378
|
+
activePixelRatio: round1((activePixelCount / Math.max(raw.width * raw.height, 1)) * 100),
|
|
379
|
+
subject: {
|
|
380
|
+
mode: subject.mode,
|
|
381
|
+
weight: subject.weight,
|
|
382
|
+
alphaThreshold: subject.alphaThreshold,
|
|
383
|
+
colorDistanceThreshold: subject.colorDistanceThreshold,
|
|
384
|
+
lumaThreshold: subject.lumaThreshold,
|
|
385
|
+
rangeCount: subject.ranges.length,
|
|
386
|
+
colorCount: subject.colors.length,
|
|
387
|
+
},
|
|
388
|
+
background: {
|
|
389
|
+
r: round1(background.r),
|
|
390
|
+
g: round1(background.g),
|
|
391
|
+
b: round1(background.b),
|
|
392
|
+
a: round1(background.a),
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function centeringOverlaySvg(panel, analysis, locale = OPENPRD_FALLBACK_LOCALE) {
|
|
398
|
+
const copy = visualCopyForLocale(locale);
|
|
399
|
+
const scaleX = panel.width / analysis.canvas.width;
|
|
400
|
+
const scaleY = panel.height / analysis.canvas.height;
|
|
401
|
+
const centerX = round1(analysis.canvasCenter.x * scaleX);
|
|
402
|
+
const centerY = round1(analysis.canvasCenter.y * scaleY);
|
|
403
|
+
const bounds = {
|
|
404
|
+
x: round1(analysis.activeBounds.x * scaleX),
|
|
405
|
+
y: round1(analysis.activeBounds.y * scaleY),
|
|
406
|
+
width: Math.max(2, round1(analysis.activeBounds.width * scaleX)),
|
|
407
|
+
height: Math.max(2, round1(analysis.activeBounds.height * scaleY)),
|
|
408
|
+
};
|
|
409
|
+
const bboxCenterX = round1(analysis.bboxCenter.x * scaleX);
|
|
410
|
+
const bboxCenterY = round1(analysis.bboxCenter.y * scaleY);
|
|
411
|
+
const centroidX = round1(analysis.visualCentroid.x * scaleX);
|
|
412
|
+
const centroidY = round1(analysis.visualCentroid.y * scaleY);
|
|
413
|
+
const gridLines = [];
|
|
414
|
+
for (const ratio of [0.25, 0.5, 0.75]) {
|
|
415
|
+
const x = round1(panel.width * ratio);
|
|
416
|
+
const y = round1(panel.height * ratio);
|
|
417
|
+
gridLines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${panel.height}" stroke="#94a3b8" stroke-opacity="0.22" stroke-width="1"/>`);
|
|
418
|
+
gridLines.push(`<line x1="0" y1="${y}" x2="${panel.width}" y2="${y}" stroke="#94a3b8" stroke-opacity="0.22" stroke-width="1"/>`);
|
|
419
|
+
}
|
|
420
|
+
const bboxText = `bbox ${formatSignedPx(analysis.bboxCenterOffset.x)}, ${formatSignedPx(analysis.bboxCenterOffset.y)}`;
|
|
421
|
+
const centroidText = `${copy.centeringCentroidLabel} ${formatSignedPx(analysis.visualCentroidOffset.x)}, ${formatSignedPx(analysis.visualCentroidOffset.y)}`;
|
|
422
|
+
const badgeText = panel.width < 360 ? bboxText : `${bboxText} / ${centroidText}`;
|
|
423
|
+
const badgeWidth = Math.max(24, Math.min(Math.max(24, panel.width - 24), Math.max(320, charCount(badgeText) * 13 + 32)));
|
|
424
|
+
return Buffer.from(`
|
|
425
|
+
<svg width="${panel.width}" height="${panel.height}" viewBox="0 0 ${panel.width} ${panel.height}" xmlns="http://www.w3.org/2000/svg">
|
|
426
|
+
<rect x="0" y="0" width="${panel.width}" height="${panel.height}" fill="#020617" fill-opacity="0.06"/>
|
|
427
|
+
${gridLines.join('')}
|
|
428
|
+
<line x1="${centerX}" y1="0" x2="${centerX}" y2="${panel.height}" stroke="#ef4444" stroke-width="3" stroke-dasharray="12 8"/>
|
|
429
|
+
<line x1="0" y1="${centerY}" x2="${panel.width}" y2="${centerY}" stroke="#ef4444" stroke-width="3" stroke-dasharray="12 8"/>
|
|
430
|
+
<rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}" rx="8" fill="none" stroke="#22c55e" stroke-width="4"/>
|
|
431
|
+
<line x1="${bboxCenterX - 16}" y1="${bboxCenterY}" x2="${bboxCenterX + 16}" y2="${bboxCenterY}" stroke="#22c55e" stroke-width="4"/>
|
|
432
|
+
<line x1="${bboxCenterX}" y1="${bboxCenterY - 16}" x2="${bboxCenterX}" y2="${bboxCenterY + 16}" stroke="#22c55e" stroke-width="4"/>
|
|
433
|
+
<circle cx="${centroidX}" cy="${centroidY}" r="9" fill="#facc15" stroke="#111827" stroke-width="3"/>
|
|
434
|
+
<rect x="12" y="12" width="${badgeWidth}" height="34" rx="17" fill="#0f172a" fill-opacity="0.88"/>
|
|
435
|
+
<text x="28" y="35" fill="#f8fafc" font-size="16" font-weight="800" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${escapeXml(badgeText)}</text>
|
|
436
|
+
<rect x="0.75" y="0.75" width="${panel.width - 1.5}" height="${panel.height - 1.5}" rx="12" fill="none" stroke="#475569" stroke-opacity="0.6" stroke-width="1.5"/>
|
|
437
|
+
</svg>`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function createCenteringOverlayPanel(targetPanel, analysis, locale = OPENPRD_FALLBACK_LOCALE) {
|
|
441
|
+
const overlay = centeringOverlaySvg(targetPanel, analysis, locale);
|
|
442
|
+
const input = await sharp(targetPanel.input)
|
|
443
|
+
.composite([{ input: overlay, left: 0, top: 0 }])
|
|
444
|
+
.png()
|
|
445
|
+
.toBuffer();
|
|
446
|
+
return {
|
|
447
|
+
...targetPanel,
|
|
448
|
+
input,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function normalizeCenteringTargetBox(rawBox, original) {
|
|
453
|
+
if (!rawBox) {
|
|
454
|
+
const width = Math.max(1, Math.round(original.width));
|
|
455
|
+
const height = Math.max(1, Math.round(original.height));
|
|
456
|
+
return {
|
|
457
|
+
unit: 'px',
|
|
458
|
+
requested: { x: 0, y: 0, width, height },
|
|
459
|
+
absolute: { x: 0, y: 0, width, height },
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
return resolveCropBox(rawBox, original);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export async function renderCenteringBoard(projectRoot, board, options = {}) {
|
|
466
|
+
const locale = localeFromOptions(options);
|
|
467
|
+
const copy = visualCopyForLocale(locale);
|
|
468
|
+
const format = normalizeFormat(options.format, options.out);
|
|
469
|
+
const outputPath = options.out
|
|
470
|
+
? path.resolve(projectRoot, options.out)
|
|
471
|
+
: defaultOutputPath(projectRoot, format, 'centering-board');
|
|
472
|
+
const quality = parseQuality(options.quality);
|
|
473
|
+
const maxPanelWidth = parsePositiveInteger(options.maxPanelWidth, DEFAULT_PANEL_WIDTH, '--max-panel-width');
|
|
474
|
+
const rawImage = board.payload.image ?? board.payload.screenshot ?? board.payload.actual ?? board.payload.path;
|
|
475
|
+
if (!rawImage) {
|
|
476
|
+
throw new Error('Centering board requires image, screenshot, actual, or path.');
|
|
477
|
+
}
|
|
478
|
+
const imageSpec = normalizeImageSpec(
|
|
479
|
+
projectRoot,
|
|
480
|
+
rawImage,
|
|
481
|
+
board.payload.imageLabel ?? board.payload.screenshotLabel ?? copy.centeringImageLabel,
|
|
482
|
+
);
|
|
483
|
+
const sourceMetadata = await sharp(imageSpec.path).metadata();
|
|
484
|
+
if (!sourceMetadata.width || !sourceMetadata.height) {
|
|
485
|
+
throw new Error(`Cannot read image dimensions: ${imageSpec.path}`);
|
|
486
|
+
}
|
|
487
|
+
const sourceOriginal = {
|
|
488
|
+
width: sourceMetadata.width,
|
|
489
|
+
height: sourceMetadata.height,
|
|
490
|
+
format: sourceMetadata.format ?? null,
|
|
491
|
+
};
|
|
492
|
+
const targetBox = normalizeCenteringTargetBox(
|
|
493
|
+
board.payload.targetBox ?? board.payload.box ?? board.payload.region,
|
|
494
|
+
sourceOriginal,
|
|
495
|
+
);
|
|
496
|
+
const targetPanel = await extractCenteringTargetPanel(
|
|
497
|
+
imageSpec.path,
|
|
498
|
+
targetBox.absolute,
|
|
499
|
+
maxPanelWidth,
|
|
500
|
+
sourceOriginal,
|
|
501
|
+
);
|
|
502
|
+
const analysis = await analyzeCenteringTarget(imageSpec.path, targetBox.absolute, board.payload, locale);
|
|
503
|
+
const overlayPanel = await createCenteringOverlayPanel(targetPanel, analysis, locale);
|
|
504
|
+
const panelWidth = Math.min(maxPanelWidth, Math.max(targetPanel.width, overlayPanel.width, 180));
|
|
505
|
+
const overviewHeight = Math.max(targetPanel.height, overlayPanel.height);
|
|
506
|
+
const margin = 24;
|
|
507
|
+
const gap = 24;
|
|
508
|
+
const contentWidth = Math.max(560, margin * 2 + panelWidth * 2 + gap);
|
|
509
|
+
const summary = String(board.payload.summary ?? copy.centeringSummary);
|
|
510
|
+
const titleBlock = titleBlockSvg(
|
|
511
|
+
contentWidth - margin * 2,
|
|
512
|
+
String(board.payload.title ?? copy.centeringTitle),
|
|
513
|
+
summary,
|
|
514
|
+
copy.centeringEyebrow,
|
|
515
|
+
);
|
|
516
|
+
const targetX = margin + Math.round((panelWidth - targetPanel.width) / 2);
|
|
517
|
+
const overlayX = margin + panelWidth + gap + Math.round((panelWidth - overlayPanel.width) / 2);
|
|
518
|
+
const overviewTop = margin + titleBlock.height + 18;
|
|
519
|
+
const targetY = overviewTop + Math.round((overviewHeight - targetPanel.height) / 2);
|
|
520
|
+
const overlayY = overviewTop + Math.round((overviewHeight - overlayPanel.height) / 2);
|
|
521
|
+
const metricsCard = await renderParallelCard(projectRoot, {
|
|
522
|
+
label: copy.centeringMeasureLabel,
|
|
523
|
+
subtitle: `${copy.centeringThresholdLabel} ${analysis.thresholdPx}px / ${analysis.verdict}`,
|
|
524
|
+
verdict: analysis.verdict,
|
|
525
|
+
metrics: [
|
|
526
|
+
{ label: copy.centeringCanvasSizeLabel, value: `${analysis.canvas.width}x${analysis.canvas.height}px` },
|
|
527
|
+
{ label: copy.centeringBoundsLabel, value: `x=${analysis.activeBounds.x}, y=${analysis.activeBounds.y}, ${analysis.activeBounds.width}x${analysis.activeBounds.height}px` },
|
|
528
|
+
{ label: copy.centeringBboxOffsetLabel, value: `${formatSignedPx(analysis.bboxCenterOffset.x)}, ${formatSignedPx(analysis.bboxCenterOffset.y)}` },
|
|
529
|
+
{ label: copy.centeringCentroidOffsetLabel, value: `${formatSignedPx(analysis.visualCentroidOffset.x)}, ${formatSignedPx(analysis.visualCentroidOffset.y)}` },
|
|
530
|
+
{ label: copy.centeringMaxOffsetLabel, value: `${analysis.maxAbsOffset}px` },
|
|
531
|
+
{ label: copy.centeringPixelRatioLabel, value: `${analysis.activePixelRatio}%` },
|
|
532
|
+
{ label: 'mask', value: `${analysis.subject.mode} / weight=${analysis.subject.weight}` },
|
|
533
|
+
],
|
|
534
|
+
notes: copy.centeringNotes,
|
|
535
|
+
}, 0, {
|
|
536
|
+
cardWidth: Math.min(contentWidth - margin * 2, Math.max(560, panelWidth * 2 + gap)),
|
|
537
|
+
eyebrow: copy.centeringMeasureEyebrow,
|
|
538
|
+
locale,
|
|
539
|
+
});
|
|
540
|
+
const metricsTop = overviewTop + overviewHeight + 28;
|
|
541
|
+
const composites = [
|
|
542
|
+
{ input: titleBlock.input, left: margin, top: margin },
|
|
543
|
+
{ input: targetPanel.input, left: targetX, top: targetY },
|
|
544
|
+
{ input: overlayPanel.input, left: overlayX, top: overlayY },
|
|
545
|
+
{
|
|
546
|
+
input: labelSvg(compactLabel(imageSpec.label), {
|
|
547
|
+
fontSize: 14,
|
|
548
|
+
height: 32,
|
|
549
|
+
paddingX: 12,
|
|
550
|
+
radius: 10,
|
|
551
|
+
minWidth: 80,
|
|
552
|
+
maxWidth: Math.max(32, targetPanel.width - 32),
|
|
553
|
+
}),
|
|
554
|
+
left: targetX + 16,
|
|
555
|
+
top: targetY + 16,
|
|
556
|
+
},
|
|
557
|
+
{ input: metricsCard.image, left: margin, top: metricsTop },
|
|
558
|
+
];
|
|
559
|
+
const canvasHeight = metricsTop + metricsCard.height + margin;
|
|
560
|
+
const canvas = sharp({
|
|
561
|
+
create: {
|
|
562
|
+
width: contentWidth,
|
|
563
|
+
height: canvasHeight,
|
|
564
|
+
channels: 3,
|
|
565
|
+
background: '#111827',
|
|
566
|
+
},
|
|
567
|
+
}).composite(composites);
|
|
568
|
+
|
|
569
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
570
|
+
await encodePipeline(canvas, format, quality).toFile(outputPath);
|
|
571
|
+
const metadataPath = metadataPathForOutput(outputPath);
|
|
572
|
+
const reviewArtifact = {
|
|
573
|
+
version: 1,
|
|
574
|
+
schema: VISUAL_REVIEW_SCHEMA,
|
|
575
|
+
generatedAt: timestamp(),
|
|
576
|
+
mode: 'centering-board',
|
|
577
|
+
locale,
|
|
578
|
+
outputPath: toWorkspacePath(projectRoot, outputPath),
|
|
579
|
+
format,
|
|
580
|
+
boardSource: toWorkspacePath(projectRoot, board.sourcePath),
|
|
581
|
+
title: String(board.payload.title ?? copy.centeringTitle),
|
|
582
|
+
labels: {
|
|
583
|
+
image: imageSpec.label,
|
|
584
|
+
overlay: copy.centeringOverlayLabel,
|
|
585
|
+
},
|
|
586
|
+
target: {
|
|
587
|
+
path: toWorkspacePath(projectRoot, imageSpec.path),
|
|
588
|
+
original: sourceOriginal,
|
|
589
|
+
box: {
|
|
590
|
+
unit: targetBox.unit,
|
|
591
|
+
requested: targetBox.requested,
|
|
592
|
+
absolute: targetBox.absolute,
|
|
593
|
+
},
|
|
594
|
+
rendered: {
|
|
595
|
+
width: targetPanel.width,
|
|
596
|
+
height: targetPanel.height,
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
centering: analysis,
|
|
600
|
+
canvas: {
|
|
601
|
+
width: contentWidth,
|
|
602
|
+
height: canvasHeight,
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
await fs.writeFile(metadataPath, `${JSON.stringify(reviewArtifact, null, 2)}\n`, 'utf8');
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
ok: true,
|
|
609
|
+
action: 'visual-compare',
|
|
610
|
+
mode: 'centering-board',
|
|
611
|
+
projectRoot,
|
|
612
|
+
outputPath,
|
|
613
|
+
metadataPath,
|
|
614
|
+
format,
|
|
615
|
+
quality: format === 'png' ? null : quality,
|
|
616
|
+
canvas: reviewArtifact.canvas,
|
|
617
|
+
target: reviewArtifact.target,
|
|
618
|
+
centering: analysis,
|
|
619
|
+
reviewArtifact,
|
|
620
|
+
nextActions: copy.centeringNextActions,
|
|
621
|
+
};
|
|
622
|
+
}
|