@openprd/cli 0.1.9 → 0.1.10
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 +15 -1
- package/.openprd/config.yaml +10 -0
- package/.openprd/design/README.md +47 -0
- package/.openprd/design/README_EN.md +25 -0
- package/.openprd/design/active/asset-spec.md +15 -0
- package/.openprd/design/active/direction-plan.md +7 -0
- package/.openprd/design/active/facts-sheet.md +13 -0
- package/.openprd/design/active/image-preflight.md +9 -0
- package/.openprd/design/active/selected-direction.md +11 -0
- package/.openprd/design/anti-slop.md +16 -0
- package/.openprd/design/assets/dark-mesh.svg +14 -0
- package/.openprd/design/assets/paper-grid.svg +14 -0
- package/.openprd/design/assets/surface-presets.md +21 -0
- package/.openprd/design/assets/workbench-grid.svg +16 -0
- package/.openprd/design/checklists/ui-quality-gate.md +22 -0
- package/.openprd/design/components/component-catalog.md +42 -0
- package/.openprd/design/layouts/layout-catalog.json +45 -0
- package/.openprd/design/lenses/frontend-lenses.md +31 -0
- package/.openprd/design/recipes/content-experience.md +26 -0
- package/.openprd/design/recipes/operational-tool.md +25 -0
- package/.openprd/design/recipes/product-launch.md +26 -0
- package/.openprd/design/templates/README.md +36 -0
- package/.openprd/design/templates/README_EN.md +31 -0
- package/.openprd/design/templates/content-home.html +415 -0
- package/.openprd/design/templates/ops-dashboard.html +340 -0
- package/.openprd/design/templates/product-launch.html +360 -0
- package/.openprd/design/themes/theme-catalog.json +77 -0
- package/.openprd/engagements/active/prd.md +111 -100
- package/.openprd/schema/prd.schema.yaml +10 -0
- package/.openprd/templates/base/intake.md +4 -0
- package/.openprd/templates/base/prd.md +16 -6
- package/AGENTS.md +10 -7
- package/README.md +26 -28
- package/README_EN.md +3 -3
- package/package.json +41 -2
- package/scripts/openprd-codex-isolated-worker.mjs +799 -0
- package/skills/openprd-benchmark-router/SKILL.md +4 -0
- package/skills/openprd-frontend-design/SKILL.md +161 -0
- package/skills/openprd-frontend-design/references/design-asset-contract.md +34 -0
- package/skills/openprd-frontend-design/references/direction-engine.md +46 -0
- package/skills/openprd-harness/SKILL.md +37 -20
- package/skills/openprd-learning-review/SKILL.md +2 -1
- package/skills/openprd-learning-review/references/style-packs/xianxia-cultivation.prompt.md +1 -1
- package/skills/openprd-quality/SKILL.md +5 -3
- package/skills/openprd-requirement-intake/SKILL.md +27 -5
- package/skills/openprd-requirement-intake/references/prd-template-lenses.md +6 -3
- package/skills/openprd-requirement-intake/references/routing-rubric.md +9 -5
- package/skills/openprd-requirement-intake/references/startup-validation-lens.md +23 -0
- package/skills/openprd-router/SKILL.md +13 -5
- package/skills/openprd-shared/SKILL.md +38 -25
- package/src/agent-integration.js +120 -69
- package/src/brainstorm-artifacts.js +1657 -0
- package/src/brainstorm-presentation.js +223 -0
- package/src/brainstorm.js +603 -0
- package/src/cli/args.js +51 -3
- package/src/cli/doctor-print.js +34 -8
- package/src/cli/print.js +6 -0
- package/src/cli/quality-print.js +70 -0
- package/src/cli/run-print.js +43 -1
- package/src/cli/shared-print.js +33 -2
- package/src/cli/workflow-print.js +21 -0
- package/src/codex-hook-runner-template.mjs +838 -58
- package/src/design-starter-support.js +462 -0
- package/src/design-starter.js +1207 -0
- package/src/knowledge.js +232 -20
- package/src/learning-review.js +3 -3
- package/src/loop.js +839 -101
- package/src/openprd.js +86 -1
- package/src/openspec/change-validate.js +1 -0
- package/src/prd-core.js +18 -0
- package/src/quality-learning.js +50 -6
- package/src/quality.js +2 -2
- package/src/run-harness.js +191 -29
- package/src/self-update.js +301 -4
- package/src/standards.js +46 -5
- package/src/visual-prepare.js +940 -0
- package/src/workspace-core.js +137 -0
- package/src/workspace-workflow.js +190 -7
- package/.openprd/benchmarks/evidence/milvus-io-ai-code-review-gets-better-when-models-debate-claude-vs-gemini-vs-code.md +0 -14
- package/.openprd/benchmarks/evidence/nolanlawson-com-using-ai-to-write-better-code-more-slowly.md +0 -14
- package/.openprd/discovery/config.json +0 -35
- package/.openprd/engagements/active/flows.md +0 -35
- package/.openprd/engagements/active/handoff.md +0 -16
- package/.openprd/engagements/active/review.html +0 -61
- package/.openprd/engagements/active/roles.md +0 -22
- package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +0 -23
- package/.openprd/engagements/work-units/wu-20260602113956-a99b5b88.json +0 -18
- package/.openprd/engagements/work-units/wu-20260602122244-78656aaf.json +0 -18
- package/.openprd/engagements/work-units/wu-20260602122442-e96489e2.json +0 -18
- package/.openprd/engagements/work-units/wu-20260602132835-695429e8.json +0 -18
- package/.openprd/exports/.gitkeep +0 -0
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/candidate.json +0 -78
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/diagnostic-report.json +0 -129
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/root-cause-candidates.json +0 -41
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/timeline.json +0 -14
- package/.openprd/knowledge/drafts/openprd-experience-diagnostic-candidate-turn-1780116203372-5f266a79e968c758/SKILL.md +0 -49
- package/.openprd/knowledge/index.json +0 -47
- package/.openprd/reviews/v0001.html +0 -1322
- package/.openprd/reviews/v0002.html +0 -1150
- package/.openprd/reviews/v0003.html +0 -1150
- package/.openprd/reviews/v0004.html +0 -1150
- package/.openprd/reviews/v0005.html +0 -1150
- package/.openprd/sessions/.gitkeep +0 -0
- package/.openprd/state/changes.json +0 -27
- package/.openprd/state/current.json +0 -505
- package/.openprd/state/release-ledger.json +0 -387
- package/.openprd/state/version-index.json +0 -67
- package/.openprd/state/versions/.gitkeep +0 -0
- package/.openprd/state/versions/v0001.json +0 -121
- package/.openprd/state/versions/v0001.md +0 -161
- package/.openprd/state/versions/v0002.json +0 -264
- package/.openprd/state/versions/v0002.md +0 -183
- package/.openprd/state/versions/v0003.json +0 -269
- package/.openprd/state/versions/v0003.md +0 -188
- package/.openprd/state/versions/v0004.json +0 -274
- package/.openprd/state/versions/v0004.md +0 -193
- package/.openprd/state/versions/v0005.json +0 -299
- package/.openprd/state/versions/v0005.md +0 -189
- package/docs/assets/openprd-capability-overview-en.png +0 -0
- package/docs/assets/openprd-capability-overview-zh.png +0 -0
- package/docs/assets/openprd-learning-html.png +0 -0
- package/docs/assets/openprd-quality-html.png +0 -0
- package/docs/assets/openprd-requirement-routing-en.png +0 -0
- package/docs/assets/openprd-requirement-routing-en.svg +0 -102
- 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 +0 -102
- package/docs/assets/openprd-review-html.png +0 -0
- package/docs/assets/openprd-scenario-overview.png +0 -0
- package/docs/assets/openprd-scenario-overview.svg +0 -114
- package/docs/assets/openprd-self-evolving-mechanisms-en.png +0 -0
- package/docs/assets/openprd-self-evolving-mechanisms-zh.png +0 -0
- package/docs/assets/openprd-visual-compare-case-study-en.png +0 -0
- package/docs/assets/openprd-visual-compare-case-study-zh.png +0 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 核心功能
|
|
3
|
+
* 把已确认的参考图预处理为 reference-set,包括规则切片、contact sheet 和后续视觉对比模板。
|
|
4
|
+
*
|
|
5
|
+
* 输入
|
|
6
|
+
* 接收项目根目录、参考图路径,以及 grid 或 boxes 形式的裁剪计划。
|
|
7
|
+
*
|
|
8
|
+
* 输出
|
|
9
|
+
* 在 `.openprd/harness/visual-reviews/reference-sets/<id>/` 下写入 staged source、crops、reference-set.json、contact-sheet 和 board 模板。
|
|
10
|
+
*
|
|
11
|
+
* 定位
|
|
12
|
+
* 位于视觉验收的准备层,负责“整板 / 多子图 / 网格图”到可比对 reference-set 的确定性几何处理。
|
|
13
|
+
*
|
|
14
|
+
* 依赖
|
|
15
|
+
* 依赖 `sharp` 做图片解码、裁剪和合成,依赖 `time` 生成稳定时间戳。
|
|
16
|
+
*
|
|
17
|
+
* 维护规则
|
|
18
|
+
* 只做确定性裁剪、登记和模板生成;不要在这里引入无法审计的自动识别或主观视觉判断。
|
|
19
|
+
*/
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import fs from 'node:fs/promises';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import sharp from 'sharp';
|
|
24
|
+
import { compactTimestamp, timestamp } from './time.js';
|
|
25
|
+
|
|
26
|
+
const REFERENCE_SET_SCHEMA = 'openprd.reference-set.v1';
|
|
27
|
+
const VISUAL_PREPARE_PLAN_SCHEMA = 'openprd.visual-prepare.plan.v1';
|
|
28
|
+
const DEFAULT_REFERENCE_SET_DIR = path.join('.openprd', 'harness', 'visual-reviews', 'reference-sets');
|
|
29
|
+
const DEFAULT_SOURCE_FILENAME = 'source.png';
|
|
30
|
+
const DEFAULT_CONTACT_SHEET_FILENAME = 'contact-sheet.jpg';
|
|
31
|
+
const DEFAULT_REFERENCE_SET_FILENAME = 'reference-set.json';
|
|
32
|
+
const DEFAULT_COMPARE_PLAN_FILENAME = 'compare-plan.json';
|
|
33
|
+
const DEFAULT_FOCUS_BOARD_TEMPLATE = 'focus-board.template.json';
|
|
34
|
+
const DEFAULT_PARALLEL_BOARD_TEMPLATE = 'parallel-board.template.json';
|
|
35
|
+
const DEFAULT_IMPLEMENTATION_PLACEHOLDER = '__REPLACE_WITH_IMPLEMENTATION_SCREENSHOT__';
|
|
36
|
+
const DEFAULT_OVERVIEW_WIDTH = 960;
|
|
37
|
+
const DEFAULT_THUMB_WIDTH = 260;
|
|
38
|
+
const DEFAULT_CARD_COLUMNS = 3;
|
|
39
|
+
const FOCUS_COLORS = ['#f97316', '#22c55e', '#38bdf8', '#eab308', '#fb7185', '#a78bfa'];
|
|
40
|
+
|
|
41
|
+
function normalizeWorkspacePath(value) {
|
|
42
|
+
return String(value ?? '').split(path.sep).join('/');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toWorkspacePath(projectRoot, filePath) {
|
|
46
|
+
const absolutePath = path.resolve(filePath);
|
|
47
|
+
const relativePath = path.relative(projectRoot, absolutePath);
|
|
48
|
+
if (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
|
|
49
|
+
return normalizeWorkspacePath(relativePath);
|
|
50
|
+
}
|
|
51
|
+
return absolutePath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveProjectPath(projectRoot, filePath) {
|
|
55
|
+
return path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function slugify(value) {
|
|
59
|
+
return String(value ?? '')
|
|
60
|
+
.trim()
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
63
|
+
.replace(/^-+|-+$/g, '') || 'reference-set';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sanitizeItemId(value, fallback) {
|
|
67
|
+
const raw = String(value ?? '').trim();
|
|
68
|
+
if (!raw) {
|
|
69
|
+
return fallback;
|
|
70
|
+
}
|
|
71
|
+
const normalized = raw.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
72
|
+
return normalized || fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sha256Buffer(buffer) {
|
|
76
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function sha256File(filePath) {
|
|
80
|
+
const buffer = await fs.readFile(filePath);
|
|
81
|
+
return sha256Buffer(buffer);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function escapeXml(value) {
|
|
85
|
+
return String(value)
|
|
86
|
+
.replaceAll('&', '&')
|
|
87
|
+
.replaceAll('<', '<')
|
|
88
|
+
.replaceAll('>', '>')
|
|
89
|
+
.replaceAll('"', '"')
|
|
90
|
+
.replaceAll("'", ''');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function charCount(value) {
|
|
94
|
+
return Array.from(String(value ?? '')).length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function wrapText(value, maxCharsPerLine) {
|
|
98
|
+
const lines = [];
|
|
99
|
+
const normalized = String(value ?? '').trim();
|
|
100
|
+
if (!normalized) {
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
for (const rawLine of normalized.split(/\r?\n/u)) {
|
|
104
|
+
const line = rawLine.trim();
|
|
105
|
+
if (!line) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let buffer = '';
|
|
109
|
+
for (const char of Array.from(line)) {
|
|
110
|
+
if (charCount(buffer) >= maxCharsPerLine) {
|
|
111
|
+
lines.push(buffer);
|
|
112
|
+
buffer = '';
|
|
113
|
+
}
|
|
114
|
+
buffer += char;
|
|
115
|
+
}
|
|
116
|
+
if (buffer) {
|
|
117
|
+
lines.push(buffer);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return lines;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function lineSvg(lines, {
|
|
124
|
+
x = 0,
|
|
125
|
+
y = 0,
|
|
126
|
+
lineHeight = 24,
|
|
127
|
+
fontSize = 18,
|
|
128
|
+
fill = '#e5e7eb',
|
|
129
|
+
fontWeight = 500,
|
|
130
|
+
} = {}) {
|
|
131
|
+
return lines.map((line, index) => (
|
|
132
|
+
`<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>`
|
|
133
|
+
)).join('');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function labelSvg(label, options = {}) {
|
|
137
|
+
const text = escapeXml(label);
|
|
138
|
+
const fontSize = options.fontSize ?? 22;
|
|
139
|
+
const height = options.height ?? 46;
|
|
140
|
+
const paddingX = options.paddingX ?? 21;
|
|
141
|
+
const radius = options.radius ?? 14;
|
|
142
|
+
const bg = options.background ?? '#111827';
|
|
143
|
+
const bgOpacity = options.backgroundOpacity ?? 0.82;
|
|
144
|
+
const stroke = options.stroke ?? '#ffffff';
|
|
145
|
+
const strokeOpacity = options.strokeOpacity ?? 0.22;
|
|
146
|
+
const width = Math.max(options.minWidth ?? 126, charCount(label) * (fontSize + 4) + paddingX * 2);
|
|
147
|
+
return Buffer.from(`
|
|
148
|
+
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
149
|
+
<rect x="0" y="0" width="${width}" height="${height}" rx="${radius}" fill="${bg}" fill-opacity="${bgOpacity}"/>
|
|
150
|
+
<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"/>
|
|
151
|
+
<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>
|
|
152
|
+
</svg>`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function titleBlockSvg(width, title, subtitle, eyebrow = null) {
|
|
156
|
+
const contentWidth = Math.max(Number(width) || 0, 1);
|
|
157
|
+
const titleLines = wrapText(title, Math.max(12, Math.floor((contentWidth - 48) / 18)));
|
|
158
|
+
const subtitleLines = wrapText(subtitle, Math.max(16, Math.floor((contentWidth - 48) / 16)));
|
|
159
|
+
const eyebrowLines = wrapText(eyebrow, Math.max(18, Math.floor((contentWidth - 48) / 18)));
|
|
160
|
+
let y = eyebrowLines.length > 0 ? 28 : 0;
|
|
161
|
+
const parts = [];
|
|
162
|
+
if (eyebrowLines.length > 0) {
|
|
163
|
+
parts.push(lineSvg(eyebrowLines, {
|
|
164
|
+
x: 0,
|
|
165
|
+
y,
|
|
166
|
+
lineHeight: 20,
|
|
167
|
+
fontSize: 16,
|
|
168
|
+
fill: '#93c5fd',
|
|
169
|
+
fontWeight: 700,
|
|
170
|
+
}));
|
|
171
|
+
y += eyebrowLines.length * 20 + 14;
|
|
172
|
+
}
|
|
173
|
+
parts.push(lineSvg(titleLines, {
|
|
174
|
+
x: 0,
|
|
175
|
+
y: y + 30,
|
|
176
|
+
lineHeight: 34,
|
|
177
|
+
fontSize: 30,
|
|
178
|
+
fill: '#f8fafc',
|
|
179
|
+
fontWeight: 800,
|
|
180
|
+
}));
|
|
181
|
+
y += titleLines.length * 34 + 10;
|
|
182
|
+
if (subtitleLines.length > 0) {
|
|
183
|
+
parts.push(lineSvg(subtitleLines, {
|
|
184
|
+
x: 0,
|
|
185
|
+
y: y + 24,
|
|
186
|
+
lineHeight: 24,
|
|
187
|
+
fontSize: 18,
|
|
188
|
+
fill: '#cbd5e1',
|
|
189
|
+
fontWeight: 500,
|
|
190
|
+
}));
|
|
191
|
+
y += subtitleLines.length * 24 + 6;
|
|
192
|
+
}
|
|
193
|
+
const height = Math.max(72, y + 16);
|
|
194
|
+
return {
|
|
195
|
+
height,
|
|
196
|
+
input: Buffer.from(`
|
|
197
|
+
<svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
198
|
+
${parts.join('')}
|
|
199
|
+
</svg>`),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function sectionHeaderSvg(width, index, label, reason = '') {
|
|
204
|
+
const contentWidth = Math.max(Number(width) || 0, 1);
|
|
205
|
+
const titleLines = wrapText(`${index}. ${label}`, Math.max(10, Math.floor((contentWidth - 48) / 18)));
|
|
206
|
+
const reasonLines = wrapText(reason, Math.max(14, Math.floor((contentWidth - 48) / 16)));
|
|
207
|
+
const height = 32 + titleLines.length * 28 + (reasonLines.length > 0 ? 10 + reasonLines.length * 22 : 0);
|
|
208
|
+
return {
|
|
209
|
+
height,
|
|
210
|
+
input: Buffer.from(`
|
|
211
|
+
<svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
212
|
+
<rect x="0" y="0" width="${contentWidth}" height="${height}" rx="18" fill="#0f172a" fill-opacity="0.92"/>
|
|
213
|
+
<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"/>
|
|
214
|
+
${lineSvg(titleLines, {
|
|
215
|
+
x: 20,
|
|
216
|
+
y: 30,
|
|
217
|
+
lineHeight: 28,
|
|
218
|
+
fontSize: 24,
|
|
219
|
+
fill: '#f8fafc',
|
|
220
|
+
fontWeight: 800,
|
|
221
|
+
})}
|
|
222
|
+
${reasonLines.length > 0 ? lineSvg(reasonLines, {
|
|
223
|
+
x: 20,
|
|
224
|
+
y: 30 + titleLines.length * 28 + 10,
|
|
225
|
+
lineHeight: 22,
|
|
226
|
+
fontSize: 16,
|
|
227
|
+
fill: '#cbd5e1',
|
|
228
|
+
fontWeight: 500,
|
|
229
|
+
}) : ''}
|
|
230
|
+
</svg>`),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function metricsSvg(width, metrics = [], notes = null) {
|
|
235
|
+
const contentWidth = Math.max(Number(width) || 0, 1);
|
|
236
|
+
const lines = [];
|
|
237
|
+
for (const metric of metrics) {
|
|
238
|
+
lines.push(`${metric.label}:${metric.value}`);
|
|
239
|
+
}
|
|
240
|
+
if (notes) {
|
|
241
|
+
lines.push(...wrapText(notes, Math.max(12, Math.floor((contentWidth - 24) / 16))));
|
|
242
|
+
}
|
|
243
|
+
if (lines.length === 0) {
|
|
244
|
+
return { height: 0, input: null };
|
|
245
|
+
}
|
|
246
|
+
const height = 18 + lines.length * 22;
|
|
247
|
+
return {
|
|
248
|
+
height,
|
|
249
|
+
input: Buffer.from(`
|
|
250
|
+
<svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
251
|
+
${lineSvg(lines, {
|
|
252
|
+
x: 0,
|
|
253
|
+
y: 20,
|
|
254
|
+
lineHeight: 22,
|
|
255
|
+
fontSize: 16,
|
|
256
|
+
fill: '#cbd5e1',
|
|
257
|
+
fontWeight: 500,
|
|
258
|
+
})}
|
|
259
|
+
</svg>`),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function resizePanel(inputPath, panelWidth) {
|
|
264
|
+
const source = path.resolve(inputPath);
|
|
265
|
+
const metadata = await sharp(source).metadata();
|
|
266
|
+
if (!metadata.width || !metadata.height) {
|
|
267
|
+
throw new Error(`Cannot read image dimensions: ${inputPath}`);
|
|
268
|
+
}
|
|
269
|
+
const { data, info } = await sharp(source)
|
|
270
|
+
.rotate()
|
|
271
|
+
.resize({
|
|
272
|
+
width: panelWidth,
|
|
273
|
+
fit: 'inside',
|
|
274
|
+
withoutEnlargement: true,
|
|
275
|
+
})
|
|
276
|
+
.png()
|
|
277
|
+
.toBuffer({ resolveWithObject: true });
|
|
278
|
+
return {
|
|
279
|
+
input: data,
|
|
280
|
+
width: info.width,
|
|
281
|
+
height: info.height,
|
|
282
|
+
source,
|
|
283
|
+
original: {
|
|
284
|
+
width: metadata.width,
|
|
285
|
+
height: metadata.height,
|
|
286
|
+
format: metadata.format ?? null,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function parseCropDimension(box, key, alias) {
|
|
292
|
+
const primary = box[key];
|
|
293
|
+
const secondary = alias ? box[alias] : undefined;
|
|
294
|
+
const value = primary ?? secondary;
|
|
295
|
+
if (value === null || value === undefined || value === '') {
|
|
296
|
+
throw new Error(`Missing ${key} in crop box.`);
|
|
297
|
+
}
|
|
298
|
+
const parsed = Number(value);
|
|
299
|
+
if (!Number.isFinite(parsed)) {
|
|
300
|
+
throw new Error(`Invalid ${key} in crop box.`);
|
|
301
|
+
}
|
|
302
|
+
return parsed;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function clampBox(box, original) {
|
|
306
|
+
const x = Math.max(0, Math.min(box.x, original.width - 1));
|
|
307
|
+
const y = Math.max(0, Math.min(box.y, original.height - 1));
|
|
308
|
+
const width = Math.max(1, Math.min(box.width, original.width - x));
|
|
309
|
+
const height = Math.max(1, Math.min(box.height, original.height - y));
|
|
310
|
+
return {
|
|
311
|
+
x,
|
|
312
|
+
y,
|
|
313
|
+
width,
|
|
314
|
+
height,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function resolveCropBox(box, original) {
|
|
319
|
+
if (!box || typeof box !== 'object') {
|
|
320
|
+
throw new Error('Crop box must be an object.');
|
|
321
|
+
}
|
|
322
|
+
const rawUnit = String(box.unit ?? 'ratio').trim().toLowerCase();
|
|
323
|
+
const x = parseCropDimension(box, 'x', 'left');
|
|
324
|
+
const y = parseCropDimension(box, 'y', 'top');
|
|
325
|
+
const width = parseCropDimension(box, 'width', 'w');
|
|
326
|
+
const height = parseCropDimension(box, 'height', 'h');
|
|
327
|
+
let absolute;
|
|
328
|
+
|
|
329
|
+
if (rawUnit === 'ratio') {
|
|
330
|
+
absolute = {
|
|
331
|
+
x: Math.round(x * original.width),
|
|
332
|
+
y: Math.round(y * original.height),
|
|
333
|
+
width: Math.round(width * original.width),
|
|
334
|
+
height: Math.round(height * original.height),
|
|
335
|
+
};
|
|
336
|
+
} else if (rawUnit === 'percent') {
|
|
337
|
+
absolute = {
|
|
338
|
+
x: Math.round((x / 100) * original.width),
|
|
339
|
+
y: Math.round((y / 100) * original.height),
|
|
340
|
+
width: Math.round((width / 100) * original.width),
|
|
341
|
+
height: Math.round((height / 100) * original.height),
|
|
342
|
+
};
|
|
343
|
+
} else if (rawUnit === 'px' || rawUnit === 'pixel' || rawUnit === 'pixels') {
|
|
344
|
+
absolute = {
|
|
345
|
+
x: Math.round(x),
|
|
346
|
+
y: Math.round(y),
|
|
347
|
+
width: Math.round(width),
|
|
348
|
+
height: Math.round(height),
|
|
349
|
+
};
|
|
350
|
+
} else {
|
|
351
|
+
throw new Error(`Unsupported crop unit: ${rawUnit}. Use ratio, percent, or px.`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
unit: rawUnit === 'pixel' || rawUnit === 'pixels' ? 'px' : rawUnit,
|
|
356
|
+
requested: { x, y, width, height },
|
|
357
|
+
absolute: clampBox(absolute, original),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function renderedBoxFromAbsolute(box, panel) {
|
|
362
|
+
const scaleX = panel.width / panel.original.width;
|
|
363
|
+
const scaleY = panel.height / panel.original.height;
|
|
364
|
+
return {
|
|
365
|
+
x: Math.round(box.x * scaleX),
|
|
366
|
+
y: Math.round(box.y * scaleY),
|
|
367
|
+
width: Math.max(2, Math.round(box.width * scaleX)),
|
|
368
|
+
height: Math.max(2, Math.round(box.height * scaleY)),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function absoluteToRatio(box, original) {
|
|
373
|
+
return {
|
|
374
|
+
unit: 'ratio',
|
|
375
|
+
x: Number((box.x / original.width).toFixed(6)),
|
|
376
|
+
y: Number((box.y / original.height).toFixed(6)),
|
|
377
|
+
width: Number((box.width / original.width).toFixed(6)),
|
|
378
|
+
height: Number((box.height / original.height).toFixed(6)),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function focusOverlaySvg(width, height, regions) {
|
|
383
|
+
const overlays = regions.map((region, index) => {
|
|
384
|
+
const color = region.color;
|
|
385
|
+
const box = region.box;
|
|
386
|
+
const badgeSize = 32;
|
|
387
|
+
const badgeX = Math.max(10, box.x + 10);
|
|
388
|
+
const badgeY = Math.max(10, box.y + 10);
|
|
389
|
+
return `
|
|
390
|
+
<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="16" fill="none" stroke="${color}" stroke-width="4"/>
|
|
391
|
+
<rect x="${badgeX}" y="${badgeY}" width="${badgeSize}" height="${badgeSize}" rx="16" fill="${color}"/>
|
|
392
|
+
<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>
|
|
393
|
+
`;
|
|
394
|
+
}).join('');
|
|
395
|
+
return Buffer.from(`
|
|
396
|
+
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
397
|
+
${overlays}
|
|
398
|
+
</svg>`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function parseGridSpec(value) {
|
|
402
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
403
|
+
const match = normalized.match(/^(\d+)\s*x\s*(\d+)$/u);
|
|
404
|
+
if (!match) {
|
|
405
|
+
throw new Error(`Invalid --grid value: ${value}. Use <columns>x<rows>, for example 2x5.`);
|
|
406
|
+
}
|
|
407
|
+
const columns = Number.parseInt(match[1], 10);
|
|
408
|
+
const rows = Number.parseInt(match[2], 10);
|
|
409
|
+
if (columns <= 0 || rows <= 0) {
|
|
410
|
+
throw new Error('--grid requires positive columns and rows.');
|
|
411
|
+
}
|
|
412
|
+
return { columns, rows };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function zeroPadWidth(count) {
|
|
416
|
+
return Math.max(2, String(count).length);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function buildGridItems(original, grid, options = {}) {
|
|
420
|
+
const total = grid.columns * grid.rows;
|
|
421
|
+
const width = zeroPadWidth(total);
|
|
422
|
+
const items = [];
|
|
423
|
+
for (let row = 0; row < grid.rows; row += 1) {
|
|
424
|
+
for (let column = 0; column < grid.columns; column += 1) {
|
|
425
|
+
const order = items.length + 1;
|
|
426
|
+
const id = String(order).padStart(width, '0');
|
|
427
|
+
const left = Math.round((column / grid.columns) * original.width);
|
|
428
|
+
const right = Math.round(((column + 1) / grid.columns) * original.width);
|
|
429
|
+
const top = Math.round((row / grid.rows) * original.height);
|
|
430
|
+
const bottom = Math.round(((row + 1) / grid.rows) * original.height);
|
|
431
|
+
const absolute = clampBox({
|
|
432
|
+
x: left,
|
|
433
|
+
y: top,
|
|
434
|
+
width: Math.max(1, right - left),
|
|
435
|
+
height: Math.max(1, bottom - top),
|
|
436
|
+
}, original);
|
|
437
|
+
items.push({
|
|
438
|
+
order,
|
|
439
|
+
id,
|
|
440
|
+
label: `${options.labelPrefix ?? '对象'} ${id}`,
|
|
441
|
+
note: `${column + 1}/${grid.columns} 列 · ${row + 1}/${grid.rows} 行`,
|
|
442
|
+
cropBox: absoluteToRatio(absolute, original),
|
|
443
|
+
absoluteBox: absolute,
|
|
444
|
+
requestedBox: absoluteToRatio(absolute, original),
|
|
445
|
+
requestedUnit: 'ratio',
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return items;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function resolveRawBox(raw) {
|
|
453
|
+
if (!raw || typeof raw !== 'object') {
|
|
454
|
+
throw new Error('Invalid boxes entry.');
|
|
455
|
+
}
|
|
456
|
+
if (raw.box && typeof raw.box === 'object') {
|
|
457
|
+
return raw.box;
|
|
458
|
+
}
|
|
459
|
+
if (raw.crop && typeof raw.crop === 'object') {
|
|
460
|
+
return raw.crop;
|
|
461
|
+
}
|
|
462
|
+
if (raw.region && typeof raw.region === 'object') {
|
|
463
|
+
return raw.region;
|
|
464
|
+
}
|
|
465
|
+
return raw;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function loadBoxItems(projectRoot, boxesPath, original) {
|
|
469
|
+
const sourcePath = resolveProjectPath(projectRoot, boxesPath);
|
|
470
|
+
const payload = JSON.parse(await fs.readFile(sourcePath, 'utf8'));
|
|
471
|
+
const container = Array.isArray(payload) ? { items: payload } : payload;
|
|
472
|
+
const rawItems = container.items ?? container.boxes ?? container.regions ?? container.cells ?? container.slices;
|
|
473
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0) {
|
|
474
|
+
throw new Error(`Box plan in ${boxesPath} must provide a non-empty items/boxes/regions array.`);
|
|
475
|
+
}
|
|
476
|
+
const width = zeroPadWidth(rawItems.length);
|
|
477
|
+
const items = rawItems.map((raw, index) => {
|
|
478
|
+
const order = index + 1;
|
|
479
|
+
const fallbackId = String(order).padStart(width, '0');
|
|
480
|
+
const resolved = resolveCropBox(resolveRawBox(raw), original);
|
|
481
|
+
const absolute = resolved.absolute;
|
|
482
|
+
const cropBox = absoluteToRatio(absolute, original);
|
|
483
|
+
const label = String(raw.label ?? raw.name ?? raw.title ?? fallbackId);
|
|
484
|
+
return {
|
|
485
|
+
order,
|
|
486
|
+
id: sanitizeItemId(raw.id, fallbackId),
|
|
487
|
+
label,
|
|
488
|
+
note: String(raw.note ?? raw.reason ?? '').trim(),
|
|
489
|
+
cropBox,
|
|
490
|
+
absoluteBox: absolute,
|
|
491
|
+
requestedBox: resolved.requested,
|
|
492
|
+
requestedUnit: resolved.unit,
|
|
493
|
+
};
|
|
494
|
+
});
|
|
495
|
+
return {
|
|
496
|
+
sourcePath,
|
|
497
|
+
title: typeof container.title === 'string' ? container.title.trim() : '',
|
|
498
|
+
summary: typeof container.summary === 'string' ? container.summary.trim() : '',
|
|
499
|
+
items,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function parseIncludeTokens(include) {
|
|
504
|
+
return new Set(
|
|
505
|
+
String(include ?? '')
|
|
506
|
+
.split(',')
|
|
507
|
+
.map((token) => token.trim().toLowerCase())
|
|
508
|
+
.filter(Boolean),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function filterItemsByInclude(items, include) {
|
|
513
|
+
if (!include) {
|
|
514
|
+
return items;
|
|
515
|
+
}
|
|
516
|
+
const tokens = parseIncludeTokens(include);
|
|
517
|
+
const filtered = items.filter((item) => (
|
|
518
|
+
tokens.has(String(item.order).toLowerCase())
|
|
519
|
+
|| tokens.has(String(item.id).toLowerCase())
|
|
520
|
+
|| tokens.has(String(item.label).toLowerCase())
|
|
521
|
+
));
|
|
522
|
+
if (filtered.length === 0) {
|
|
523
|
+
throw new Error(`--include did not match any crop item. Available ids: ${items.map((item) => item.id).join(', ')}`);
|
|
524
|
+
}
|
|
525
|
+
return filtered;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function ensureUniqueItemIds(items) {
|
|
529
|
+
const seen = new Set();
|
|
530
|
+
for (const item of items) {
|
|
531
|
+
const key = String(item.id);
|
|
532
|
+
if (seen.has(key)) {
|
|
533
|
+
throw new Error(`Duplicate crop id detected: ${item.id}`);
|
|
534
|
+
}
|
|
535
|
+
seen.add(key);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function stageReferenceSource(referencePath, outputPath) {
|
|
540
|
+
const sourcePath = path.resolve(referencePath);
|
|
541
|
+
const sourceMetadata = await sharp(sourcePath).metadata();
|
|
542
|
+
if (!sourceMetadata.width || !sourceMetadata.height) {
|
|
543
|
+
throw new Error(`Cannot read image dimensions: ${referencePath}`);
|
|
544
|
+
}
|
|
545
|
+
const { data, info } = await sharp(sourcePath)
|
|
546
|
+
.rotate()
|
|
547
|
+
.png()
|
|
548
|
+
.toBuffer({ resolveWithObject: true });
|
|
549
|
+
await fs.writeFile(outputPath, data);
|
|
550
|
+
return {
|
|
551
|
+
originalPath: sourcePath,
|
|
552
|
+
originalSha256: await sha256File(sourcePath),
|
|
553
|
+
originalMetadata: {
|
|
554
|
+
width: sourceMetadata.width,
|
|
555
|
+
height: sourceMetadata.height,
|
|
556
|
+
format: sourceMetadata.format ?? null,
|
|
557
|
+
},
|
|
558
|
+
stagedPath: outputPath,
|
|
559
|
+
stagedSha256: sha256Buffer(data),
|
|
560
|
+
stagedMetadata: {
|
|
561
|
+
width: info.width,
|
|
562
|
+
height: info.height,
|
|
563
|
+
format: info.format,
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function writeCrop(sourcePath, box, outputPath) {
|
|
569
|
+
const { data, info } = await sharp(sourcePath)
|
|
570
|
+
.extract({
|
|
571
|
+
left: box.x,
|
|
572
|
+
top: box.y,
|
|
573
|
+
width: box.width,
|
|
574
|
+
height: box.height,
|
|
575
|
+
})
|
|
576
|
+
.png()
|
|
577
|
+
.toBuffer({ resolveWithObject: true });
|
|
578
|
+
await fs.writeFile(outputPath, data);
|
|
579
|
+
return {
|
|
580
|
+
path: outputPath,
|
|
581
|
+
sha256: sha256Buffer(data),
|
|
582
|
+
metadata: {
|
|
583
|
+
width: info.width,
|
|
584
|
+
height: info.height,
|
|
585
|
+
format: info.format,
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function renderReferenceCard(projectRoot, item, index, options = {}) {
|
|
591
|
+
const cardWidth = options.cardWidth;
|
|
592
|
+
const contentWidth = cardWidth - 36;
|
|
593
|
+
const header = sectionHeaderSvg(
|
|
594
|
+
contentWidth,
|
|
595
|
+
index + 1,
|
|
596
|
+
`${item.id} · ${item.label}`,
|
|
597
|
+
item.note || `裁剪 ${item.crop.metadata.width}x${item.crop.metadata.height}px`,
|
|
598
|
+
);
|
|
599
|
+
const composites = [
|
|
600
|
+
{ input: header.input, left: 18, top: 18 },
|
|
601
|
+
];
|
|
602
|
+
const panel = await resizePanel(item.crop.path, Math.min(DEFAULT_THUMB_WIDTH, contentWidth));
|
|
603
|
+
const panelTop = 18 + header.height + 12;
|
|
604
|
+
composites.push(
|
|
605
|
+
{ input: panel.input, left: 18 + Math.round((contentWidth - panel.width) / 2), top: panelTop },
|
|
606
|
+
{ input: labelSvg(`参考 ${item.id}`, { fontSize: 16, height: 36, minWidth: 96, paddingX: 16, radius: 12 }), left: 30, top: panelTop + 12 },
|
|
607
|
+
);
|
|
608
|
+
const metrics = metricsSvg(contentWidth, [
|
|
609
|
+
{ label: '裁剪', value: `${item.crop.metadata.width}x${item.crop.metadata.height}px` },
|
|
610
|
+
{ label: '位置', value: `${item.absoluteBox.x},${item.absoluteBox.y},${item.absoluteBox.width},${item.absoluteBox.height}` },
|
|
611
|
+
]);
|
|
612
|
+
let currentTop = panelTop + panel.height + 16;
|
|
613
|
+
if (metrics.input) {
|
|
614
|
+
composites.push({ input: metrics.input, left: 18, top: currentTop });
|
|
615
|
+
currentTop += metrics.height + 12;
|
|
616
|
+
}
|
|
617
|
+
const cardHeight = currentTop + 18;
|
|
618
|
+
const card = sharp({
|
|
619
|
+
create: {
|
|
620
|
+
width: cardWidth,
|
|
621
|
+
height: cardHeight,
|
|
622
|
+
channels: 3,
|
|
623
|
+
background: '#0f172a',
|
|
624
|
+
},
|
|
625
|
+
}).composite([
|
|
626
|
+
...composites,
|
|
627
|
+
{
|
|
628
|
+
input: Buffer.from(`
|
|
629
|
+
<svg width="${cardWidth}" height="${cardHeight}" viewBox="0 0 ${cardWidth} ${cardHeight}" xmlns="http://www.w3.org/2000/svg">
|
|
630
|
+
<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"/>
|
|
631
|
+
</svg>`),
|
|
632
|
+
left: 0,
|
|
633
|
+
top: 0,
|
|
634
|
+
},
|
|
635
|
+
]);
|
|
636
|
+
return {
|
|
637
|
+
image: await card.png().toBuffer(),
|
|
638
|
+
width: cardWidth,
|
|
639
|
+
height: cardHeight,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function renderContactSheet(projectRoot, sourcePath, items, outputPath, options = {}) {
|
|
644
|
+
const overview = await resizePanel(sourcePath, DEFAULT_OVERVIEW_WIDTH);
|
|
645
|
+
const margin = 24;
|
|
646
|
+
const gap = 24;
|
|
647
|
+
const columns = Math.max(1, Math.min(DEFAULT_CARD_COLUMNS, items.length));
|
|
648
|
+
const cardWidth = 320;
|
|
649
|
+
const contentWidth = Math.max(overview.width, columns * cardWidth + (columns - 1) * gap);
|
|
650
|
+
const titleBlock = titleBlockSvg(
|
|
651
|
+
contentWidth,
|
|
652
|
+
options.title ?? '参考图预处理',
|
|
653
|
+
options.summary ?? `先检查整板编号和每个裁剪块是否完整,再决定哪些对象纳入后续视觉对比。当前共 ${items.length} 个参考对象。`,
|
|
654
|
+
'视觉验收 / Reference Set',
|
|
655
|
+
);
|
|
656
|
+
const overviewTop = margin + titleBlock.height + 18;
|
|
657
|
+
const overviewLeft = margin + Math.round((contentWidth - overview.width) / 2);
|
|
658
|
+
const overlayRegions = items.map((item, index) => ({
|
|
659
|
+
color: FOCUS_COLORS[index % FOCUS_COLORS.length],
|
|
660
|
+
box: renderedBoxFromAbsolute(item.absoluteBox, overview),
|
|
661
|
+
}));
|
|
662
|
+
const renderedCards = [];
|
|
663
|
+
for (const [index, item] of items.entries()) {
|
|
664
|
+
renderedCards.push(await renderReferenceCard(projectRoot, item, index, { cardWidth }));
|
|
665
|
+
}
|
|
666
|
+
const rowHeights = [];
|
|
667
|
+
for (let index = 0; index < renderedCards.length; index += columns) {
|
|
668
|
+
rowHeights.push(Math.max(...renderedCards.slice(index, index + columns).map((card) => card.height)));
|
|
669
|
+
}
|
|
670
|
+
const overviewBottom = overviewTop + overview.height;
|
|
671
|
+
const cardsTop = overviewBottom + 28;
|
|
672
|
+
const cardsHeight = rowHeights.reduce((sum, value) => sum + value, 0) + Math.max(0, rowHeights.length - 1) * gap;
|
|
673
|
+
const canvasWidth = contentWidth + margin * 2;
|
|
674
|
+
const canvasHeight = cardsTop + cardsHeight + margin;
|
|
675
|
+
const composites = [
|
|
676
|
+
{ input: titleBlock.input, left: margin, top: margin },
|
|
677
|
+
{ input: overview.input, left: overviewLeft, top: overviewTop },
|
|
678
|
+
{ input: labelSvg('参考整板'), left: overviewLeft + 16, top: overviewTop + 16 },
|
|
679
|
+
{ input: focusOverlaySvg(overview.width, overview.height, overlayRegions), left: overviewLeft, top: overviewTop },
|
|
680
|
+
];
|
|
681
|
+
let currentTop = cardsTop;
|
|
682
|
+
for (let row = 0; row < rowHeights.length; row += 1) {
|
|
683
|
+
const rowCards = renderedCards.slice(row * columns, row * columns + columns);
|
|
684
|
+
const rowWidth = rowCards.length * cardWidth + Math.max(0, rowCards.length - 1) * gap;
|
|
685
|
+
const rowLeft = margin + Math.round((contentWidth - rowWidth) / 2);
|
|
686
|
+
for (const [column, card] of rowCards.entries()) {
|
|
687
|
+
composites.push({
|
|
688
|
+
input: card.image,
|
|
689
|
+
left: rowLeft + column * (cardWidth + gap),
|
|
690
|
+
top: currentTop,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
currentTop += rowHeights[row] + gap;
|
|
694
|
+
}
|
|
695
|
+
const canvas = sharp({
|
|
696
|
+
create: {
|
|
697
|
+
width: canvasWidth,
|
|
698
|
+
height: canvasHeight,
|
|
699
|
+
channels: 3,
|
|
700
|
+
background: '#111827',
|
|
701
|
+
},
|
|
702
|
+
}).composite(composites);
|
|
703
|
+
await canvas.jpeg({ quality: 85 }).toFile(outputPath);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function buildFocusBoardTemplate(paths, title, items) {
|
|
707
|
+
return {
|
|
708
|
+
mode: 'focus-board',
|
|
709
|
+
title: `${title} 局部焦点模板`,
|
|
710
|
+
summary: '把 right.path 替换成实现截图;如果实现截图布局与参考图不同,再分别调整 rightBox。',
|
|
711
|
+
left: {
|
|
712
|
+
path: paths.source,
|
|
713
|
+
label: '参考整板',
|
|
714
|
+
},
|
|
715
|
+
right: {
|
|
716
|
+
path: DEFAULT_IMPLEMENTATION_PLACEHOLDER,
|
|
717
|
+
label: '实现截图',
|
|
718
|
+
},
|
|
719
|
+
focusRegions: items.map((item) => ({
|
|
720
|
+
label: item.label,
|
|
721
|
+
reason: item.note || `由 reference-set ${item.id} 自动生成,可按需微调 rightBox。`,
|
|
722
|
+
leftBox: item.cropBox,
|
|
723
|
+
rightBox: item.cropBox,
|
|
724
|
+
})),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function buildParallelBoardTemplate(title, items) {
|
|
729
|
+
return {
|
|
730
|
+
mode: 'parallel-board',
|
|
731
|
+
title: `${title} 参考映射模板`,
|
|
732
|
+
summary: '每张卡先挂参考 crop。要做多对象、多方向或多页面统一验收时,在对应卡片里继续补实现截图、指标或备注。',
|
|
733
|
+
columns: Math.max(1, Math.min(DEFAULT_CARD_COLUMNS, items.length)),
|
|
734
|
+
cardWidth: 360,
|
|
735
|
+
items: items.map((item) => ({
|
|
736
|
+
label: item.label,
|
|
737
|
+
subtitle: `参考对象 ${item.id}`,
|
|
738
|
+
verdict: '待补实现截图',
|
|
739
|
+
media: [
|
|
740
|
+
{ path: item.crop.workspacePath, label: '参考 crop' },
|
|
741
|
+
],
|
|
742
|
+
notes: '需要时可继续补实现截图、指标或阶段性结论。',
|
|
743
|
+
})),
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function buildComparePlan(referenceSetPath, title, items, artifacts) {
|
|
748
|
+
return {
|
|
749
|
+
version: 1,
|
|
750
|
+
schema: VISUAL_PREPARE_PLAN_SCHEMA,
|
|
751
|
+
generatedAt: timestamp(),
|
|
752
|
+
title: `${title} 对比计划`,
|
|
753
|
+
referenceSet: referenceSetPath,
|
|
754
|
+
artifacts,
|
|
755
|
+
items: items.map((item) => ({
|
|
756
|
+
id: item.id,
|
|
757
|
+
label: item.label,
|
|
758
|
+
reference: item.crop.workspacePath,
|
|
759
|
+
suggestedActual: DEFAULT_IMPLEMENTATION_PLACEHOLDER,
|
|
760
|
+
suggestedCommand: `openprd visual-compare . --reference ${item.crop.workspacePath} --actual ${DEFAULT_IMPLEMENTATION_PLACEHOLDER}`,
|
|
761
|
+
})),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function inferReferenceTitle(referencePath, explicitTitle) {
|
|
766
|
+
if (explicitTitle) {
|
|
767
|
+
return String(explicitTitle).trim();
|
|
768
|
+
}
|
|
769
|
+
const base = path.basename(referencePath, path.extname(referencePath)).replace(/[-_]+/g, ' ').trim();
|
|
770
|
+
return base || '参考图预处理';
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function inferReferenceSetId(referencePath, explicitId) {
|
|
774
|
+
if (explicitId) {
|
|
775
|
+
return slugify(explicitId);
|
|
776
|
+
}
|
|
777
|
+
return `${slugify(path.basename(referencePath, path.extname(referencePath)))}-${compactTimestamp()}`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function visualPrepareWorkspace(projectRoot, options = {}) {
|
|
781
|
+
if (!options.reference) {
|
|
782
|
+
throw new Error('Missing --reference image path.');
|
|
783
|
+
}
|
|
784
|
+
const hasGrid = Boolean(options.grid);
|
|
785
|
+
const hasBoxes = Boolean(options.boxes);
|
|
786
|
+
if (hasGrid === hasBoxes) {
|
|
787
|
+
throw new Error('Use exactly one prepare mode: --grid <columns>x<rows> or --boxes <plan.json>.');
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const referencePath = resolveProjectPath(projectRoot, options.reference);
|
|
791
|
+
const referenceTitle = inferReferenceTitle(referencePath, options.title);
|
|
792
|
+
const setId = inferReferenceSetId(referencePath, options.id);
|
|
793
|
+
const outputDir = options.out
|
|
794
|
+
? resolveProjectPath(projectRoot, options.out)
|
|
795
|
+
: path.join(projectRoot, DEFAULT_REFERENCE_SET_DIR, setId);
|
|
796
|
+
const cropsDir = path.join(outputDir, 'crops');
|
|
797
|
+
await fs.mkdir(cropsDir, { recursive: true });
|
|
798
|
+
|
|
799
|
+
const stagedSourcePath = path.join(outputDir, DEFAULT_SOURCE_FILENAME);
|
|
800
|
+
const staged = await stageReferenceSource(referencePath, stagedSourcePath);
|
|
801
|
+
const original = staged.stagedMetadata;
|
|
802
|
+
|
|
803
|
+
let mode;
|
|
804
|
+
let grid = null;
|
|
805
|
+
let boxesSourcePath = null;
|
|
806
|
+
let summary = '';
|
|
807
|
+
let items;
|
|
808
|
+
if (hasGrid) {
|
|
809
|
+
mode = 'grid';
|
|
810
|
+
grid = parseGridSpec(options.grid);
|
|
811
|
+
items = buildGridItems(original, grid, { labelPrefix: '对象' });
|
|
812
|
+
summary = `${grid.columns}x${grid.rows} 网格切片,共 ${items.length} 个参考对象。`;
|
|
813
|
+
} else {
|
|
814
|
+
mode = 'boxes';
|
|
815
|
+
const loaded = await loadBoxItems(projectRoot, options.boxes, original);
|
|
816
|
+
boxesSourcePath = loaded.sourcePath;
|
|
817
|
+
summary = loaded.summary || `${loaded.items.length} 个手工裁剪对象。`;
|
|
818
|
+
items = loaded.items;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
items = filterItemsByInclude(items, options.include);
|
|
822
|
+
ensureUniqueItemIds(items);
|
|
823
|
+
|
|
824
|
+
const itemResults = [];
|
|
825
|
+
for (const item of items) {
|
|
826
|
+
const cropPath = path.join(cropsDir, `${item.id}.png`);
|
|
827
|
+
const crop = await writeCrop(stagedSourcePath, item.absoluteBox, cropPath);
|
|
828
|
+
itemResults.push({
|
|
829
|
+
...item,
|
|
830
|
+
crop: {
|
|
831
|
+
...crop,
|
|
832
|
+
workspacePath: toWorkspacePath(projectRoot, crop.path),
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const contactSheetPath = path.join(outputDir, DEFAULT_CONTACT_SHEET_FILENAME);
|
|
838
|
+
await renderContactSheet(projectRoot, stagedSourcePath, itemResults, contactSheetPath, {
|
|
839
|
+
title: referenceTitle,
|
|
840
|
+
summary,
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
const paths = {
|
|
844
|
+
source: toWorkspacePath(projectRoot, stagedSourcePath),
|
|
845
|
+
contactSheet: toWorkspacePath(projectRoot, contactSheetPath),
|
|
846
|
+
referenceSet: normalizeWorkspacePath(path.join(toWorkspacePath(projectRoot, outputDir), DEFAULT_REFERENCE_SET_FILENAME)),
|
|
847
|
+
focusBoardTemplate: normalizeWorkspacePath(path.join(toWorkspacePath(projectRoot, outputDir), DEFAULT_FOCUS_BOARD_TEMPLATE)),
|
|
848
|
+
parallelBoardTemplate: normalizeWorkspacePath(path.join(toWorkspacePath(projectRoot, outputDir), DEFAULT_PARALLEL_BOARD_TEMPLATE)),
|
|
849
|
+
comparePlan: normalizeWorkspacePath(path.join(toWorkspacePath(projectRoot, outputDir), DEFAULT_COMPARE_PLAN_FILENAME)),
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
const focusBoardTemplate = buildFocusBoardTemplate(paths, referenceTitle, itemResults);
|
|
853
|
+
const parallelBoardTemplate = buildParallelBoardTemplate(referenceTitle, itemResults);
|
|
854
|
+
const comparePlan = buildComparePlan(paths.referenceSet, referenceTitle, itemResults, {
|
|
855
|
+
focusBoardTemplate: paths.focusBoardTemplate,
|
|
856
|
+
parallelBoardTemplate: paths.parallelBoardTemplate,
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
const referenceSet = {
|
|
860
|
+
version: 1,
|
|
861
|
+
schema: REFERENCE_SET_SCHEMA,
|
|
862
|
+
generatedAt: timestamp(),
|
|
863
|
+
id: setId,
|
|
864
|
+
title: referenceTitle,
|
|
865
|
+
mode,
|
|
866
|
+
summary,
|
|
867
|
+
source: {
|
|
868
|
+
originalPath: referencePath,
|
|
869
|
+
originalSha256: staged.originalSha256,
|
|
870
|
+
originalMetadata: staged.originalMetadata,
|
|
871
|
+
stagedPath: paths.source,
|
|
872
|
+
stagedSha256: staged.stagedSha256,
|
|
873
|
+
stagedMetadata: staged.stagedMetadata,
|
|
874
|
+
},
|
|
875
|
+
selection: mode === 'grid'
|
|
876
|
+
? { mode, grid, include: options.include ?? null }
|
|
877
|
+
: { mode, boxesPath: boxesSourcePath ? toWorkspacePath(projectRoot, boxesSourcePath) : null, include: options.include ?? null },
|
|
878
|
+
artifacts: {
|
|
879
|
+
contactSheet: paths.contactSheet,
|
|
880
|
+
focusBoardTemplate: paths.focusBoardTemplate,
|
|
881
|
+
parallelBoardTemplate: paths.parallelBoardTemplate,
|
|
882
|
+
comparePlan: paths.comparePlan,
|
|
883
|
+
},
|
|
884
|
+
items: itemResults.map((item) => ({
|
|
885
|
+
order: item.order,
|
|
886
|
+
id: item.id,
|
|
887
|
+
label: item.label,
|
|
888
|
+
note: item.note,
|
|
889
|
+
cropPath: item.crop.workspacePath,
|
|
890
|
+
cropSha256: item.crop.sha256,
|
|
891
|
+
cropMetadata: item.crop.metadata,
|
|
892
|
+
cropBox: item.cropBox,
|
|
893
|
+
absoluteBox: item.absoluteBox,
|
|
894
|
+
requestedUnit: item.requestedUnit,
|
|
895
|
+
requestedBox: item.requestedBox,
|
|
896
|
+
})),
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
await fs.writeFile(path.join(outputDir, DEFAULT_REFERENCE_SET_FILENAME), `${JSON.stringify(referenceSet, null, 2)}\n`, 'utf8');
|
|
900
|
+
await fs.writeFile(path.join(outputDir, DEFAULT_FOCUS_BOARD_TEMPLATE), `${JSON.stringify(focusBoardTemplate, null, 2)}\n`, 'utf8');
|
|
901
|
+
await fs.writeFile(path.join(outputDir, DEFAULT_PARALLEL_BOARD_TEMPLATE), `${JSON.stringify(parallelBoardTemplate, null, 2)}\n`, 'utf8');
|
|
902
|
+
await fs.writeFile(path.join(outputDir, DEFAULT_COMPARE_PLAN_FILENAME), `${JSON.stringify(comparePlan, null, 2)}\n`, 'utf8');
|
|
903
|
+
|
|
904
|
+
return {
|
|
905
|
+
ok: true,
|
|
906
|
+
action: 'visual-prepare',
|
|
907
|
+
projectRoot,
|
|
908
|
+
setId,
|
|
909
|
+
title: referenceTitle,
|
|
910
|
+
mode,
|
|
911
|
+
outputDir,
|
|
912
|
+
referenceSetPath: path.join(outputDir, DEFAULT_REFERENCE_SET_FILENAME),
|
|
913
|
+
contactSheetPath,
|
|
914
|
+
focusBoardTemplatePath: path.join(outputDir, DEFAULT_FOCUS_BOARD_TEMPLATE),
|
|
915
|
+
parallelBoardTemplatePath: path.join(outputDir, DEFAULT_PARALLEL_BOARD_TEMPLATE),
|
|
916
|
+
comparePlanPath: path.join(outputDir, DEFAULT_COMPARE_PLAN_FILENAME),
|
|
917
|
+
itemCount: itemResults.length,
|
|
918
|
+
items: itemResults.map((item) => ({
|
|
919
|
+
id: item.id,
|
|
920
|
+
label: item.label,
|
|
921
|
+
cropPath: item.crop.path,
|
|
922
|
+
cropMetadata: item.crop.metadata,
|
|
923
|
+
cropBox: item.cropBox,
|
|
924
|
+
})),
|
|
925
|
+
source: {
|
|
926
|
+
originalPath: referencePath,
|
|
927
|
+
stagedPath: stagedSourcePath,
|
|
928
|
+
metadata: staged.stagedMetadata,
|
|
929
|
+
},
|
|
930
|
+
nextActions: [
|
|
931
|
+
'先打开 contact-sheet 检查编号、边界和裁剪是否完整,确认没有漏裁、裁偏或跨对象。',
|
|
932
|
+
'如果只需要其中一部分对象纳入后续验收,重新运行 visual-prepare 并用 --include 收紧范围,或改用 --boxes 精细定义。',
|
|
933
|
+
'多对象逐项对比时优先使用 compare-plan.json 里的单项 reference 命令;同一整板局部验收时再编辑 focus-board.template.json。',
|
|
934
|
+
],
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export {
|
|
939
|
+
visualPrepareWorkspace,
|
|
940
|
+
};
|