@openprd/cli 0.1.0 → 0.1.8
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/README.md +43 -69
- package/.openprd/README_EN.md +84 -0
- package/.openprd/benchmarks/index.md +7 -0
- package/.openprd/benchmarks/sources.yaml +25 -3
- package/.openprd/discovery/config.json +16 -2
- package/.openprd/engagements/active/flows.md +19 -14
- package/.openprd/engagements/active/handoff.md +11 -4
- package/.openprd/engagements/active/prd.md +99 -71
- package/.openprd/engagements/active/review.html +4 -4
- package/.openprd/engagements/active/roles.md +9 -8
- package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +4 -4
- package/.openprd/engagements/work-units/wu-20260602113956-a99b5b88.json +18 -0
- package/.openprd/engagements/work-units/wu-20260602122244-78656aaf.json +18 -0
- package/.openprd/engagements/work-units/wu-20260602122442-e96489e2.json +18 -0
- package/.openprd/engagements/work-units/wu-20260602132835-695429e8.json +18 -0
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/candidate.json +78 -0
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/diagnostic-report.json +129 -0
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/root-cause-candidates.json +41 -0
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/timeline.json +14 -0
- package/.openprd/knowledge/drafts/openprd-experience-diagnostic-candidate-turn-1780116203372-5f266a79e968c758/SKILL.md +49 -0
- package/.openprd/knowledge/index.json +44 -4
- package/.openprd/reviews/v0001.html +195 -129
- package/.openprd/reviews/v0002.html +1150 -0
- package/.openprd/reviews/v0003.html +1150 -0
- package/.openprd/reviews/v0004.html +1150 -0
- package/.openprd/reviews/v0005.html +1150 -0
- package/.openprd/standards/config.json +12 -9
- package/.openprd/state/changes.json +17 -2
- package/.openprd/state/current.json +399 -63
- package/.openprd/state/release-ledger.json +344 -0
- package/.openprd/state/version-index.json +52 -0
- package/.openprd/state/versions/v0002.json +264 -0
- package/.openprd/state/versions/v0002.md +183 -0
- package/.openprd/state/versions/v0003.json +269 -0
- package/.openprd/state/versions/v0003.md +188 -0
- package/.openprd/state/versions/v0004.json +274 -0
- package/.openprd/state/versions/v0004.md +193 -0
- package/.openprd/state/versions/v0005.json +299 -0
- package/.openprd/state/versions/v0005.md +189 -0
- package/.openprd/templates/agent/intake.md +5 -4
- package/.openprd/templates/b2b/intake.md +5 -4
- package/.openprd/templates/base/intake.md +10 -4
- package/.openprd/templates/company/README.md +9 -7
- package/.openprd/templates/company/README_EN.md +12 -0
- package/.openprd/templates/consumer/intake.md +5 -4
- package/.openprd/templates/industry/README.md +12 -10
- package/.openprd/templates/industry/README_EN.md +18 -0
- package/.openprd/templates/project/README.md +11 -9
- package/.openprd/templates/project/README_EN.md +16 -0
- package/.openprd/templates/session/README.md +11 -9
- package/.openprd/templates/session/README_EN.md +16 -0
- package/AGENTS.md +12 -8
- package/README.md +402 -441
- package/README_CN.md +4 -578
- package/README_EN.md +850 -0
- package/docs/assets/openprd-requirement-routing-en.png +0 -0
- package/docs/assets/openprd-requirement-routing-en.svg +102 -0
- package/docs/assets/openprd-requirement-routing-zh-refined.png +0 -0
- package/docs/assets/openprd-requirement-routing-zh.png +0 -0
- package/docs/assets/openprd-requirement-routing-zh.svg +102 -0
- package/package.json +6 -2
- package/scripts/dev-check-wrapup-copy.mjs +110 -0
- package/scripts/openprd-github-release-notes.mjs +99 -0
- package/scripts/quality-perf-check.mjs +203 -0
- package/skills/openprd-benchmark-router/SKILL.md +1 -0
- package/skills/openprd-benchmark-router/references/benchmark-sources.md +1 -0
- package/skills/openprd-benchmark-router/references/source-policy.md +2 -0
- package/skills/openprd-discovery-loop/SKILL.md +2 -2
- package/skills/openprd-harness/SKILL.md +46 -24
- package/skills/openprd-harness/references/workflow-gates.md +15 -0
- package/skills/openprd-quality/SKILL.md +10 -4
- package/skills/openprd-requirement-intake/SKILL.md +39 -23
- package/skills/openprd-requirement-intake/references/prd-template-lenses.md +6 -6
- package/skills/openprd-requirement-intake/references/routing-rubric.md +22 -8
- package/skills/openprd-router/SKILL.md +2 -2
- package/skills/openprd-shared/SKILL.md +51 -23
- package/skills/openprd-standards/SKILL.md +2 -1
- package/src/agent-integration.js +265 -65
- package/src/benchmark/constants.js +107 -0
- package/src/benchmark/operations.js +235 -0
- package/src/benchmark/registry.js +64 -0
- package/src/benchmark/render.js +115 -0
- package/src/benchmark/source.js +617 -0
- package/src/benchmark/storage.js +121 -0
- package/src/benchmark/verify.js +235 -0
- package/src/benchmark.js +50 -851
- package/src/change-summary.js +339 -0
- package/src/cli/args.js +67 -6
- package/src/cli/basic-print.js +365 -0
- package/src/cli/benchmark-print.js +91 -0
- package/src/cli/change-print.js +221 -0
- package/src/cli/doctor-print.js +268 -0
- package/src/cli/growth-print.js +176 -0
- package/src/cli/print.js +73 -1384
- package/src/cli/quality-print.js +284 -0
- package/src/cli/run-print.js +297 -0
- package/src/cli/shared-print.js +127 -0
- package/src/cli/workflow-print.js +195 -0
- package/src/codex-hook-runner-template.mjs +639 -117
- package/src/codex-runtime.js +324 -0
- package/src/dev-standards.js +178 -5
- package/src/diagram-core.js +5 -5
- package/src/discovery.js +2 -1
- package/src/execution-strategy.js +369 -0
- package/src/fleet.js +4 -0
- package/src/github-release.js +156 -0
- package/src/growth.js +311 -13
- package/src/html-artifact-utils.js +25 -0
- package/src/html-artifacts.js +157 -1596
- package/src/knowledge.js +1176 -75
- package/src/language-policy.js +2 -112
- package/src/learning-html-artifact.js +1031 -0
- package/src/learning-review.js +3 -2
- package/src/loop.js +280 -9
- package/src/openprd.js +341 -38
- package/src/openspec/change-validate.js +0 -9
- package/src/openspec/execute.js +79 -3
- package/src/openspec/generate.js +33 -20
- package/src/openspec/tasks.js +33 -2
- package/src/prd-core.js +10 -9
- package/src/product-type-copy.js +69 -0
- package/src/quality-html-artifact.js +108 -9
- package/src/quality-learning.js +30 -0
- package/src/quality-visual-review.js +237 -0
- package/src/quality.js +329 -43
- package/src/registry-hygiene.js +54 -0
- package/src/release-ledger.js +413 -0
- package/src/review-presentation.js +12 -6
- package/src/run-harness.js +722 -48
- package/src/self-update.js +1 -1
- package/src/session-binding.js +40 -3
- package/src/session-registry.js +159 -0
- package/src/standards.js +5 -3
- package/src/test-strategy.js +386 -0
- package/src/visual-compare.js +915 -34
- package/src/work-unit-migration.js +5 -1
- package/src/workspace-core.js +343 -19
- package/src/workspace-workflow.js +538 -134
package/src/visual-compare.js
CHANGED
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import sharp from 'sharp';
|
|
4
|
-
import {
|
|
4
|
+
import { VISUAL_REVIEW_SCHEMA } from './quality-visual-review.js';
|
|
5
|
+
import { compactTimestamp, timestamp } from './time.js';
|
|
5
6
|
|
|
6
7
|
const DEFAULT_PANEL_WIDTH = 1180;
|
|
7
8
|
const DEFAULT_QUALITY = 85;
|
|
8
9
|
const DEFAULT_REFERENCE_LABEL = '效果图';
|
|
9
10
|
const DEFAULT_ACTUAL_LABEL = '实现截图';
|
|
11
|
+
const DEFAULT_BEFORE_LABEL = '修改前';
|
|
12
|
+
const DEFAULT_AFTER_LABEL = '修改后';
|
|
13
|
+
const DEFAULT_FOCUS_TITLE = '局部焦点证据板';
|
|
14
|
+
const DEFAULT_PARALLEL_TITLE = '并行实验证据板';
|
|
15
|
+
const DEFAULT_PARALLEL_CARD_WIDTH = 380;
|
|
16
|
+
const DEFAULT_PARALLEL_COLUMNS = 3;
|
|
10
17
|
const OUTPUT_FORMATS = new Set(['jpg', 'jpeg', 'png', 'webp']);
|
|
18
|
+
const FOCUS_COLORS = ['#f97316', '#22c55e', '#38bdf8', '#eab308', '#fb7185', '#a78bfa'];
|
|
19
|
+
const MODE_PREFIX = {
|
|
20
|
+
'reference-actual': 'visual-compare',
|
|
21
|
+
'before-after': 'visual-before-after',
|
|
22
|
+
'focus-board': 'visual-focus-board',
|
|
23
|
+
'parallel-board': 'visual-parallel-board',
|
|
24
|
+
};
|
|
25
|
+
const BOARD_MODE_ALIASES = new Map([
|
|
26
|
+
['focus', 'focus-board'],
|
|
27
|
+
['focus-board', 'focus-board'],
|
|
28
|
+
['focus-region', 'focus-board'],
|
|
29
|
+
['focus-region-board', 'focus-board'],
|
|
30
|
+
['parallel', 'parallel-board'],
|
|
31
|
+
['parallel-board', 'parallel-board'],
|
|
32
|
+
['parallel-experiment-board', 'parallel-board'],
|
|
33
|
+
['experiment-board', 'parallel-board'],
|
|
34
|
+
]);
|
|
11
35
|
|
|
12
36
|
function normalizeFormat(format, outPath) {
|
|
13
37
|
const requested = String(format || '').trim().toLowerCase();
|
|
@@ -29,13 +53,31 @@ function outputExtension(format) {
|
|
|
29
53
|
return format === 'jpeg' ? 'jpg' : format;
|
|
30
54
|
}
|
|
31
55
|
|
|
32
|
-
function
|
|
56
|
+
function normalizeWorkspacePath(value) {
|
|
57
|
+
return String(value ?? '').split(path.sep).join('/');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toWorkspacePath(projectRoot, filePath) {
|
|
61
|
+
const absolutePath = path.resolve(filePath);
|
|
62
|
+
const relativePath = path.relative(projectRoot, absolutePath);
|
|
63
|
+
if (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
|
|
64
|
+
return normalizeWorkspacePath(relativePath);
|
|
65
|
+
}
|
|
66
|
+
return absolutePath;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveProjectPath(projectRoot, filePath) {
|
|
70
|
+
return path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function defaultOutputPath(projectRoot, format, mode) {
|
|
74
|
+
const prefix = MODE_PREFIX[mode] ?? 'visual-compare';
|
|
33
75
|
return path.join(
|
|
34
76
|
projectRoot,
|
|
35
77
|
'.openprd',
|
|
36
78
|
'harness',
|
|
37
79
|
'visual-reviews',
|
|
38
|
-
|
|
80
|
+
`${prefix}-${compactTimestamp()}.${outputExtension(format)}`,
|
|
39
81
|
);
|
|
40
82
|
}
|
|
41
83
|
|
|
@@ -58,6 +100,11 @@ function parseQuality(value) {
|
|
|
58
100
|
return quality;
|
|
59
101
|
}
|
|
60
102
|
|
|
103
|
+
function metadataPathForOutput(outputPath) {
|
|
104
|
+
const ext = path.extname(outputPath);
|
|
105
|
+
return ext ? outputPath.slice(0, -ext.length) + '.json' : `${outputPath}.json`;
|
|
106
|
+
}
|
|
107
|
+
|
|
61
108
|
function escapeXml(value) {
|
|
62
109
|
return String(value)
|
|
63
110
|
.replaceAll('&', '&')
|
|
@@ -67,18 +114,176 @@ function escapeXml(value) {
|
|
|
67
114
|
.replaceAll("'", ''');
|
|
68
115
|
}
|
|
69
116
|
|
|
70
|
-
function
|
|
117
|
+
function charCount(value) {
|
|
118
|
+
return Array.from(String(value ?? '')).length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function wrapText(value, maxCharsPerLine) {
|
|
122
|
+
const lines = [];
|
|
123
|
+
const normalized = String(value ?? '').trim();
|
|
124
|
+
if (!normalized) {
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
for (const rawLine of normalized.split(/\r?\n/u)) {
|
|
128
|
+
const line = rawLine.trim();
|
|
129
|
+
if (!line) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
let buffer = '';
|
|
133
|
+
for (const char of Array.from(line)) {
|
|
134
|
+
if (charCount(buffer) >= maxCharsPerLine) {
|
|
135
|
+
lines.push(buffer);
|
|
136
|
+
buffer = '';
|
|
137
|
+
}
|
|
138
|
+
buffer += char;
|
|
139
|
+
}
|
|
140
|
+
if (buffer) {
|
|
141
|
+
lines.push(buffer);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function lineSvg(lines, {
|
|
148
|
+
x = 0,
|
|
149
|
+
y = 0,
|
|
150
|
+
lineHeight = 24,
|
|
151
|
+
fontSize = 18,
|
|
152
|
+
fill = '#e5e7eb',
|
|
153
|
+
fontWeight = 500,
|
|
154
|
+
} = {}) {
|
|
155
|
+
return lines.map((line, index) => (
|
|
156
|
+
`<text x="${x}" y="${y + index * lineHeight}" fill="${fill}" font-size="${fontSize}" font-weight="${fontWeight}" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${escapeXml(line)}</text>`
|
|
157
|
+
)).join('');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function labelSvg(label, options = {}) {
|
|
71
161
|
const text = escapeXml(label);
|
|
72
|
-
const
|
|
73
|
-
const
|
|
162
|
+
const fontSize = options.fontSize ?? 22;
|
|
163
|
+
const height = options.height ?? 46;
|
|
164
|
+
const paddingX = options.paddingX ?? 21;
|
|
165
|
+
const radius = options.radius ?? 14;
|
|
166
|
+
const bg = options.background ?? '#111827';
|
|
167
|
+
const bgOpacity = options.backgroundOpacity ?? 0.82;
|
|
168
|
+
const stroke = options.stroke ?? '#ffffff';
|
|
169
|
+
const strokeOpacity = options.strokeOpacity ?? 0.22;
|
|
170
|
+
const width = Math.max(options.minWidth ?? 126, charCount(label) * (fontSize + 4) + paddingX * 2);
|
|
74
171
|
return Buffer.from(`
|
|
75
|
-
<svg width="${width}" height="
|
|
76
|
-
<rect x="0" y="0" width="${width}" height="
|
|
77
|
-
<rect x="0.75" y="0.75" width="${width - 1.5}" height="
|
|
78
|
-
<text x="
|
|
172
|
+
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
173
|
+
<rect x="0" y="0" width="${width}" height="${height}" rx="${radius}" fill="${bg}" fill-opacity="${bgOpacity}"/>
|
|
174
|
+
<rect x="0.75" y="0.75" width="${width - 1.5}" height="${height - 1.5}" rx="${Math.max(radius - 0.75, 1)}" fill="none" stroke="${stroke}" stroke-opacity="${strokeOpacity}" stroke-width="1.5"/>
|
|
175
|
+
<text x="${paddingX}" y="${Math.round(height * 0.64)}" fill="#ffffff" font-size="${fontSize}" font-weight="700" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${text}</text>
|
|
79
176
|
</svg>`);
|
|
80
177
|
}
|
|
81
178
|
|
|
179
|
+
function titleBlockSvg(width, title, subtitle, eyebrow = null) {
|
|
180
|
+
const contentWidth = Math.max(Number(width) || 0, 1);
|
|
181
|
+
const titleLines = wrapText(title, Math.max(12, Math.floor((contentWidth - 48) / 18)));
|
|
182
|
+
const subtitleLines = wrapText(subtitle, Math.max(16, Math.floor((contentWidth - 48) / 16)));
|
|
183
|
+
const eyebrowLines = wrapText(eyebrow, Math.max(18, Math.floor((contentWidth - 48) / 18)));
|
|
184
|
+
let y = eyebrowLines.length > 0 ? 28 : 0;
|
|
185
|
+
const parts = [];
|
|
186
|
+
if (eyebrowLines.length > 0) {
|
|
187
|
+
parts.push(lineSvg(eyebrowLines, {
|
|
188
|
+
x: 0,
|
|
189
|
+
y: y,
|
|
190
|
+
lineHeight: 20,
|
|
191
|
+
fontSize: 16,
|
|
192
|
+
fill: '#93c5fd',
|
|
193
|
+
fontWeight: 700,
|
|
194
|
+
}));
|
|
195
|
+
y += eyebrowLines.length * 20 + 14;
|
|
196
|
+
}
|
|
197
|
+
parts.push(lineSvg(titleLines, {
|
|
198
|
+
x: 0,
|
|
199
|
+
y: y + 30,
|
|
200
|
+
lineHeight: 34,
|
|
201
|
+
fontSize: 30,
|
|
202
|
+
fill: '#f8fafc',
|
|
203
|
+
fontWeight: 800,
|
|
204
|
+
}));
|
|
205
|
+
y += titleLines.length * 34 + 10;
|
|
206
|
+
if (subtitleLines.length > 0) {
|
|
207
|
+
parts.push(lineSvg(subtitleLines, {
|
|
208
|
+
x: 0,
|
|
209
|
+
y: y + 24,
|
|
210
|
+
lineHeight: 24,
|
|
211
|
+
fontSize: 18,
|
|
212
|
+
fill: '#cbd5e1',
|
|
213
|
+
fontWeight: 500,
|
|
214
|
+
}));
|
|
215
|
+
y += subtitleLines.length * 24 + 6;
|
|
216
|
+
}
|
|
217
|
+
const height = Math.max(72, y + 16);
|
|
218
|
+
return {
|
|
219
|
+
height,
|
|
220
|
+
input: Buffer.from(`
|
|
221
|
+
<svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
222
|
+
${parts.join('')}
|
|
223
|
+
</svg>`),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sectionHeaderSvg(width, index, label, reason = '') {
|
|
228
|
+
const contentWidth = Math.max(Number(width) || 0, 1);
|
|
229
|
+
const titleLines = wrapText(`${index}. ${label}`, Math.max(10, Math.floor((contentWidth - 48) / 18)));
|
|
230
|
+
const reasonLines = wrapText(reason, Math.max(14, Math.floor((contentWidth - 48) / 16)));
|
|
231
|
+
const height = 32 + titleLines.length * 28 + (reasonLines.length > 0 ? 10 + reasonLines.length * 22 : 0);
|
|
232
|
+
return {
|
|
233
|
+
height,
|
|
234
|
+
input: Buffer.from(`
|
|
235
|
+
<svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
236
|
+
<rect x="0" y="0" width="${contentWidth}" height="${height}" rx="18" fill="#0f172a" fill-opacity="0.92"/>
|
|
237
|
+
<rect x="0.75" y="0.75" width="${contentWidth - 1.5}" height="${height - 1.5}" rx="17.25" fill="none" stroke="#475569" stroke-opacity="0.45" stroke-width="1.5"/>
|
|
238
|
+
${lineSvg(titleLines, {
|
|
239
|
+
x: 20,
|
|
240
|
+
y: 30,
|
|
241
|
+
lineHeight: 28,
|
|
242
|
+
fontSize: 24,
|
|
243
|
+
fill: '#f8fafc',
|
|
244
|
+
fontWeight: 800,
|
|
245
|
+
})}
|
|
246
|
+
${reasonLines.length > 0 ? lineSvg(reasonLines, {
|
|
247
|
+
x: 20,
|
|
248
|
+
y: 30 + titleLines.length * 28 + 10,
|
|
249
|
+
lineHeight: 22,
|
|
250
|
+
fontSize: 16,
|
|
251
|
+
fill: '#cbd5e1',
|
|
252
|
+
fontWeight: 500,
|
|
253
|
+
}) : ''}
|
|
254
|
+
</svg>`),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function metricsSvg(width, metrics = [], notes = null) {
|
|
259
|
+
const contentWidth = Math.max(Number(width) || 0, 1);
|
|
260
|
+
const lines = [];
|
|
261
|
+
for (const metric of metrics) {
|
|
262
|
+
lines.push(`${metric.label}:${metric.value}`);
|
|
263
|
+
}
|
|
264
|
+
if (notes) {
|
|
265
|
+
lines.push(...wrapText(notes, Math.max(12, Math.floor((contentWidth - 24) / 16))));
|
|
266
|
+
}
|
|
267
|
+
if (lines.length === 0) {
|
|
268
|
+
return { height: 0, input: null };
|
|
269
|
+
}
|
|
270
|
+
const height = 18 + lines.length * 22;
|
|
271
|
+
return {
|
|
272
|
+
height,
|
|
273
|
+
input: Buffer.from(`
|
|
274
|
+
<svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
275
|
+
${lineSvg(lines, {
|
|
276
|
+
x: 0,
|
|
277
|
+
y: 20,
|
|
278
|
+
lineHeight: 22,
|
|
279
|
+
fontSize: 16,
|
|
280
|
+
fill: '#cbd5e1',
|
|
281
|
+
fontWeight: 500,
|
|
282
|
+
})}
|
|
283
|
+
</svg>`),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
82
287
|
async function resizePanel(inputPath, panelWidth) {
|
|
83
288
|
const source = path.resolve(inputPath);
|
|
84
289
|
const metadata = await sharp(source).metadata();
|
|
@@ -107,6 +312,32 @@ async function resizePanel(inputPath, panelWidth) {
|
|
|
107
312
|
};
|
|
108
313
|
}
|
|
109
314
|
|
|
315
|
+
async function extractCrop(inputPath, box, outputWidth) {
|
|
316
|
+
const source = path.resolve(inputPath);
|
|
317
|
+
const { data, info } = await sharp(source)
|
|
318
|
+
.rotate()
|
|
319
|
+
.extract({
|
|
320
|
+
left: box.x,
|
|
321
|
+
top: box.y,
|
|
322
|
+
width: box.width,
|
|
323
|
+
height: box.height,
|
|
324
|
+
})
|
|
325
|
+
.resize({
|
|
326
|
+
width: outputWidth,
|
|
327
|
+
fit: 'inside',
|
|
328
|
+
withoutEnlargement: false,
|
|
329
|
+
})
|
|
330
|
+
.png()
|
|
331
|
+
.toBuffer({ resolveWithObject: true });
|
|
332
|
+
return {
|
|
333
|
+
input: data,
|
|
334
|
+
width: info.width,
|
|
335
|
+
height: info.height,
|
|
336
|
+
source,
|
|
337
|
+
absolute: box,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
110
341
|
function encodePipeline(image, format, quality) {
|
|
111
342
|
if (format === 'png') {
|
|
112
343
|
return image.png();
|
|
@@ -117,31 +348,260 @@ function encodePipeline(image, format, quality) {
|
|
|
117
348
|
return image.jpeg({ quality });
|
|
118
349
|
}
|
|
119
350
|
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
351
|
+
function resolveComparisonInputs(options) {
|
|
352
|
+
const hasReferenceActual = Boolean(options.reference || options.actual);
|
|
353
|
+
const hasBeforeAfter = Boolean(options.before || options.after);
|
|
354
|
+
const hasBoard = Boolean(options.board);
|
|
355
|
+
const modeCount = [hasReferenceActual, hasBeforeAfter, hasBoard].filter(Boolean).length;
|
|
356
|
+
if (modeCount > 1) {
|
|
357
|
+
throw new Error('Use either --reference/--actual, --before/--after, or --board, not multiple modes together.');
|
|
358
|
+
}
|
|
359
|
+
if (hasBeforeAfter) {
|
|
360
|
+
if (!options.before) {
|
|
361
|
+
throw new Error('Missing --before image path.');
|
|
362
|
+
}
|
|
363
|
+
if (!options.after) {
|
|
364
|
+
throw new Error('Missing --after image path.');
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
mode: 'before-after',
|
|
368
|
+
left: options.before,
|
|
369
|
+
right: options.after,
|
|
370
|
+
leftLabel: options.referenceLabel || DEFAULT_BEFORE_LABEL,
|
|
371
|
+
rightLabel: options.actualLabel || DEFAULT_AFTER_LABEL,
|
|
372
|
+
nextActions: [
|
|
373
|
+
'把输出图片作为视觉改动自检证据查看:左侧修改前,右侧修改后。',
|
|
374
|
+
'检查预期变化是否出现,以及未改区域是否有布局、颜色、密度或状态漂移。',
|
|
375
|
+
'没有效果图时,这张图只证明改动前后差异已自检;大界面方向性改造仍需先完成方案评审。',
|
|
376
|
+
],
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (hasReferenceActual) {
|
|
380
|
+
if (!options.reference) {
|
|
381
|
+
throw new Error('Missing --reference image path.');
|
|
382
|
+
}
|
|
383
|
+
if (!options.actual) {
|
|
384
|
+
throw new Error('Missing --actual image path.');
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
mode: 'reference-actual',
|
|
388
|
+
left: options.reference,
|
|
389
|
+
right: options.actual,
|
|
390
|
+
leftLabel: options.referenceLabel || DEFAULT_REFERENCE_LABEL,
|
|
391
|
+
rightLabel: options.actualLabel || DEFAULT_ACTUAL_LABEL,
|
|
392
|
+
nextActions: [
|
|
393
|
+
'把输出图片作为视觉评审证据查看:左侧效果图,右侧实现截图。',
|
|
394
|
+
'如果仍有明显差异,继续按效果图复刻并重新运行 visual-compare。',
|
|
395
|
+
'只有对比图确认一致后,才声明本阶段界面视觉实现完成。',
|
|
396
|
+
],
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (hasBoard) {
|
|
400
|
+
return {
|
|
401
|
+
mode: 'board',
|
|
402
|
+
board: options.board,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
throw new Error('Missing visual compare input. Use --reference/--actual, --before/--after, or --board.');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function normalizeBoardMode(value) {
|
|
409
|
+
const key = String(value ?? '').trim().toLowerCase();
|
|
410
|
+
return BOARD_MODE_ALIASES.get(key) ?? null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function readBoardSpec(projectRoot, boardPath) {
|
|
414
|
+
const sourcePath = resolveProjectPath(projectRoot, boardPath);
|
|
415
|
+
const payload = JSON.parse(await fs.readFile(sourcePath, 'utf8'));
|
|
416
|
+
const mode = normalizeBoardMode(payload.mode);
|
|
417
|
+
if (!mode) {
|
|
418
|
+
throw new Error(`Unsupported board mode in ${boardPath}. Use focus-board or parallel-board.`);
|
|
125
419
|
}
|
|
126
|
-
|
|
127
|
-
|
|
420
|
+
return {
|
|
421
|
+
mode,
|
|
422
|
+
sourcePath,
|
|
423
|
+
payload,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function normalizeImageSpec(projectRoot, value, fallbackLabel) {
|
|
428
|
+
if (!value) {
|
|
429
|
+
throw new Error('Board image spec is missing.');
|
|
128
430
|
}
|
|
431
|
+
if (typeof value === 'string') {
|
|
432
|
+
return {
|
|
433
|
+
path: resolveProjectPath(projectRoot, value),
|
|
434
|
+
label: fallbackLabel,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
if (typeof value === 'object' && value.path) {
|
|
438
|
+
return {
|
|
439
|
+
path: resolveProjectPath(projectRoot, value.path),
|
|
440
|
+
label: value.label ? String(value.label) : fallbackLabel,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
throw new Error('Board image spec must be a path string or an object with { path, label? }.');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function normalizeMetricList(metrics) {
|
|
447
|
+
if (!metrics) {
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
if (Array.isArray(metrics)) {
|
|
451
|
+
return metrics
|
|
452
|
+
.filter(Boolean)
|
|
453
|
+
.map((item) => {
|
|
454
|
+
if (typeof item === 'string') {
|
|
455
|
+
return { label: '说明', value: item };
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
label: String(item.label ?? item.key ?? '指标'),
|
|
459
|
+
value: String(item.value ?? ''),
|
|
460
|
+
};
|
|
461
|
+
})
|
|
462
|
+
.filter((item) => item.value.trim());
|
|
463
|
+
}
|
|
464
|
+
if (typeof metrics === 'object') {
|
|
465
|
+
return Object.entries(metrics)
|
|
466
|
+
.filter(([, value]) => value !== null && value !== undefined && String(value).trim())
|
|
467
|
+
.map(([key, value]) => ({ label: key, value: String(value) }));
|
|
468
|
+
}
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function normalizeMediaList(projectRoot, media) {
|
|
473
|
+
if (!Array.isArray(media)) {
|
|
474
|
+
return [];
|
|
475
|
+
}
|
|
476
|
+
return media
|
|
477
|
+
.filter(Boolean)
|
|
478
|
+
.map((entry, index) => {
|
|
479
|
+
if (typeof entry === 'string') {
|
|
480
|
+
return {
|
|
481
|
+
path: resolveProjectPath(projectRoot, entry),
|
|
482
|
+
label: `素材 ${index + 1}`,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
if (!entry.path) {
|
|
486
|
+
throw new Error(`Parallel board media[${index}] is missing path.`);
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
path: resolveProjectPath(projectRoot, entry.path),
|
|
490
|
+
label: String(entry.label ?? `素材 ${index + 1}`),
|
|
491
|
+
};
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function parseCropDimension(box, key, alias) {
|
|
496
|
+
const primary = box[key];
|
|
497
|
+
const secondary = alias ? box[alias] : undefined;
|
|
498
|
+
const value = primary ?? secondary;
|
|
499
|
+
if (value === null || value === undefined || value === '') {
|
|
500
|
+
throw new Error(`Missing ${key} in focus region box.`);
|
|
501
|
+
}
|
|
502
|
+
const parsed = Number(value);
|
|
503
|
+
if (!Number.isFinite(parsed)) {
|
|
504
|
+
throw new Error(`Invalid ${key} in focus region box.`);
|
|
505
|
+
}
|
|
506
|
+
return parsed;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function clampBox(box, original) {
|
|
510
|
+
const x = Math.max(0, Math.min(box.x, original.width - 1));
|
|
511
|
+
const y = Math.max(0, Math.min(box.y, original.height - 1));
|
|
512
|
+
const width = Math.max(1, Math.min(box.width, original.width - x));
|
|
513
|
+
const height = Math.max(1, Math.min(box.height, original.height - y));
|
|
514
|
+
return {
|
|
515
|
+
x,
|
|
516
|
+
y,
|
|
517
|
+
width,
|
|
518
|
+
height,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function resolveCropBox(box, original) {
|
|
523
|
+
if (!box || typeof box !== 'object') {
|
|
524
|
+
throw new Error('Focus region box must be an object.');
|
|
525
|
+
}
|
|
526
|
+
const rawUnit = String(box.unit ?? 'ratio').trim().toLowerCase();
|
|
527
|
+
const x = parseCropDimension(box, 'x', 'left');
|
|
528
|
+
const y = parseCropDimension(box, 'y', 'top');
|
|
529
|
+
const width = parseCropDimension(box, 'width', 'w');
|
|
530
|
+
const height = parseCropDimension(box, 'height', 'h');
|
|
531
|
+
let absolute;
|
|
532
|
+
|
|
533
|
+
if (rawUnit === 'ratio') {
|
|
534
|
+
absolute = {
|
|
535
|
+
x: Math.round(x * original.width),
|
|
536
|
+
y: Math.round(y * original.height),
|
|
537
|
+
width: Math.round(width * original.width),
|
|
538
|
+
height: Math.round(height * original.height),
|
|
539
|
+
};
|
|
540
|
+
} else if (rawUnit === 'percent') {
|
|
541
|
+
absolute = {
|
|
542
|
+
x: Math.round((x / 100) * original.width),
|
|
543
|
+
y: Math.round((y / 100) * original.height),
|
|
544
|
+
width: Math.round((width / 100) * original.width),
|
|
545
|
+
height: Math.round((height / 100) * original.height),
|
|
546
|
+
};
|
|
547
|
+
} else if (rawUnit === 'px' || rawUnit === 'pixel' || rawUnit === 'pixels') {
|
|
548
|
+
absolute = {
|
|
549
|
+
x: Math.round(x),
|
|
550
|
+
y: Math.round(y),
|
|
551
|
+
width: Math.round(width),
|
|
552
|
+
height: Math.round(height),
|
|
553
|
+
};
|
|
554
|
+
} else {
|
|
555
|
+
throw new Error(`Unsupported focus region unit: ${rawUnit}. Use ratio, percent, or px.`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
unit: rawUnit === 'pixel' || rawUnit === 'pixels' ? 'px' : rawUnit,
|
|
560
|
+
requested: { x, y, width, height },
|
|
561
|
+
absolute: clampBox(absolute, original),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function renderedBoxFromAbsolute(box, panel) {
|
|
566
|
+
const scaleX = panel.width / panel.original.width;
|
|
567
|
+
const scaleY = panel.height / panel.original.height;
|
|
568
|
+
return {
|
|
569
|
+
x: Math.round(box.x * scaleX),
|
|
570
|
+
y: Math.round(box.y * scaleY),
|
|
571
|
+
width: Math.max(2, Math.round(box.width * scaleX)),
|
|
572
|
+
height: Math.max(2, Math.round(box.height * scaleY)),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function focusOverlaySvg(width, height, regions) {
|
|
577
|
+
const overlays = regions.map((region, index) => {
|
|
578
|
+
const color = region.color;
|
|
579
|
+
const box = region.box;
|
|
580
|
+
const badgeSize = 32;
|
|
581
|
+
const badgeX = Math.max(10, box.x + 10);
|
|
582
|
+
const badgeY = Math.max(10, box.y + 10);
|
|
583
|
+
return `
|
|
584
|
+
<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="16" fill="none" stroke="${color}" stroke-width="4"/>
|
|
585
|
+
<rect x="${badgeX}" y="${badgeY}" width="${badgeSize}" height="${badgeSize}" rx="16" fill="${color}"/>
|
|
586
|
+
<text x="${badgeX + 10}" y="${badgeY + 22}" fill="#0f172a" font-size="18" font-weight="800" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${index + 1}</text>
|
|
587
|
+
`;
|
|
588
|
+
}).join('');
|
|
589
|
+
return Buffer.from(`
|
|
590
|
+
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
591
|
+
${overlays}
|
|
592
|
+
</svg>`);
|
|
593
|
+
}
|
|
129
594
|
|
|
595
|
+
async function renderStandardComparison(projectRoot, comparison, options = {}) {
|
|
130
596
|
const format = normalizeFormat(options.format, options.out);
|
|
131
597
|
const outputPath = options.out
|
|
132
598
|
? path.resolve(projectRoot, options.out)
|
|
133
|
-
: defaultOutputPath(projectRoot, format);
|
|
599
|
+
: defaultOutputPath(projectRoot, format, comparison.mode);
|
|
134
600
|
const quality = parseQuality(options.quality);
|
|
135
601
|
const maxPanelWidth = parsePositiveInteger(options.maxPanelWidth, DEFAULT_PANEL_WIDTH, '--max-panel-width');
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
const referencePanel = await resizePanel(reference, maxPanelWidth);
|
|
140
|
-
const actualPanel = await resizePanel(actual, maxPanelWidth);
|
|
141
|
-
const panelWidth = Math.min(
|
|
142
|
-
maxPanelWidth,
|
|
143
|
-
Math.max(referencePanel.width, actualPanel.width),
|
|
144
|
-
);
|
|
602
|
+
const referencePanel = await resizePanel(comparison.left, maxPanelWidth);
|
|
603
|
+
const actualPanel = await resizePanel(comparison.right, maxPanelWidth);
|
|
604
|
+
const panelWidth = Math.min(maxPanelWidth, Math.max(referencePanel.width, actualPanel.width));
|
|
145
605
|
const maxPanelHeight = Math.max(referencePanel.height, actualPanel.height);
|
|
146
606
|
const margin = 24;
|
|
147
607
|
const gap = 24;
|
|
@@ -166,24 +626,60 @@ async function visualCompareWorkspace(projectRoot, options = {}) {
|
|
|
166
626
|
}).composite([
|
|
167
627
|
{ input: referencePanel.input, left: referenceLeft, top: referenceTop },
|
|
168
628
|
{ input: actualPanel.input, left: actualLeft, top: actualTop },
|
|
169
|
-
{ input: labelSvg(
|
|
170
|
-
{ input: labelSvg(
|
|
629
|
+
{ input: labelSvg(comparison.leftLabel), left: referenceLeft + 16, top: referenceTop + 16 },
|
|
630
|
+
{ input: labelSvg(comparison.rightLabel), left: actualLeft + 16, top: actualTop + 16 },
|
|
171
631
|
]);
|
|
172
632
|
|
|
173
633
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
174
634
|
await encodePipeline(canvas, format, quality).toFile(outputPath);
|
|
635
|
+
const metadataPath = metadataPathForOutput(outputPath);
|
|
636
|
+
const reviewArtifact = {
|
|
637
|
+
version: 1,
|
|
638
|
+
schema: VISUAL_REVIEW_SCHEMA,
|
|
639
|
+
generatedAt: timestamp(),
|
|
640
|
+
mode: comparison.mode,
|
|
641
|
+
outputPath: toWorkspacePath(projectRoot, outputPath),
|
|
642
|
+
format,
|
|
643
|
+
labels: {
|
|
644
|
+
reference: comparison.leftLabel,
|
|
645
|
+
actual: comparison.rightLabel,
|
|
646
|
+
},
|
|
647
|
+
reference: {
|
|
648
|
+
path: toWorkspacePath(projectRoot, referencePanel.source),
|
|
649
|
+
original: referencePanel.original,
|
|
650
|
+
rendered: {
|
|
651
|
+
width: referencePanel.width,
|
|
652
|
+
height: referencePanel.height,
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
actual: {
|
|
656
|
+
path: toWorkspacePath(projectRoot, actualPanel.source),
|
|
657
|
+
original: actualPanel.original,
|
|
658
|
+
rendered: {
|
|
659
|
+
width: actualPanel.width,
|
|
660
|
+
height: actualPanel.height,
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
canvas: {
|
|
664
|
+
width: canvasWidth,
|
|
665
|
+
height: canvasHeight,
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
await fs.writeFile(metadataPath, `${JSON.stringify(reviewArtifact, null, 2)}\n`, 'utf8');
|
|
175
669
|
|
|
176
670
|
return {
|
|
177
671
|
ok: true,
|
|
178
672
|
action: 'visual-compare',
|
|
673
|
+
mode: comparison.mode,
|
|
179
674
|
projectRoot,
|
|
180
675
|
outputPath,
|
|
676
|
+
metadataPath,
|
|
181
677
|
format,
|
|
182
678
|
quality: format === 'png' ? null : quality,
|
|
183
679
|
maxPanelWidth,
|
|
184
680
|
labels: {
|
|
185
|
-
reference:
|
|
186
|
-
actual:
|
|
681
|
+
reference: comparison.leftLabel,
|
|
682
|
+
actual: comparison.rightLabel,
|
|
187
683
|
},
|
|
188
684
|
reference: {
|
|
189
685
|
path: referencePanel.source,
|
|
@@ -205,12 +701,397 @@ async function visualCompareWorkspace(projectRoot, options = {}) {
|
|
|
205
701
|
width: canvasWidth,
|
|
206
702
|
height: canvasHeight,
|
|
207
703
|
},
|
|
704
|
+
reviewArtifact,
|
|
705
|
+
nextActions: comparison.nextActions,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async function renderFocusBoard(projectRoot, board, options = {}) {
|
|
710
|
+
const format = normalizeFormat(options.format, options.out);
|
|
711
|
+
const outputPath = options.out
|
|
712
|
+
? path.resolve(projectRoot, options.out)
|
|
713
|
+
: defaultOutputPath(projectRoot, format, 'focus-board');
|
|
714
|
+
const quality = parseQuality(options.quality);
|
|
715
|
+
const maxPanelWidth = parsePositiveInteger(options.maxPanelWidth, DEFAULT_PANEL_WIDTH, '--max-panel-width');
|
|
716
|
+
const leftSpec = normalizeImageSpec(projectRoot, board.payload.left ?? board.payload.reference, board.payload.leftLabel ?? DEFAULT_REFERENCE_LABEL);
|
|
717
|
+
const rightSpec = normalizeImageSpec(projectRoot, board.payload.right ?? board.payload.actual, board.payload.rightLabel ?? DEFAULT_ACTUAL_LABEL);
|
|
718
|
+
const focusRegions = Array.isArray(board.payload.focusRegions ?? board.payload.regions) ? (board.payload.focusRegions ?? board.payload.regions) : [];
|
|
719
|
+
if (focusRegions.length === 0) {
|
|
720
|
+
throw new Error('Focus board requires focusRegions with at least one region.');
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const leftPanel = await resizePanel(leftSpec.path, maxPanelWidth);
|
|
724
|
+
const rightPanel = await resizePanel(rightSpec.path, maxPanelWidth);
|
|
725
|
+
const panelWidth = Math.min(maxPanelWidth, Math.max(leftPanel.width, rightPanel.width));
|
|
726
|
+
const overviewHeight = Math.max(leftPanel.height, rightPanel.height);
|
|
727
|
+
const margin = 24;
|
|
728
|
+
const gap = 24;
|
|
729
|
+
const sectionGap = 28;
|
|
730
|
+
const contentWidth = margin * 2 + panelWidth * 2 + gap;
|
|
731
|
+
const titleBlock = titleBlockSvg(
|
|
732
|
+
contentWidth - margin * 2,
|
|
733
|
+
String(board.payload.title ?? DEFAULT_FOCUS_TITLE),
|
|
734
|
+
String(board.payload.summary ?? '先看整体标框,再看编号对应的局部放大;局部差异优先在这里复核。'),
|
|
735
|
+
'视觉验收 / 局部焦点',
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
const leftOverviewX = margin + Math.round((panelWidth - leftPanel.width) / 2);
|
|
739
|
+
const rightOverviewX = margin + panelWidth + gap + Math.round((panelWidth - rightPanel.width) / 2);
|
|
740
|
+
const overviewTop = margin + titleBlock.height + 18;
|
|
741
|
+
const leftOverviewY = overviewTop + Math.round((overviewHeight - leftPanel.height) / 2);
|
|
742
|
+
const rightOverviewY = overviewTop + Math.round((overviewHeight - rightPanel.height) / 2);
|
|
743
|
+
|
|
744
|
+
const composites = [
|
|
745
|
+
{ input: titleBlock.input, left: margin, top: margin },
|
|
746
|
+
{ input: leftPanel.input, left: leftOverviewX, top: leftOverviewY },
|
|
747
|
+
{ input: rightPanel.input, left: rightOverviewX, top: rightOverviewY },
|
|
748
|
+
{ input: labelSvg(leftSpec.label), left: leftOverviewX + 16, top: leftOverviewY + 16 },
|
|
749
|
+
{ input: labelSvg(rightSpec.label), left: rightOverviewX + 16, top: rightOverviewY + 16 },
|
|
750
|
+
];
|
|
751
|
+
|
|
752
|
+
const metadataRegions = [];
|
|
753
|
+
const leftOverlayRegions = [];
|
|
754
|
+
const rightOverlayRegions = [];
|
|
755
|
+
let currentTop = overviewTop + overviewHeight + sectionGap;
|
|
756
|
+
|
|
757
|
+
for (const [index, region] of focusRegions.entries()) {
|
|
758
|
+
const label = String(region.label ?? region.name ?? `焦点 ${index + 1}`);
|
|
759
|
+
const reason = String(region.reason ?? region.note ?? '').trim();
|
|
760
|
+
const color = FOCUS_COLORS[index % FOCUS_COLORS.length];
|
|
761
|
+
const leftResolved = resolveCropBox(region.leftBox ?? region.referenceBox ?? region.box, leftPanel.original);
|
|
762
|
+
const rightResolved = resolveCropBox(region.rightBox ?? region.actualBox ?? region.box, rightPanel.original);
|
|
763
|
+
const leftRenderedBox = renderedBoxFromAbsolute(leftResolved.absolute, leftPanel);
|
|
764
|
+
const rightRenderedBox = renderedBoxFromAbsolute(rightResolved.absolute, rightPanel);
|
|
765
|
+
const leftCrop = await extractCrop(leftPanel.source, leftResolved.absolute, panelWidth);
|
|
766
|
+
const rightCrop = await extractCrop(rightPanel.source, rightResolved.absolute, panelWidth);
|
|
767
|
+
const header = sectionHeaderSvg(contentWidth - margin * 2, index + 1, label, reason);
|
|
768
|
+
const cropTop = currentTop + header.height + 12;
|
|
769
|
+
const cropHeight = Math.max(leftCrop.height, rightCrop.height);
|
|
770
|
+
const leftCropX = margin + Math.round((panelWidth - leftCrop.width) / 2);
|
|
771
|
+
const rightCropX = margin + panelWidth + gap + Math.round((panelWidth - rightCrop.width) / 2);
|
|
772
|
+
const leftCropY = cropTop + Math.round((cropHeight - leftCrop.height) / 2);
|
|
773
|
+
const rightCropY = cropTop + Math.round((cropHeight - rightCrop.height) / 2);
|
|
774
|
+
|
|
775
|
+
composites.push(
|
|
776
|
+
{ input: header.input, left: margin, top: currentTop },
|
|
777
|
+
{ input: leftCrop.input, left: leftCropX, top: leftCropY },
|
|
778
|
+
{ input: rightCrop.input, left: rightCropX, top: rightCropY },
|
|
779
|
+
{ input: labelSvg(leftSpec.label, { fontSize: 18, height: 40, minWidth: 112 }), left: leftCropX + 12, top: leftCropY + 12 },
|
|
780
|
+
{ input: labelSvg(rightSpec.label, { fontSize: 18, height: 40, minWidth: 112 }), left: rightCropX + 12, top: rightCropY + 12 },
|
|
781
|
+
);
|
|
782
|
+
leftOverlayRegions.push({ color, box: leftRenderedBox });
|
|
783
|
+
rightOverlayRegions.push({ color, box: rightRenderedBox });
|
|
784
|
+
metadataRegions.push({
|
|
785
|
+
index: index + 1,
|
|
786
|
+
label,
|
|
787
|
+
reason,
|
|
788
|
+
color,
|
|
789
|
+
leftBox: {
|
|
790
|
+
unit: leftResolved.unit,
|
|
791
|
+
requested: leftResolved.requested,
|
|
792
|
+
absolute: leftResolved.absolute,
|
|
793
|
+
rendered: leftRenderedBox,
|
|
794
|
+
},
|
|
795
|
+
rightBox: {
|
|
796
|
+
unit: rightResolved.unit,
|
|
797
|
+
requested: rightResolved.requested,
|
|
798
|
+
absolute: rightResolved.absolute,
|
|
799
|
+
rendered: rightRenderedBox,
|
|
800
|
+
},
|
|
801
|
+
leftCrop: {
|
|
802
|
+
width: leftCrop.width,
|
|
803
|
+
height: leftCrop.height,
|
|
804
|
+
},
|
|
805
|
+
rightCrop: {
|
|
806
|
+
width: rightCrop.width,
|
|
807
|
+
height: rightCrop.height,
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
currentTop = cropTop + cropHeight + sectionGap;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
composites.push(
|
|
814
|
+
{ input: focusOverlaySvg(leftPanel.width, leftPanel.height, leftOverlayRegions), left: leftOverviewX, top: leftOverviewY },
|
|
815
|
+
{ input: focusOverlaySvg(rightPanel.width, rightPanel.height, rightOverlayRegions), left: rightOverviewX, top: rightOverviewY },
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
const canvasHeight = currentTop + margin - sectionGap;
|
|
819
|
+
const canvas = sharp({
|
|
820
|
+
create: {
|
|
821
|
+
width: contentWidth,
|
|
822
|
+
height: canvasHeight,
|
|
823
|
+
channels: 3,
|
|
824
|
+
background: '#111827',
|
|
825
|
+
},
|
|
826
|
+
}).composite(composites);
|
|
827
|
+
|
|
828
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
829
|
+
await encodePipeline(canvas, format, quality).toFile(outputPath);
|
|
830
|
+
const metadataPath = metadataPathForOutput(outputPath);
|
|
831
|
+
const reviewArtifact = {
|
|
832
|
+
version: 1,
|
|
833
|
+
schema: VISUAL_REVIEW_SCHEMA,
|
|
834
|
+
generatedAt: timestamp(),
|
|
835
|
+
mode: 'focus-board',
|
|
836
|
+
outputPath: toWorkspacePath(projectRoot, outputPath),
|
|
837
|
+
format,
|
|
838
|
+
boardSource: toWorkspacePath(projectRoot, board.sourcePath),
|
|
839
|
+
title: String(board.payload.title ?? DEFAULT_FOCUS_TITLE),
|
|
840
|
+
labels: {
|
|
841
|
+
reference: leftSpec.label,
|
|
842
|
+
actual: rightSpec.label,
|
|
843
|
+
},
|
|
844
|
+
reference: {
|
|
845
|
+
path: toWorkspacePath(projectRoot, leftPanel.source),
|
|
846
|
+
original: leftPanel.original,
|
|
847
|
+
rendered: {
|
|
848
|
+
width: leftPanel.width,
|
|
849
|
+
height: leftPanel.height,
|
|
850
|
+
},
|
|
851
|
+
},
|
|
852
|
+
actual: {
|
|
853
|
+
path: toWorkspacePath(projectRoot, rightPanel.source),
|
|
854
|
+
original: rightPanel.original,
|
|
855
|
+
rendered: {
|
|
856
|
+
width: rightPanel.width,
|
|
857
|
+
height: rightPanel.height,
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
focusRegions: metadataRegions,
|
|
861
|
+
canvas: {
|
|
862
|
+
width: contentWidth,
|
|
863
|
+
height: canvasHeight,
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
await fs.writeFile(metadataPath, `${JSON.stringify(reviewArtifact, null, 2)}\n`, 'utf8');
|
|
867
|
+
|
|
868
|
+
return {
|
|
869
|
+
ok: true,
|
|
870
|
+
action: 'visual-compare',
|
|
871
|
+
mode: 'focus-board',
|
|
872
|
+
projectRoot,
|
|
873
|
+
outputPath,
|
|
874
|
+
metadataPath,
|
|
875
|
+
format,
|
|
876
|
+
quality: format === 'png' ? null : quality,
|
|
877
|
+
maxPanelWidth,
|
|
878
|
+
labels: reviewArtifact.labels,
|
|
879
|
+
reference: reviewArtifact.reference,
|
|
880
|
+
actual: reviewArtifact.actual,
|
|
881
|
+
focusRegions: metadataRegions,
|
|
882
|
+
canvas: reviewArtifact.canvas,
|
|
883
|
+
reviewArtifact,
|
|
208
884
|
nextActions: [
|
|
209
|
-
'
|
|
210
|
-
'
|
|
211
|
-
'
|
|
885
|
+
'先看顶部编号框,再对照下面同编号的局部放大图复核差异。',
|
|
886
|
+
'若局部区域仍有问题,优先围绕该编号返工,并重新生成焦点证据板。',
|
|
887
|
+
'局部证据板适合补充整体对比,不建议只看整体图就结束界面验收。',
|
|
212
888
|
],
|
|
213
889
|
};
|
|
214
890
|
}
|
|
215
891
|
|
|
892
|
+
async function renderParallelCard(projectRoot, item, index, options = {}) {
|
|
893
|
+
const cardWidth = options.cardWidth;
|
|
894
|
+
const contentWidth = cardWidth - 36;
|
|
895
|
+
const title = String(item.label ?? item.title ?? `方案 ${index + 1}`);
|
|
896
|
+
const subtitle = String(item.subtitle ?? item.summary ?? '').trim();
|
|
897
|
+
const verdict = String(item.verdict ?? '').trim();
|
|
898
|
+
const mediaList = normalizeMediaList(projectRoot, item.media);
|
|
899
|
+
const metrics = normalizeMetricList(item.metrics ?? item.metricMap);
|
|
900
|
+
const notes = String(item.notes ?? item.note ?? '').trim();
|
|
901
|
+
const header = titleBlockSvg(
|
|
902
|
+
contentWidth,
|
|
903
|
+
`${index + 1}. ${title}`,
|
|
904
|
+
subtitle,
|
|
905
|
+
verdict ? `结论:${verdict}` : '并行实验',
|
|
906
|
+
);
|
|
907
|
+
const composites = [
|
|
908
|
+
{ input: header.input, left: 18, top: 18 },
|
|
909
|
+
];
|
|
910
|
+
const renderedMedia = [];
|
|
911
|
+
let currentTop = 18 + header.height + 12;
|
|
912
|
+
|
|
913
|
+
for (const media of mediaList) {
|
|
914
|
+
const label = labelSvg(media.label, {
|
|
915
|
+
fontSize: 16,
|
|
916
|
+
height: 36,
|
|
917
|
+
minWidth: 96,
|
|
918
|
+
paddingX: 16,
|
|
919
|
+
radius: 12,
|
|
920
|
+
});
|
|
921
|
+
const mediaPanel = await resizePanel(media.path, contentWidth);
|
|
922
|
+
composites.push(
|
|
923
|
+
{ input: label, left: 18, top: currentTop },
|
|
924
|
+
{ input: mediaPanel.input, left: 18 + Math.round((contentWidth - mediaPanel.width) / 2), top: currentTop + 42 },
|
|
925
|
+
);
|
|
926
|
+
renderedMedia.push({
|
|
927
|
+
path: toWorkspacePath(projectRoot, media.path),
|
|
928
|
+
label: media.label,
|
|
929
|
+
original: mediaPanel.original,
|
|
930
|
+
rendered: {
|
|
931
|
+
width: mediaPanel.width,
|
|
932
|
+
height: mediaPanel.height,
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
currentTop += 42 + mediaPanel.height + 16;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const metricsBlock = metricsSvg(contentWidth, metrics, notes);
|
|
939
|
+
if (metricsBlock.input) {
|
|
940
|
+
composites.push({ input: metricsBlock.input, left: 18, top: currentTop });
|
|
941
|
+
currentTop += metricsBlock.height + 12;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const cardHeight = currentTop + 18;
|
|
945
|
+
const card = sharp({
|
|
946
|
+
create: {
|
|
947
|
+
width: cardWidth,
|
|
948
|
+
height: cardHeight,
|
|
949
|
+
channels: 3,
|
|
950
|
+
background: '#0f172a',
|
|
951
|
+
},
|
|
952
|
+
}).composite([
|
|
953
|
+
...composites,
|
|
954
|
+
{
|
|
955
|
+
input: Buffer.from(`
|
|
956
|
+
<svg width="${cardWidth}" height="${cardHeight}" viewBox="0 0 ${cardWidth} ${cardHeight}" xmlns="http://www.w3.org/2000/svg">
|
|
957
|
+
<rect x="0.75" y="0.75" width="${cardWidth - 1.5}" height="${cardHeight - 1.5}" rx="22" fill="none" stroke="#334155" stroke-width="1.5"/>
|
|
958
|
+
</svg>`),
|
|
959
|
+
left: 0,
|
|
960
|
+
top: 0,
|
|
961
|
+
},
|
|
962
|
+
]);
|
|
963
|
+
|
|
964
|
+
return {
|
|
965
|
+
image: await card.png().toBuffer(),
|
|
966
|
+
width: cardWidth,
|
|
967
|
+
height: cardHeight,
|
|
968
|
+
item: {
|
|
969
|
+
index: index + 1,
|
|
970
|
+
label: title,
|
|
971
|
+
subtitle,
|
|
972
|
+
verdict,
|
|
973
|
+
metrics,
|
|
974
|
+
notes,
|
|
975
|
+
media: renderedMedia,
|
|
976
|
+
},
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function renderParallelBoard(projectRoot, board, options = {}) {
|
|
981
|
+
const format = normalizeFormat(options.format, options.out);
|
|
982
|
+
const outputPath = options.out
|
|
983
|
+
? path.resolve(projectRoot, options.out)
|
|
984
|
+
: defaultOutputPath(projectRoot, format, 'parallel-board');
|
|
985
|
+
const quality = parseQuality(options.quality);
|
|
986
|
+
const items = Array.isArray(board.payload.items) ? board.payload.items : [];
|
|
987
|
+
if (items.length === 0) {
|
|
988
|
+
throw new Error('Parallel board requires items with at least one experiment.');
|
|
989
|
+
}
|
|
990
|
+
const cardWidth = parsePositiveInteger(board.payload.cardWidth ?? options.maxPanelWidth, DEFAULT_PARALLEL_CARD_WIDTH, 'cardWidth');
|
|
991
|
+
const columns = Math.max(1, Math.min(parsePositiveInteger(board.payload.columns, Math.min(DEFAULT_PARALLEL_COLUMNS, items.length), 'columns'), 4));
|
|
992
|
+
const margin = 24;
|
|
993
|
+
const gap = 24;
|
|
994
|
+
const titleBlock = titleBlockSvg(
|
|
995
|
+
columns * cardWidth + (columns - 1) * gap,
|
|
996
|
+
String(board.payload.title ?? DEFAULT_PARALLEL_TITLE),
|
|
997
|
+
String(board.payload.summary ?? '把多方向产物、局部截图、GIF 首帧和指标放到一板里,方便统一审查。'),
|
|
998
|
+
'视觉验收 / 并行实验',
|
|
999
|
+
);
|
|
1000
|
+
|
|
1001
|
+
const renderedCards = [];
|
|
1002
|
+
for (const [index, item] of items.entries()) {
|
|
1003
|
+
renderedCards.push(await renderParallelCard(projectRoot, item, index, { cardWidth }));
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const rowHeights = [];
|
|
1007
|
+
for (let index = 0; index < renderedCards.length; index += columns) {
|
|
1008
|
+
rowHeights.push(Math.max(...renderedCards.slice(index, index + columns).map((card) => card.height)));
|
|
1009
|
+
}
|
|
1010
|
+
const canvasWidth = margin * 2 + columns * cardWidth + (columns - 1) * gap;
|
|
1011
|
+
const canvasHeight = margin * 2 + titleBlock.height + 18 + rowHeights.reduce((sum, value) => sum + value, 0) + Math.max(0, rowHeights.length - 1) * gap;
|
|
1012
|
+
const composites = [
|
|
1013
|
+
{ input: titleBlock.input, left: margin, top: margin },
|
|
1014
|
+
];
|
|
1015
|
+
let currentTop = margin + titleBlock.height + 18;
|
|
1016
|
+
for (let row = 0; row < rowHeights.length; row += 1) {
|
|
1017
|
+
const rowCards = renderedCards.slice(row * columns, row * columns + columns);
|
|
1018
|
+
for (const [column, card] of rowCards.entries()) {
|
|
1019
|
+
composites.push({
|
|
1020
|
+
input: card.image,
|
|
1021
|
+
left: margin + column * (cardWidth + gap),
|
|
1022
|
+
top: currentTop,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
currentTop += rowHeights[row] + gap;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const canvas = sharp({
|
|
1029
|
+
create: {
|
|
1030
|
+
width: canvasWidth,
|
|
1031
|
+
height: canvasHeight,
|
|
1032
|
+
channels: 3,
|
|
1033
|
+
background: '#111827',
|
|
1034
|
+
},
|
|
1035
|
+
}).composite(composites);
|
|
1036
|
+
|
|
1037
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
1038
|
+
await encodePipeline(canvas, format, quality).toFile(outputPath);
|
|
1039
|
+
const metadataPath = metadataPathForOutput(outputPath);
|
|
1040
|
+
const reviewArtifact = {
|
|
1041
|
+
version: 1,
|
|
1042
|
+
schema: VISUAL_REVIEW_SCHEMA,
|
|
1043
|
+
generatedAt: timestamp(),
|
|
1044
|
+
mode: 'parallel-board',
|
|
1045
|
+
outputPath: toWorkspacePath(projectRoot, outputPath),
|
|
1046
|
+
format,
|
|
1047
|
+
boardSource: toWorkspacePath(projectRoot, board.sourcePath),
|
|
1048
|
+
title: String(board.payload.title ?? DEFAULT_PARALLEL_TITLE),
|
|
1049
|
+
layout: {
|
|
1050
|
+
columns,
|
|
1051
|
+
cardWidth,
|
|
1052
|
+
rowCount: rowHeights.length,
|
|
1053
|
+
},
|
|
1054
|
+
items: renderedCards.map((card) => card.item),
|
|
1055
|
+
canvas: {
|
|
1056
|
+
width: canvasWidth,
|
|
1057
|
+
height: canvasHeight,
|
|
1058
|
+
},
|
|
1059
|
+
};
|
|
1060
|
+
await fs.writeFile(metadataPath, `${JSON.stringify(reviewArtifact, null, 2)}\n`, 'utf8');
|
|
1061
|
+
|
|
1062
|
+
return {
|
|
1063
|
+
ok: true,
|
|
1064
|
+
action: 'visual-compare',
|
|
1065
|
+
mode: 'parallel-board',
|
|
1066
|
+
projectRoot,
|
|
1067
|
+
outputPath,
|
|
1068
|
+
metadataPath,
|
|
1069
|
+
format,
|
|
1070
|
+
quality: format === 'png' ? null : quality,
|
|
1071
|
+
canvas: reviewArtifact.canvas,
|
|
1072
|
+
reviewArtifact,
|
|
1073
|
+
items: reviewArtifact.items,
|
|
1074
|
+
nextActions: [
|
|
1075
|
+
'横向比较每个实验卡片,再结合指标和结论判断是否要保留多条方向继续迭代。',
|
|
1076
|
+
'如果某个方向需要局部细看,再补一张局部焦点证据板,不要只盯整体缩略图。',
|
|
1077
|
+
'并行实验证据板适合在 Agent 自验收和用户评审时一起使用,减少来回口头解释。',
|
|
1078
|
+
],
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async function visualCompareWorkspace(projectRoot, options = {}) {
|
|
1083
|
+
const request = resolveComparisonInputs(options);
|
|
1084
|
+
if (request.mode === 'board') {
|
|
1085
|
+
const board = await readBoardSpec(projectRoot, request.board);
|
|
1086
|
+
if (board.mode === 'focus-board') {
|
|
1087
|
+
return renderFocusBoard(projectRoot, board, options);
|
|
1088
|
+
}
|
|
1089
|
+
if (board.mode === 'parallel-board') {
|
|
1090
|
+
return renderParallelBoard(projectRoot, board, options);
|
|
1091
|
+
}
|
|
1092
|
+
throw new Error(`Unsupported board mode: ${board.mode}`);
|
|
1093
|
+
}
|
|
1094
|
+
return renderStandardComparison(projectRoot, request, options);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
216
1097
|
export { visualCompareWorkspace };
|