cardbuddy 0.13.3 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/cli.js +4 -4
- package/dist/config.js +7 -7
- package/dist/init.js +15 -4
- package/dist/portGuard.js +2 -2
- package/dist/preview-editor/assets/index-DnXqKrCc.js +307 -0
- package/dist/preview-editor/assets/index-cbTCHp8J.css +1 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +14 -6
- package/dist/vendored/card-editor/api.js +6 -6
- package/dist/vendored/mcp/client.mjs +53 -3
- package/dist/vendored/mcp/tools.mjs +17 -1
- package/dist/vendored/server/core/ai-chat.js +1443 -271
- package/dist/vendored/server/core/cardFiles.js +271 -39
- package/dist/vendored/server/core/cardImportMeta.js +16 -0
- package/dist/vendored/server/core/chatHistory.js +122 -0
- package/dist/vendored/server/core/index.js +10 -3
- package/dist/vendored/server/core/turnJournal.js +386 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +399 -222
- package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +103 -19
- package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +378 -200
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/runs.mjs +147 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +297 -4
- package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +484 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +77 -96
- package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +237 -62
- package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -35
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +15 -2
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +5 -3
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +120 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +10 -0
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -1
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +2 -2
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/scatter-pill-band.json +10 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +43 -1
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +33 -6
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +12 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +47 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +12 -4
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +388 -328
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_pixels.mjs +109 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +45 -7
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +110 -23
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +2954 -127
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +128 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +28 -0
- package/dist/vendored/skills/finmall-card-sync/SKILL.md +1 -1
- package/dist/vendored/skills/finmall-session-navigator/SKILL.md +1 -1
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +1 -1
- package/dist/vendored/skills/shared/finmall-config.mjs +4 -4
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +2 -2
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +29 -2
- package/dist/vendored/tools/finmall-env.mjs +6 -6
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-CQScMhyg.css +0 -1
- package/dist/preview-editor/assets/index-pW1J4vP3.js +0 -276
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +0 -43
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +0 -47
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +0 -48
|
@@ -1,328 +1,388 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* design_sample.mjs — 从设计稿聚焦图(card-N.png)采样顶部色带。
|
|
3
|
-
*
|
|
4
|
-
* 用途:弥散背景/浅色顶带没有切片素材时,按「缺素材用纯色」约束,
|
|
5
|
-
* 从设计稿截图采样带高与纯色近似值(不裁切图片,只取颜色)。
|
|
6
|
-
*
|
|
7
|
-
* 算法(纯几何 + 统计,无任何业务/样本硬编码):
|
|
8
|
-
* 1. 取中央竖条(30%-70% 宽)避开圆角与侧边装饰;
|
|
9
|
-
* 2. 每行取中位色(对文字等前景像素稳健);
|
|
10
|
-
* 3. 卡体基色 = 底部 8% 行中位色的中位数;
|
|
11
|
-
* 4. 从顶部扫描:行色与基色平均通道差 > 6 视为色带行,
|
|
12
|
-
* 允许开头 ≤8 CSS px 的基色行(圆角行),允许 1 行例外(抗锯齿);
|
|
13
|
-
* 5. 带高 ≥10 且 ≤60% 卡高才采纳。
|
|
14
|
-
*/
|
|
15
|
-
import fs from 'node:fs'
|
|
16
|
-
import { PNG } from 'pngjs'
|
|
17
|
-
|
|
18
|
-
function median(nums) {
|
|
19
|
-
if (!nums.length) return 0
|
|
20
|
-
const sorted = [...nums].sort((a, b) => a - b)
|
|
21
|
-
return sorted[Math.floor(sorted.length / 2)]
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function toHex(r, g, b) {
|
|
25
|
-
const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0').toUpperCase()
|
|
26
|
-
return `#${c(r)}${c(g)}${c(b)}`
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* @param {string} designPath 设计稿聚焦图路径
|
|
31
|
-
* @param {number} cardWidth 卡片宽(CSS px)
|
|
32
|
-
* @param {number} cardHeight 卡片高(CSS px)
|
|
33
|
-
* @returns {{ height: number, color: string } | null}
|
|
34
|
-
*/
|
|
35
|
-
export function sampleTopBand(designPath, cardWidth, cardHeight) {
|
|
36
|
-
try {
|
|
37
|
-
if (!designPath || !fs.existsSync(designPath)) return null
|
|
38
|
-
const png = PNG.sync.read(fs.readFileSync(designPath))
|
|
39
|
-
const scale = png.width / Math.max(1, cardWidth)
|
|
40
|
-
if (!(scale > 0.5 && scale < 5)) return null
|
|
41
|
-
const { width: w, height: h, data } = png
|
|
42
|
-
const x0 = Math.floor(w * 0.3)
|
|
43
|
-
const x1 = Math.max(x0 + 2, Math.ceil(w * 0.7))
|
|
44
|
-
|
|
45
|
-
const rowMedian = (y) => {
|
|
46
|
-
const rs = [], gs = [], bs = []
|
|
47
|
-
for (let x = x0; x < x1; x += 2) {
|
|
48
|
-
const i = (y * w + x) * 4
|
|
49
|
-
rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
|
|
50
|
-
}
|
|
51
|
-
return [median(rs), median(gs), median(bs)]
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// 卡体基色:底部 8% 的行
|
|
55
|
-
const bodyRows = []
|
|
56
|
-
for (let y = Math.floor(h * 0.92); y < h; y++) bodyRows.push(rowMedian(y))
|
|
57
|
-
const body = [0, 1, 2].map((i) => median(bodyRows.map((c) => c[i])))
|
|
58
|
-
const dist = (a, b) => (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3
|
|
59
|
-
|
|
60
|
-
const maxLeadRows = Math.round(8 * scale) // 圆角保护
|
|
61
|
-
const bandColors = []
|
|
62
|
-
let bandEndRow = -1
|
|
63
|
-
let started = false
|
|
64
|
-
let exceptions = 0
|
|
65
|
-
for (let y = 0; y < h; y++) {
|
|
66
|
-
const c = rowMedian(y)
|
|
67
|
-
if (dist(c, body) > 6) {
|
|
68
|
-
started = true
|
|
69
|
-
bandColors.push(c)
|
|
70
|
-
bandEndRow = y
|
|
71
|
-
exceptions = 0
|
|
72
|
-
} else {
|
|
73
|
-
if (!started) {
|
|
74
|
-
if (y > maxLeadRows) return null // 顶部 8px 内没有出现色带 → 无顶带
|
|
75
|
-
continue
|
|
76
|
-
}
|
|
77
|
-
exceptions += 1
|
|
78
|
-
if (exceptions > 1) break // 容忍 1 行例外(分隔线/抗锯齿)
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
if (!started || bandEndRow < 0) return null
|
|
82
|
-
const heightCss = (bandEndRow + 1) / scale
|
|
83
|
-
// 上限 80%:整卡同色时底行=带色根本检不出带,能检出的高带(如顶面板+异色底栏)是合法形态
|
|
84
|
-
if (heightCss < 10 || heightCss > cardHeight * 0.8) return null
|
|
85
|
-
const color = toHex(
|
|
86
|
-
median(bandColors.map((c) => c[0])),
|
|
87
|
-
median(bandColors.map((c) => c[1])),
|
|
88
|
-
median(bandColors.map((c) => c[2])),
|
|
89
|
-
)
|
|
90
|
-
return { height: Math.round(heightCss), color }
|
|
91
|
-
} catch {
|
|
92
|
-
return null
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* 采样设计稿聚焦图中某个卡内区域的主色(区域中央 50% 的中位色,对内部文字稳健)。
|
|
98
|
-
* 用途:无导出素材的大切片(如烘焙按钮图)用纯色块近似(constraint:缺素材用纯色)。
|
|
99
|
-
* @param {string} designPath
|
|
100
|
-
* @param {number} cardWidth CSS px
|
|
101
|
-
* @param {{x:number,y:number,width:number,height:number}} region 卡内相对坐标
|
|
102
|
-
* @returns {string|null} hex 颜色
|
|
103
|
-
*/
|
|
104
|
-
export function sampleRegionColor(designPath, cardWidth, region) {
|
|
105
|
-
try {
|
|
106
|
-
if (!designPath || !fs.existsSync(designPath)) return null
|
|
107
|
-
const png = PNG.sync.read(fs.readFileSync(designPath))
|
|
108
|
-
const scale = png.width / Math.max(1, cardWidth)
|
|
109
|
-
if (!(scale > 0.5 && scale < 5)) return null
|
|
110
|
-
const { width: w, height: h, data } = png
|
|
111
|
-
const x0 = Math.max(0, Math.round((region.x + region.width * 0.25) * scale))
|
|
112
|
-
const x1 = Math.min(w, Math.round((region.x + region.width * 0.75) * scale))
|
|
113
|
-
const y0 = Math.max(0, Math.round((region.y + region.height * 0.25) * scale))
|
|
114
|
-
const y1 = Math.min(h, Math.round((region.y + region.height * 0.75) * scale))
|
|
115
|
-
if (x1 - x0 < 2 || y1 - y0 < 2) return null
|
|
116
|
-
const rs = [], gs = [], bs = []
|
|
117
|
-
for (let y = y0; y < y1; y += 2) {
|
|
118
|
-
for (let x = x0; x < x1; x += 2) {
|
|
119
|
-
const i = (y * w + x) * 4
|
|
120
|
-
rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return toHex(median(rs), median(gs), median(bs))
|
|
124
|
-
} catch {
|
|
125
|
-
return null
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* 采样区域边缘条带主色(左右两条竖带的中位色,避开中央文字)。
|
|
131
|
-
* 用途:按钮/pill 的标注填充色与设计稿像素交叉验证——中央采样会被按钮文字污染,
|
|
132
|
-
* 边缘条带(圆角半径之外的纯背景区)才是填充色真值。
|
|
133
|
-
* @param {string} designPath
|
|
134
|
-
* @param {number} cardWidth CSS px
|
|
135
|
-
* @param {{x:number,y:number,width:number,height:number}} region 卡内相对坐标
|
|
136
|
-
* @returns {string|null} hex 颜色
|
|
137
|
-
*/
|
|
138
|
-
export function sampleRegionEdgeColor(designPath, cardWidth, region) {
|
|
139
|
-
try {
|
|
140
|
-
if (!designPath || !fs.existsSync(designPath)) return null
|
|
141
|
-
const png = PNG.sync.read(fs.readFileSync(designPath))
|
|
142
|
-
const scale = png.width / Math.max(1, cardWidth)
|
|
143
|
-
if (!(scale > 0.5 && scale < 5)) return null
|
|
144
|
-
const { width: w, height: h, data } = png
|
|
145
|
-
// 条带内缩:横向 8%-22% / 78%-92%(避开圆角与中央文字),纵向 30%-70%
|
|
146
|
-
const y0 = Math.max(0, Math.round((region.y + region.height * 0.3) * scale))
|
|
147
|
-
const y1 = Math.min(h, Math.round((region.y + region.height * 0.7) * scale))
|
|
148
|
-
const strips = [
|
|
149
|
-
[Math.round((region.x + region.width * 0.08) * scale), Math.round((region.x + region.width * 0.22) * scale)],
|
|
150
|
-
[Math.round((region.x + region.width * 0.78) * scale), Math.round((region.x + region.width * 0.92) * scale)],
|
|
151
|
-
]
|
|
152
|
-
const rs = [], gs = [], bs = []
|
|
153
|
-
for (const [sx0, sx1] of strips) {
|
|
154
|
-
const x0 = Math.max(0, sx0), x1 = Math.min(w, sx1)
|
|
155
|
-
if (x1 - x0 < 2 || y1 - y0 < 2) continue
|
|
156
|
-
for (let y = y0; y < y1; y += 2) {
|
|
157
|
-
for (let x = x0; x < x1; x += 2) {
|
|
158
|
-
const i = (y * w + x) * 4
|
|
159
|
-
rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (!rs.length) return null
|
|
164
|
-
return toHex(median(rs), median(gs), median(bs))
|
|
165
|
-
} catch {
|
|
166
|
-
return null
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* 采样卡体主色(设计稿聚焦图中央竖条底部 8% 行的中位色)。
|
|
172
|
-
* 用于校验/纠正 spec cardFill 与设计稿明显不一致的情况(视觉优先)。
|
|
173
|
-
* @returns {string|null} hex
|
|
174
|
-
*/
|
|
175
|
-
export function sampleCardBodyColor(designPath, cardWidth) {
|
|
176
|
-
try {
|
|
177
|
-
if (!designPath || !fs.existsSync(designPath)) return null
|
|
178
|
-
const png = PNG.sync.read(fs.readFileSync(designPath))
|
|
179
|
-
const scale = png.width / Math.max(1, cardWidth)
|
|
180
|
-
if (!(scale > 0.5 && scale < 5)) return null
|
|
181
|
-
const { width: w, height: h, data } = png
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
1
|
+
/**
|
|
2
|
+
* design_sample.mjs — 从设计稿聚焦图(card-N.png)采样顶部色带。
|
|
3
|
+
*
|
|
4
|
+
* 用途:弥散背景/浅色顶带没有切片素材时,按「缺素材用纯色」约束,
|
|
5
|
+
* 从设计稿截图采样带高与纯色近似值(不裁切图片,只取颜色)。
|
|
6
|
+
*
|
|
7
|
+
* 算法(纯几何 + 统计,无任何业务/样本硬编码):
|
|
8
|
+
* 1. 取中央竖条(30%-70% 宽)避开圆角与侧边装饰;
|
|
9
|
+
* 2. 每行取中位色(对文字等前景像素稳健);
|
|
10
|
+
* 3. 卡体基色 = 底部 8% 行中位色的中位数;
|
|
11
|
+
* 4. 从顶部扫描:行色与基色平均通道差 > 6 视为色带行,
|
|
12
|
+
* 允许开头 ≤8 CSS px 的基色行(圆角行),允许 1 行例外(抗锯齿);
|
|
13
|
+
* 5. 带高 ≥10 且 ≤60% 卡高才采纳。
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import { PNG } from 'pngjs'
|
|
17
|
+
|
|
18
|
+
function median(nums) {
|
|
19
|
+
if (!nums.length) return 0
|
|
20
|
+
const sorted = [...nums].sort((a, b) => a - b)
|
|
21
|
+
return sorted[Math.floor(sorted.length / 2)]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toHex(r, g, b) {
|
|
25
|
+
const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0').toUpperCase()
|
|
26
|
+
return `#${c(r)}${c(g)}${c(b)}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} designPath 设计稿聚焦图路径
|
|
31
|
+
* @param {number} cardWidth 卡片宽(CSS px)
|
|
32
|
+
* @param {number} cardHeight 卡片高(CSS px)
|
|
33
|
+
* @returns {{ height: number, color: string } | null}
|
|
34
|
+
*/
|
|
35
|
+
export function sampleTopBand(designPath, cardWidth, cardHeight) {
|
|
36
|
+
try {
|
|
37
|
+
if (!designPath || !fs.existsSync(designPath)) return null
|
|
38
|
+
const png = PNG.sync.read(fs.readFileSync(designPath))
|
|
39
|
+
const scale = png.width / Math.max(1, cardWidth)
|
|
40
|
+
if (!(scale > 0.5 && scale < 5)) return null
|
|
41
|
+
const { width: w, height: h, data } = png
|
|
42
|
+
const x0 = Math.floor(w * 0.3)
|
|
43
|
+
const x1 = Math.max(x0 + 2, Math.ceil(w * 0.7))
|
|
44
|
+
|
|
45
|
+
const rowMedian = (y) => {
|
|
46
|
+
const rs = [], gs = [], bs = []
|
|
47
|
+
for (let x = x0; x < x1; x += 2) {
|
|
48
|
+
const i = (y * w + x) * 4
|
|
49
|
+
rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
|
|
50
|
+
}
|
|
51
|
+
return [median(rs), median(gs), median(bs)]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 卡体基色:底部 8% 的行
|
|
55
|
+
const bodyRows = []
|
|
56
|
+
for (let y = Math.floor(h * 0.92); y < h; y++) bodyRows.push(rowMedian(y))
|
|
57
|
+
const body = [0, 1, 2].map((i) => median(bodyRows.map((c) => c[i])))
|
|
58
|
+
const dist = (a, b) => (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3
|
|
59
|
+
|
|
60
|
+
const maxLeadRows = Math.round(8 * scale) // 圆角保护
|
|
61
|
+
const bandColors = []
|
|
62
|
+
let bandEndRow = -1
|
|
63
|
+
let started = false
|
|
64
|
+
let exceptions = 0
|
|
65
|
+
for (let y = 0; y < h; y++) {
|
|
66
|
+
const c = rowMedian(y)
|
|
67
|
+
if (dist(c, body) > 6) {
|
|
68
|
+
started = true
|
|
69
|
+
bandColors.push(c)
|
|
70
|
+
bandEndRow = y
|
|
71
|
+
exceptions = 0
|
|
72
|
+
} else {
|
|
73
|
+
if (!started) {
|
|
74
|
+
if (y > maxLeadRows) return null // 顶部 8px 内没有出现色带 → 无顶带
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
exceptions += 1
|
|
78
|
+
if (exceptions > 1) break // 容忍 1 行例外(分隔线/抗锯齿)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!started || bandEndRow < 0) return null
|
|
82
|
+
const heightCss = (bandEndRow + 1) / scale
|
|
83
|
+
// 上限 80%:整卡同色时底行=带色根本检不出带,能检出的高带(如顶面板+异色底栏)是合法形态
|
|
84
|
+
if (heightCss < 10 || heightCss > cardHeight * 0.8) return null
|
|
85
|
+
const color = toHex(
|
|
86
|
+
median(bandColors.map((c) => c[0])),
|
|
87
|
+
median(bandColors.map((c) => c[1])),
|
|
88
|
+
median(bandColors.map((c) => c[2])),
|
|
89
|
+
)
|
|
90
|
+
return { height: Math.round(heightCss), color }
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 采样设计稿聚焦图中某个卡内区域的主色(区域中央 50% 的中位色,对内部文字稳健)。
|
|
98
|
+
* 用途:无导出素材的大切片(如烘焙按钮图)用纯色块近似(constraint:缺素材用纯色)。
|
|
99
|
+
* @param {string} designPath
|
|
100
|
+
* @param {number} cardWidth CSS px
|
|
101
|
+
* @param {{x:number,y:number,width:number,height:number}} region 卡内相对坐标
|
|
102
|
+
* @returns {string|null} hex 颜色
|
|
103
|
+
*/
|
|
104
|
+
export function sampleRegionColor(designPath, cardWidth, region) {
|
|
105
|
+
try {
|
|
106
|
+
if (!designPath || !fs.existsSync(designPath)) return null
|
|
107
|
+
const png = PNG.sync.read(fs.readFileSync(designPath))
|
|
108
|
+
const scale = png.width / Math.max(1, cardWidth)
|
|
109
|
+
if (!(scale > 0.5 && scale < 5)) return null
|
|
110
|
+
const { width: w, height: h, data } = png
|
|
111
|
+
const x0 = Math.max(0, Math.round((region.x + region.width * 0.25) * scale))
|
|
112
|
+
const x1 = Math.min(w, Math.round((region.x + region.width * 0.75) * scale))
|
|
113
|
+
const y0 = Math.max(0, Math.round((region.y + region.height * 0.25) * scale))
|
|
114
|
+
const y1 = Math.min(h, Math.round((region.y + region.height * 0.75) * scale))
|
|
115
|
+
if (x1 - x0 < 2 || y1 - y0 < 2) return null
|
|
116
|
+
const rs = [], gs = [], bs = []
|
|
117
|
+
for (let y = y0; y < y1; y += 2) {
|
|
118
|
+
for (let x = x0; x < x1; x += 2) {
|
|
119
|
+
const i = (y * w + x) * 4
|
|
120
|
+
rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return toHex(median(rs), median(gs), median(bs))
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 采样区域边缘条带主色(左右两条竖带的中位色,避开中央文字)。
|
|
131
|
+
* 用途:按钮/pill 的标注填充色与设计稿像素交叉验证——中央采样会被按钮文字污染,
|
|
132
|
+
* 边缘条带(圆角半径之外的纯背景区)才是填充色真值。
|
|
133
|
+
* @param {string} designPath
|
|
134
|
+
* @param {number} cardWidth CSS px
|
|
135
|
+
* @param {{x:number,y:number,width:number,height:number}} region 卡内相对坐标
|
|
136
|
+
* @returns {string|null} hex 颜色
|
|
137
|
+
*/
|
|
138
|
+
export function sampleRegionEdgeColor(designPath, cardWidth, region) {
|
|
139
|
+
try {
|
|
140
|
+
if (!designPath || !fs.existsSync(designPath)) return null
|
|
141
|
+
const png = PNG.sync.read(fs.readFileSync(designPath))
|
|
142
|
+
const scale = png.width / Math.max(1, cardWidth)
|
|
143
|
+
if (!(scale > 0.5 && scale < 5)) return null
|
|
144
|
+
const { width: w, height: h, data } = png
|
|
145
|
+
// 条带内缩:横向 8%-22% / 78%-92%(避开圆角与中央文字),纵向 30%-70%
|
|
146
|
+
const y0 = Math.max(0, Math.round((region.y + region.height * 0.3) * scale))
|
|
147
|
+
const y1 = Math.min(h, Math.round((region.y + region.height * 0.7) * scale))
|
|
148
|
+
const strips = [
|
|
149
|
+
[Math.round((region.x + region.width * 0.08) * scale), Math.round((region.x + region.width * 0.22) * scale)],
|
|
150
|
+
[Math.round((region.x + region.width * 0.78) * scale), Math.round((region.x + region.width * 0.92) * scale)],
|
|
151
|
+
]
|
|
152
|
+
const rs = [], gs = [], bs = []
|
|
153
|
+
for (const [sx0, sx1] of strips) {
|
|
154
|
+
const x0 = Math.max(0, sx0), x1 = Math.min(w, sx1)
|
|
155
|
+
if (x1 - x0 < 2 || y1 - y0 < 2) continue
|
|
156
|
+
for (let y = y0; y < y1; y += 2) {
|
|
157
|
+
for (let x = x0; x < x1; x += 2) {
|
|
158
|
+
const i = (y * w + x) * 4
|
|
159
|
+
rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!rs.length) return null
|
|
164
|
+
return toHex(median(rs), median(gs), median(bs))
|
|
165
|
+
} catch {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 采样卡体主色(设计稿聚焦图中央竖条底部 8% 行的中位色)。
|
|
172
|
+
* 用于校验/纠正 spec cardFill 与设计稿明显不一致的情况(视觉优先)。
|
|
173
|
+
* @returns {string|null} hex
|
|
174
|
+
*/
|
|
175
|
+
export function sampleCardBodyColor(designPath, cardWidth) {
|
|
176
|
+
try {
|
|
177
|
+
if (!designPath || !fs.existsSync(designPath)) return null
|
|
178
|
+
const png = PNG.sync.read(fs.readFileSync(designPath))
|
|
179
|
+
const scale = png.width / Math.max(1, cardWidth)
|
|
180
|
+
if (!(scale > 0.5 && scale < 5)) return null
|
|
181
|
+
const { width: w, height: h, data } = png
|
|
182
|
+
// 采样卡体左右margin条带(各 ~3% 宽、全高):内缩壳/列表行背景永远盖不到margin,
|
|
183
|
+
// 此前采底部 6% 中条会把「最后一行彩色壳」误当卡底色(整卡被染成行壳色)。
|
|
184
|
+
const edge = Math.max(2, Math.floor(w * 0.03))
|
|
185
|
+
const buckets = new Map()
|
|
186
|
+
const pick = (x, y) => {
|
|
187
|
+
const i = (y * w + x) * 4
|
|
188
|
+
const key = `${data[i] >> 4},${data[i + 1] >> 4},${data[i + 2] >> 4}`
|
|
189
|
+
const slot = buckets.get(key) || (buckets.set(key, { n: 0, rs: [], gs: [], bs: [] }), buckets.get(key))
|
|
190
|
+
slot.n += 1
|
|
191
|
+
slot.rs.push(data[i]); slot.gs.push(data[i + 1]); slot.bs.push(data[i + 2])
|
|
192
|
+
}
|
|
193
|
+
for (let y = 0; y < h; y += 2) {
|
|
194
|
+
for (let x = 0; x < edge; x += 1) pick(x, y)
|
|
195
|
+
for (let x = Math.max(0, w - edge); x < w; x += 1) pick(x, y)
|
|
196
|
+
}
|
|
197
|
+
if (!buckets.size) return null
|
|
198
|
+
const top = [...buckets.values()].sort((a, b) => b.n - a.n)[0]
|
|
199
|
+
return toHex(median(top.rs), median(top.gs), median(top.bs))
|
|
200
|
+
} catch {
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 文本颜色交叉校验(标注缺陷治理,视觉优先):
|
|
207
|
+
* 标注 textStyle.color 与设计稿实际字形颜色明显不一致时(如设计是蓝灰字、标注写深色),
|
|
208
|
+
* 从设计稿聚焦图采样字形真实颜色覆盖标注。
|
|
209
|
+
*
|
|
210
|
+
* 算法(纯几何 + 统计,无业务词):
|
|
211
|
+
* 1. 文本所在小壳(按钮/pill)bbox 内缩 15% 取中央区(避开圆角/壳边抗锯齿);
|
|
212
|
+
* 2. 全区中位色 ≈ 壳底色(文字稀疏);
|
|
213
|
+
* 3. 与底色距离 >40 的像素视为字形;取其中亮度最低 25%(抗锯齿核心)的中位色;
|
|
214
|
+
* 4. 采样色与标注色平均通道差 >28 才覆盖(小差不折腾,保留标注)。
|
|
215
|
+
*
|
|
216
|
+
* @param {string} designPath 设计稿聚焦图路径(card-N.png)
|
|
217
|
+
* @param {number} cardWidth 卡片宽(CSS px)
|
|
218
|
+
* @param {{x:number,y:number,width:number,height:number}} bboxCardRel 壳 bbox(卡内相对坐标)
|
|
219
|
+
* @param {string} specColorHex 标注颜色(#RRGGBB)
|
|
220
|
+
* @returns {string|null} 需覆盖时返回采样色 #RRGGBB,否则 null
|
|
221
|
+
*/
|
|
222
|
+
export function crossCheckTextColor(designPath, cardWidth, bboxCardRel, specColorHex) {
|
|
223
|
+
try {
|
|
224
|
+
if (!designPath || !fs.existsSync(designPath)) return null
|
|
225
|
+
const png = PNG.sync.read(fs.readFileSync(designPath))
|
|
226
|
+
const scale = png.width / Math.max(1, cardWidth)
|
|
227
|
+
if (!(scale > 0.5 && scale < 5)) return null
|
|
228
|
+
const { width: w, height: h, data } = png
|
|
229
|
+
const bx = (bboxCardRel.x || 0) * scale
|
|
230
|
+
const by = (bboxCardRel.y || 0) * scale
|
|
231
|
+
const bw = (bboxCardRel.width || 0) * scale
|
|
232
|
+
const bh = (bboxCardRel.height || 0) * scale
|
|
233
|
+
if (bw < 8 || bh < 6) return null
|
|
234
|
+
const x0 = Math.max(0, Math.round(bx + bw * 0.15))
|
|
235
|
+
const x1 = Math.min(w - 1, Math.round(bx + bw * 0.85))
|
|
236
|
+
const y0 = Math.max(0, Math.round(by + bh * 0.15))
|
|
237
|
+
const y1 = Math.min(h - 1, Math.round(by + bh * 0.85))
|
|
238
|
+
if (x1 - x0 < 4 || y1 - y0 < 4) return null
|
|
239
|
+
const rs = [], gs = [], bs = []
|
|
240
|
+
for (let y = y0; y <= y1; y++) {
|
|
241
|
+
for (let x = x0; x <= x1; x++) {
|
|
242
|
+
const i = (y * w + x) * 4
|
|
243
|
+
rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const bg = [median(rs), median(gs), median(bs)]
|
|
247
|
+
const dist = (r, g, b, t) => (Math.abs(r - t[0]) + Math.abs(g - t[1]) + Math.abs(b - t[2])) / 3
|
|
248
|
+
const glyphs = []
|
|
249
|
+
for (let k = 0; k < rs.length; k++) {
|
|
250
|
+
if (dist(rs[k], gs[k], bs[k], bg) > 40) {
|
|
251
|
+
glyphs.push([rs[k], gs[k], bs[k], rs[k] * 0.299 + gs[k] * 0.587 + bs[k] * 0.114])
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (glyphs.length < 30) return null
|
|
255
|
+
glyphs.sort((a, b) => a[3] - b[3])
|
|
256
|
+
const core = glyphs.slice(0, Math.max(10, Math.floor(glyphs.length * 0.25)))
|
|
257
|
+
const sampled = [
|
|
258
|
+
median(core.map((c) => c[0])),
|
|
259
|
+
median(core.map((c) => c[1])),
|
|
260
|
+
median(core.map((c) => c[2])),
|
|
261
|
+
]
|
|
262
|
+
const m = String(specColorHex || '').replace('#', '')
|
|
263
|
+
if (!/^[0-9a-fA-F]{6}$/.test(m)) return toHex(...sampled)
|
|
264
|
+
const spec = [parseInt(m.slice(0, 2), 16), parseInt(m.slice(2, 4), 16), parseInt(m.slice(4, 6), 16)]
|
|
265
|
+
if (dist(sampled[0], sampled[1], sampled[2], spec) > 28) return toHex(...sampled)
|
|
266
|
+
return null
|
|
267
|
+
} catch {
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 描边环采样(标注缺陷治理,视觉优先):
|
|
274
|
+
* 壳标注无 fill 无 border("隐形"壳),但设计稿可见描边时(如线框 pill 按钮),
|
|
275
|
+
* 从设计稿采样壳边缘环带颜色,判定是否存在描边并取其颜色。
|
|
276
|
+
*
|
|
277
|
+
* 算法(纯几何 + 统计):
|
|
278
|
+
* 1. 壳 bbox 四边各取中段 30%-70%(避开圆角);
|
|
279
|
+
* 2. 环带 = bbox 边缘向内 max(1, round(1.6×scale)) px 的窄带;
|
|
280
|
+
* 3. 内部参照色 = 中央 30% 区域中位色;
|
|
281
|
+
* 4. 环带亮度最低 40% 像素的中位色与内部参照色距离 >18 → 存在描边,返回其颜色。
|
|
282
|
+
*
|
|
283
|
+
* @param {string} designPath 设计稿聚焦图路径
|
|
284
|
+
* @param {number} cardWidth 卡片宽(CSS px)
|
|
285
|
+
* @param {{x:number,y:number,width:number,height:number}} bboxCardRel 壳 bbox(卡内相对坐标)
|
|
286
|
+
* @returns {string|null} 描边色 #RRGGBB;无描边/采样失败返回 null
|
|
287
|
+
*/
|
|
288
|
+
export function sampleOutlineRing(designPath, cardWidth, bboxCardRel) {
|
|
289
|
+
try {
|
|
290
|
+
if (!designPath || !fs.existsSync(designPath)) return null
|
|
291
|
+
const png = PNG.sync.read(fs.readFileSync(designPath))
|
|
292
|
+
const scale = png.width / Math.max(1, cardWidth)
|
|
293
|
+
if (!(scale > 0.5 && scale < 5)) return null
|
|
294
|
+
const { width: w, height: h, data } = png
|
|
295
|
+
const bx = Math.round((bboxCardRel.x || 0) * scale)
|
|
296
|
+
const by = Math.round((bboxCardRel.y || 0) * scale)
|
|
297
|
+
const bw = Math.round((bboxCardRel.width || 0) * scale)
|
|
298
|
+
const bh = Math.round((bboxCardRel.height || 0) * scale)
|
|
299
|
+
if (bw < 10 || bh < 8) return null
|
|
300
|
+
const t = Math.max(1, Math.round(1.6 * scale))
|
|
301
|
+
const at = (x, y) => {
|
|
302
|
+
const i = (Math.min(h - 1, Math.max(0, y)) * w + Math.min(w - 1, Math.max(0, x))) * 4
|
|
303
|
+
return [data[i], data[i + 1], data[i + 2]]
|
|
304
|
+
}
|
|
305
|
+
const ring = []
|
|
306
|
+
const push = (x, y) => { const c = at(x, y); ring.push([c[0], c[1], c[2], c[0] * 0.299 + c[1] * 0.587 + c[2] * 0.114]) }
|
|
307
|
+
for (let x = bx + Math.round(bw * 0.3); x <= bx + Math.round(bw * 0.7); x++) {
|
|
308
|
+
for (let k = 0; k < t; k++) { push(x, by + k); push(x, by + bh - 1 - k) }
|
|
309
|
+
}
|
|
310
|
+
for (let y = by + Math.round(bh * 0.3); y <= by + Math.round(bh * 0.7); y++) {
|
|
311
|
+
for (let k = 0; k < t; k++) { push(bx + k, y); push(bx + bw - 1 - k, y) }
|
|
312
|
+
}
|
|
313
|
+
if (ring.length < 20) return null
|
|
314
|
+
// 内部参照色(中央 30% 区域中位色)
|
|
315
|
+
const ir = [], ig = [], ib = []
|
|
316
|
+
for (let y = by + Math.round(bh * 0.35); y <= by + Math.round(bh * 0.65); y += 2) {
|
|
317
|
+
for (let x = bx + Math.round(bw * 0.35); x <= bx + Math.round(bw * 0.65); x += 2) {
|
|
318
|
+
const c = at(x, y); ir.push(c[0]); ig.push(c[1]); ib.push(c[2])
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const interior = [median(ir), median(ig), median(ib)]
|
|
322
|
+
ring.sort((a, b) => a[3] - b[3])
|
|
323
|
+
const core = ring.slice(0, Math.max(8, Math.floor(ring.length * 0.4)))
|
|
324
|
+
const ringColor = [
|
|
325
|
+
median(core.map((c) => c[0])),
|
|
326
|
+
median(core.map((c) => c[1])),
|
|
327
|
+
median(core.map((c) => c[2])),
|
|
328
|
+
]
|
|
329
|
+
const dist = (Math.abs(ringColor[0] - interior[0])
|
|
330
|
+
+ Math.abs(ringColor[1] - interior[1])
|
|
331
|
+
+ Math.abs(ringColor[2] - interior[2])) / 3
|
|
332
|
+
return dist > 18 ? toHex(...ringColor) : null
|
|
333
|
+
} catch {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 分区底色带采样:设计稿卡体「上灰下白」等 flat 分区底色,按左缘逐行取色分段。
|
|
341
|
+
* 仅采纳行内均匀(flat 填充)且高度 >=24 的带;渐变/弥散背景行色逐行变化 → 自动排除。
|
|
342
|
+
* @returns {{ y0: number, y1: number, color: string }[] | null} 卡坐标 px 带列表
|
|
343
|
+
*/
|
|
344
|
+
export function sampleBackdropBands(designPath, cardWidth) {
|
|
345
|
+
try {
|
|
346
|
+
if (!designPath || !fs.existsSync(designPath)) return null
|
|
347
|
+
const png = PNG.sync.read(fs.readFileSync(designPath))
|
|
348
|
+
const scale = png.width / Math.max(1, cardWidth)
|
|
349
|
+
if (!(scale > 0.5 && scale < 5)) return null
|
|
350
|
+
const { width: w, height: h, data } = png
|
|
351
|
+
const dist = (a, b) => (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3
|
|
352
|
+
const rowColor = (py) => {
|
|
353
|
+
const rs = [], gs = [], bs = []
|
|
354
|
+
for (const x of [1, 2, 3]) {
|
|
355
|
+
const i = (py * w + x) * 4
|
|
356
|
+
rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
|
|
357
|
+
}
|
|
358
|
+
return [median(rs), median(gs), median(bs)]
|
|
359
|
+
}
|
|
360
|
+
const cardH = Math.round(h / scale)
|
|
361
|
+
const raw = []
|
|
362
|
+
let cur = null
|
|
363
|
+
for (let cy = 0; cy < cardH; cy++) {
|
|
364
|
+
const c = rowColor(Math.min(h - 1, Math.round((cy + 0.5) * scale)))
|
|
365
|
+
if (!cur || dist(cur.color, c) > 4) {
|
|
366
|
+
if (cur) raw.push(cur)
|
|
367
|
+
cur = { y0: cy, y1: cy + 1, color: c, rows: [c] }
|
|
368
|
+
} else {
|
|
369
|
+
cur.y1 = cy + 1
|
|
370
|
+
cur.rows.push(c)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (cur) raw.push(cur)
|
|
374
|
+
const out = []
|
|
375
|
+
for (const b of raw) {
|
|
376
|
+
if (b.y1 - b.y0 < 24) continue
|
|
377
|
+
if (!b.rows.every((r) => dist(r, b.color) <= 4)) continue
|
|
378
|
+
const med = [0, 1, 2].map((ci) => median(b.rows.map((r) => r[ci])))
|
|
379
|
+
const hex = toHex(med[0], med[1], med[2])
|
|
380
|
+
const last = out[out.length - 1]
|
|
381
|
+
if (last && last.color === hex) last.y1 = b.y1
|
|
382
|
+
else out.push({ y0: b.y0, y1: b.y1, color: hex })
|
|
383
|
+
}
|
|
384
|
+
return out.length >= 2 ? out : null
|
|
385
|
+
} catch {
|
|
386
|
+
return null
|
|
387
|
+
}
|
|
388
|
+
}
|