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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* slice_pixels.mjs —— 切图「像素级内容判重」共享实现(slices_convention.md §3.1 根治配套)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么 sha256 不够:同一张图可能有**两个像素相同但字节不同**的来源——
|
|
5
|
+
* ① 设计稿导出资产(矢量渲染 PNG);② 从 card-N.png / 画板整图**裁切**出来的派生图。
|
|
6
|
+
* 两者像素几乎一致但编码器/参数不同 → sha256 不同 → 仅靠 sha 的去重(slice_dedupe.mjs)
|
|
7
|
+
* 认不出「裁切图 ≡ 已存在的共用切图」,于是 src/slices 堆出 `_altN` / `__artboard_crop__` 重复副本
|
|
8
|
+
* (2026-09-18 ynet-card 事故:共用图标已重命名落地,仍被裁出 6 份重复副本)。
|
|
9
|
+
*
|
|
10
|
+
* 判重口径(权威 → 辅助):
|
|
11
|
+
* - **sha256(字节)**:一致即同图,最快路径;
|
|
12
|
+
* - **像素容差比对**:尺寸相同 + 逐像素 RGBA 差 ≤ tol 的坏点占比 ≤ maxBadRatio → 同图
|
|
13
|
+
* (容忍裁切/重编码带来的抗锯齿微差);
|
|
14
|
+
* - 文件名**不参与**判定(语义重命名后名字对不上是常态,见 manifest 别名)。
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { createHash } from 'node:crypto'
|
|
19
|
+
|
|
20
|
+
const { PNG } = await import('pngjs')
|
|
21
|
+
|
|
22
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
|
23
|
+
|
|
24
|
+
/** 读 PNG;失败返回 null(非 PNG / 损坏 / 不存在)。 */
|
|
25
|
+
export function readPng(file) {
|
|
26
|
+
try {
|
|
27
|
+
return PNG.sync.read(fs.readFileSync(file))
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 从 src 裁出 [x,y,w,h)(像素坐标,已取整)返回新 PNG。 */
|
|
34
|
+
export function cropPng(src, x, y, w, h) {
|
|
35
|
+
const cx = Math.max(0, Math.round(x))
|
|
36
|
+
const cy = Math.max(0, Math.round(y))
|
|
37
|
+
const cw = Math.max(1, Math.round(w))
|
|
38
|
+
const ch = Math.max(1, Math.round(h))
|
|
39
|
+
const out = new PNG({ width: cw, height: ch })
|
|
40
|
+
for (let py = 0; py < ch; py++) {
|
|
41
|
+
for (let px = 0; px < cw; px++) {
|
|
42
|
+
const si = ((cy + py) * src.width + (cx + px)) * 4
|
|
43
|
+
const di = (py * cw + px) * 4
|
|
44
|
+
for (let k = 0; k < 4; k++) out.data[di + k] = src.data[si + k] || 0
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** PNG 像素缓冲的 sha256(含尺寸):像素 identical 的裁切产物之间可快速判等。 */
|
|
51
|
+
export function pngPixelSha(png) {
|
|
52
|
+
return createHash('sha256')
|
|
53
|
+
.update(`${png.width}x${png.height}:`)
|
|
54
|
+
.update(png.data)
|
|
55
|
+
.digest('hex')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 像素容差比对:尺寸必须一致;逐像素 RGBA 任一通道差 > tol 记一个坏点,
|
|
60
|
+
* 坏点占比 ≤ maxBadRatio 判同图(抗锯齿/重编码微差容忍)。
|
|
61
|
+
* @param {object} a PNG
|
|
62
|
+
* @param {object} b PNG
|
|
63
|
+
* @param {number} [tol=4] 单通道容差
|
|
64
|
+
* @param {number} [maxBadRatio=0.01] 坏点占比上限
|
|
65
|
+
*/
|
|
66
|
+
export function pixelsMatch(a, b, tol = 4, maxBadRatio = 0.01) {
|
|
67
|
+
if (!a || !b || a.width !== b.width || a.height !== b.height) return false
|
|
68
|
+
const total = a.width * a.height
|
|
69
|
+
if (!total) return false
|
|
70
|
+
let bad = 0
|
|
71
|
+
for (let i = 0; i < total * 4; i += 4) {
|
|
72
|
+
const dr = Math.abs(a.data[i] - b.data[i])
|
|
73
|
+
const dg = Math.abs(a.data[i + 1] - b.data[i + 1])
|
|
74
|
+
const db = Math.abs(a.data[i + 2] - b.data[i + 2])
|
|
75
|
+
const da = Math.abs(a.data[i + 3] - b.data[i + 3])
|
|
76
|
+
// 全透明像素的颜色无意义:双方都透明时不算坏点
|
|
77
|
+
if (a.data[i + 3] === 0 && b.data[i + 3] === 0) continue
|
|
78
|
+
if (dr > tol || dg > tol || db > tol || da > tol) {
|
|
79
|
+
bad++
|
|
80
|
+
if (bad / total > maxBadRatio) return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return bad / total <= maxBadRatio
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 在目录里找与目标 PNG **内容相同**的既有切图(sha 快路径 + 像素容差兜底)。
|
|
88
|
+
* 只扫扁平图片文件;`skip` 里的文件名排除(如自身 / 即将被覆盖的同名文件)。
|
|
89
|
+
* @param {string} dir 目标目录(如 src/slices)
|
|
90
|
+
* @param {object} png 目标 PNG(已解码)
|
|
91
|
+
* @param {{tol?:number, maxBadRatio?:number, skip?:string[]}} [opts]
|
|
92
|
+
* @returns {string|null} 命中返回扁平文件名,否则 null
|
|
93
|
+
*/
|
|
94
|
+
export function findPixelTwin(dir, png, opts = {}) {
|
|
95
|
+
if (!png || !dir || !fs.existsSync(dir)) return null
|
|
96
|
+
const skip = new Set(opts.skip || [])
|
|
97
|
+
const sha = pngPixelSha(png)
|
|
98
|
+
let fallback = null
|
|
99
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
100
|
+
if (!entry.isFile() || skip.has(entry.name)) continue
|
|
101
|
+
if (!IMAGE_EXT.has(path.extname(entry.name).toLowerCase())) continue
|
|
102
|
+
const file = path.join(dir, entry.name)
|
|
103
|
+
const candidate = readPng(file)
|
|
104
|
+
if (!candidate || candidate.width !== png.width || candidate.height !== png.height) continue
|
|
105
|
+
if (pngPixelSha(candidate) === sha) return entry.name
|
|
106
|
+
if (!fallback && pixelsMatch(candidate, png, opts.tol, opts.maxBadRatio)) fallback = entry.name
|
|
107
|
+
}
|
|
108
|
+
return fallback
|
|
109
|
+
}
|
|
@@ -200,25 +200,63 @@ function createPreviewServer({ cardsRoot }) {
|
|
|
200
200
|
/* ------------------------------------------------------------------ */
|
|
201
201
|
/* Chrome + CDP 截屏 */
|
|
202
202
|
/* ------------------------------------------------------------------ */
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
/**
|
|
204
|
+
* 系统 Chromium 浏览器探测(跨平台)。
|
|
205
|
+
*
|
|
206
|
+
* Windows 上此前整条链路失效:候选是否按路径解析只认正斜杠(反斜杠的 CHROME_PATH 被
|
|
207
|
+
* 当成 PATH 命令名),命令查找又写死 which(Windows 无此命令,spawnSync 返回 status=null),
|
|
208
|
+
* 结果即使设了 CHROME_PATH 也报「未找到系统 Chrome」,训练轮全案例 renderFailures。
|
|
209
|
+
* 现按「显式路径 → 平台标准安装位置 → PATH 命令(where/which)」三段探测,
|
|
210
|
+
* 并以 Edge 作最后兜底(同为 Chromium/CDP,但字体度量可能与 Chrome 不同:
|
|
211
|
+
* 用它跑像素评分前须重跑 training/tools/probe_font_metrics.mjs 校准掩膜口径)。
|
|
212
|
+
*/
|
|
213
|
+
const IS_WINDOWS = process.platform === 'win32'
|
|
214
|
+
|
|
215
|
+
/** 候选值应按文件系统路径(而非 PATH 命令名)解析吗? */
|
|
216
|
+
function looksLikeFsPath(value) {
|
|
217
|
+
return /[\\/]/.test(value) || /\.exe$/i.test(value)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** PATH 命令查找:Windows 用 where,其余用 which;返回首个命中。 */
|
|
221
|
+
function lookupInPath(command) {
|
|
222
|
+
const result = spawnSync(IS_WINDOWS ? 'where' : 'which', [command], { encoding: 'utf8' })
|
|
223
|
+
if (result.status !== 0 || !result.stdout) return null
|
|
224
|
+
const first = String(result.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0]
|
|
225
|
+
return first || null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function chromeCandidates() {
|
|
229
|
+
const programFiles = process.env.ProgramFiles || 'C:\\Program Files'
|
|
230
|
+
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'
|
|
231
|
+
const localAppData = process.env.LOCALAPPDATA || ''
|
|
232
|
+
return [
|
|
205
233
|
process.env.CHROME_PATH,
|
|
206
234
|
process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : null,
|
|
207
235
|
process.platform === 'darwin' ? '/Applications/Chromium.app/Contents/MacOS/Chromium' : null,
|
|
236
|
+
IS_WINDOWS ? path.join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe') : null,
|
|
237
|
+
IS_WINDOWS ? path.join(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe') : null,
|
|
238
|
+
IS_WINDOWS && localAppData ? path.join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe') : null,
|
|
208
239
|
'google-chrome',
|
|
209
240
|
'google-chrome-stable',
|
|
210
241
|
'chromium',
|
|
211
242
|
'chromium-browser',
|
|
212
243
|
'chrome',
|
|
244
|
+
// 兜底:Edge(Chromium 内核,CDP 兼容)
|
|
245
|
+
IS_WINDOWS ? path.join(programFilesX86, 'Microsoft', 'Edge', 'Application', 'msedge.exe') : null,
|
|
246
|
+
IS_WINDOWS ? path.join(programFiles, 'Microsoft', 'Edge', 'Application', 'msedge.exe') : null,
|
|
247
|
+
process.platform === 'darwin' ? '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' : null,
|
|
248
|
+
'microsoft-edge',
|
|
213
249
|
].filter(Boolean)
|
|
250
|
+
}
|
|
214
251
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
252
|
+
function findChromePath() {
|
|
253
|
+
for (const candidate of chromeCandidates()) {
|
|
254
|
+
if (looksLikeFsPath(candidate)) {
|
|
255
|
+
if (fs.existsSync(candidate)) return candidate
|
|
218
256
|
continue
|
|
219
257
|
}
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
258
|
+
const resolved = lookupInPath(candidate)
|
|
259
|
+
if (resolved) return resolved
|
|
222
260
|
}
|
|
223
261
|
return null
|
|
224
262
|
}
|
|
@@ -34,7 +34,7 @@ const SKETCH_SKILL = path.join(skillsDir, 'sketch-spec-to-card')
|
|
|
34
34
|
* 作为提示文本随 next/note 下发,让判定器输出即带门禁,不依赖模型记住文档。
|
|
35
35
|
*/
|
|
36
36
|
const SELECTION_GATE =
|
|
37
|
-
'# 多画板门禁(rules.md
|
|
37
|
+
'# 多画板门禁(rules.md §十三 + §13.4):用 spec_tree.mjs --list-cards 把全量画板按「画板序号.卡片序号」列出(一个画板含 N 张卡则展开为 K.1 ~ K.N)+ 末尾加「全部」;让用户选的方式 = 正文逐条写清单 + 一句提问后停止输出、等用户回序号再继续(**没有** request_user_input 这个工具,不要去找弹窗,也不要替用户挑);序号格式「画板序号.卡片序号」(如 1.2),单独回画板序号默认取该画板第 1 张卡;UI 产物可回「全部」全量生成,产卡默认单卡片(回「全部」才算显式批量授权);禁止自行挑画板或挑卡、禁止擅自批量产卡;清单逐行配图:每个卡片行下紧跟它自己的 card-N.png 绝对路径,同一画板的卡共享画板预览图(artboard.png)但各有聚焦图;与序号一一对应,缺图显式标缺失,禁止把图堆在清单末尾。'
|
|
38
38
|
/**
|
|
39
39
|
* 要素包根目录 UI/:与上游 `sketch-spec-to-card/scripts/parse_meaxure.py` 的 `REPO_ROOT/UI`
|
|
40
40
|
* (按脚本自身路径推算,非 cwd)保持同一锚点,避免「建议命令」与「实际落点」分叉。
|
|
@@ -330,12 +330,16 @@ function route(inputAbs) {
|
|
|
330
330
|
},
|
|
331
331
|
next: [
|
|
332
332
|
specHint,
|
|
333
|
+
...(bundle.artboardBundle ? [] : [
|
|
334
|
+
`# 先用 --list-cards 列出每个画板下的卡片清单(画板序号.卡片序号),交给用户选:`,
|
|
335
|
+
`node ${shortPath(path.join(skillsDir, 'dsl-card-generator', 'scripts', 'spec_tree.mjs'))} --list-cards ${shortPath(inputAbs)}`,
|
|
336
|
+
SELECTION_GATE,
|
|
337
|
+
]),
|
|
333
338
|
`# 再按 SKILL.md §8 Workflow A 六步闭环生成 schema(scaffold_card → add_component/expand_ir → validate+lint → render_diff)`,
|
|
334
|
-
...(bundle.artboardBundle ? [] : [SELECTION_GATE]),
|
|
335
339
|
],
|
|
336
340
|
note: bundle.artboardBundle
|
|
337
341
|
? '输入已是单画板要素包,无需前处理,直接走 Workflow A 生成本卡。'
|
|
338
|
-
: '输入已是 sketch-spec-to-card
|
|
342
|
+
: '输入已是 sketch-spec-to-card 的产物,无需前处理;多画板/多卡时先用 --list-cards 列出画板序号.卡片序号清单让用户选(禁止自行挑、禁止擅自批量,rules.md §十三 §13.4),再走 Workflow A;subcard 与 overlay_dialog 按 Workflow A.5 分流。',
|
|
339
343
|
}
|
|
340
344
|
}
|
|
341
345
|
|
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
* 输出(stdout JSON):
|
|
16
16
|
* { card, design:{scale}, texts[], shapes[], slices[], bands[], gaps{}, colors[], missing[],
|
|
17
17
|
* bindHints[], listCandidates[] }
|
|
18
|
-
* - slices[].verdict ∈ full-asset / missing-file / reused-differs / reused-identical
|
|
18
|
+
* - slices[].verdict ∈ full-asset / missing-file / reused-differs / reused-identical / content-twin
|
|
19
|
+
* · content-twin:导出资产缺文件,但**既有切图里已有像素同内容**(共用/语义重命名/跨画板)→ 直接复用,不裁切
|
|
20
|
+
* · crops[] 按**指纹分组**产出:同组(内容相同)的多个矩形共用一张派生图(alsoRects 列同组其余矩形),
|
|
21
|
+
不再「每个矩形一个 _altN」;派生前先对既有切图做像素级判重,命中给 reuseOf(引用改绑既有名,不裁切)
|
|
19
22
|
* - missing[] 给出建议裁剪矩形(配合 crop_slices.mjs 从聚焦截图派生缺失切图)
|
|
20
23
|
* - slices[].baked 说明切图里是否已含背景 / 标签文字(决定 tile 用单 Image 还是组合节点)
|
|
21
24
|
* - bindHints[] 是「变量候选」:金额 / 百分比 / 日期 / 掩码 / 数量这类一眼动态的文本,
|
|
@@ -26,6 +29,7 @@ import fs from 'node:fs'
|
|
|
26
29
|
import path from 'node:path'
|
|
27
30
|
|
|
28
31
|
const { PNG } = await import('pngjs')
|
|
32
|
+
import { cropPng, findPixelTwin } from './lib/slice_pixels.mjs'
|
|
29
33
|
|
|
30
34
|
function parseArgs(argv) {
|
|
31
35
|
const out = { spec: '', card: 1, slicesDir: '', design: '', projectSlices: '' }
|
|
@@ -53,7 +57,10 @@ function parseArgs(argv) {
|
|
|
53
57
|
|
|
54
58
|
const hex = (r, g, b) => '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('').toUpperCase()
|
|
55
59
|
const pxHex = (png, x, y) => {
|
|
56
|
-
|
|
60
|
+
// clamp 防越界:design 与 card.rect 比例非整倍 / 聚焦裁切带边距时,采样点可能出界 → undefined 崩溃
|
|
61
|
+
const cx = Math.min(png.width - 1, Math.max(0, Math.round(x)))
|
|
62
|
+
const cy = Math.min(png.height - 1, Math.max(0, Math.round(y)))
|
|
63
|
+
const i = (cy * png.width + cx) * 4
|
|
57
64
|
return hex(png.data[i], png.data[i + 1], png.data[i + 2])
|
|
58
65
|
}
|
|
59
66
|
|
|
@@ -97,12 +104,37 @@ function analyzeSlice(file) {
|
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
/** 切图名可能不带扩展名(spec 里存的是图层名),逐个候选扩展名找文件 */
|
|
100
|
-
|
|
107
|
+
/**
|
|
108
|
+
* manifest 别名表:语义重命名(slices_normalize / 手工改名)后,导出原名 → 新名的映射。
|
|
109
|
+
* 读各目录 manifest.json 的 renamedFrom / mergedFrom(§1.1「改名要成套做」的机器可读痕迹)。
|
|
110
|
+
*/
|
|
111
|
+
function buildAliasMap(dirs) {
|
|
112
|
+
const aliases = new Map()
|
|
113
|
+
for (const dir of dirs) {
|
|
114
|
+
const mf = path.join(dir, 'manifest.json')
|
|
115
|
+
if (!fs.existsSync(mf)) continue
|
|
116
|
+
let manifest = null
|
|
117
|
+
try { manifest = JSON.parse(fs.readFileSync(mf, 'utf8')) } catch { continue }
|
|
118
|
+
for (const [key, entry] of Object.entries((manifest && manifest.files) || {})) {
|
|
119
|
+
if (entry && typeof entry.renamedFrom === 'string') aliases.set(entry.renamedFrom, key)
|
|
120
|
+
for (const old of (entry && entry.mergedFrom) || []) if (typeof old === 'string') aliases.set(old, key)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return aliases
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveSliceFile(dirs, name, aliases = null) {
|
|
101
127
|
const base = path.extname(name) ? name : `${name}.png`
|
|
128
|
+
const names = [name]
|
|
129
|
+
const alias = aliases && (aliases.get(name) || aliases.get(base))
|
|
130
|
+
if (alias) names.push(alias)
|
|
102
131
|
for (const dir of dirs) {
|
|
103
|
-
for (const
|
|
104
|
-
const
|
|
105
|
-
|
|
132
|
+
for (const nm of names) {
|
|
133
|
+
const b = path.extname(nm) ? nm : `${nm}.png`
|
|
134
|
+
for (const cand of [b, nm, `${nm}.jpg`, `${nm}.jpeg`, `${nm}.webp`]) {
|
|
135
|
+
const f = path.join(dir, cand)
|
|
136
|
+
if (fs.existsSync(f)) return f
|
|
137
|
+
}
|
|
106
138
|
}
|
|
107
139
|
}
|
|
108
140
|
return null
|
|
@@ -197,35 +229,89 @@ function main() {
|
|
|
197
229
|
const slicesDir = args.slicesDir || path.join(path.dirname(args.spec), 'slices')
|
|
198
230
|
// 派生切图的唯一落点是 src/slices/:已派生过的缺口不应再报 missing(否则每轮都建议重裁)
|
|
199
231
|
const sliceDirs = [slicesDir, ...(args.projectSlices ? [args.projectSlices] : [])]
|
|
232
|
+
const aliases = buildAliasMap(sliceDirs)
|
|
200
233
|
const missing = []
|
|
201
234
|
const slices = []
|
|
235
|
+
/** 矩形按设计稿指纹分组:同组 = 内容相同(同一张图的多次复用),一组只派生一张图 */
|
|
236
|
+
const groupByFingerprint = (rects) => {
|
|
237
|
+
if (!design) return [{ idxs: rects.map((_, i) => i) }]
|
|
238
|
+
const groups = []
|
|
239
|
+
rects.forEach((r, i) => {
|
|
240
|
+
const fp = regionFingerprint(design, r, scale)
|
|
241
|
+
const g = groups.find((x) => x.fp === fp)
|
|
242
|
+
if (g) g.idxs.push(i)
|
|
243
|
+
else groups.push({ fp, idxs: [i] })
|
|
244
|
+
})
|
|
245
|
+
return groups
|
|
246
|
+
}
|
|
247
|
+
/** 设计稿矩形区域 ↔ 既有切图的像素级判重(sha 快路径 + 容差兜底,slice_pixels.mjs)。
|
|
248
|
+
* 覆盖「共用切图已语义重命名 / 在别的画板已派生过」——名字对不上也能认出来。 */
|
|
249
|
+
const regionTwin = (rect) => {
|
|
250
|
+
if (!design) return null
|
|
251
|
+
const region = cropPng(design, Math.round(rect.x * scale), Math.round(rect.y * scale), Math.round(rect.w * scale), Math.round(rect.h * scale))
|
|
252
|
+
for (const dir of sliceDirs) {
|
|
253
|
+
const twin = findPixelTwin(dir, region)
|
|
254
|
+
if (twin) return { twin, dir }
|
|
255
|
+
}
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
202
258
|
for (const [name, rects] of byName) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
259
|
+
let file = resolveSliceFile(sliceDirs, name, aliases)
|
|
260
|
+
let exists = !!file
|
|
261
|
+
let info = exists ? analyzeSlice(file) : null
|
|
206
262
|
const logical = info ? [Math.round(info.px[0] / (info.px[0] / rects[0].w)), Math.round(info.px[1] / (info.px[1] / rects[0].h))] : null
|
|
207
263
|
let verdict = exists ? 'full-asset' : 'missing-file'
|
|
264
|
+
let reuseOf = null
|
|
208
265
|
let crops = null
|
|
266
|
+
const groups = groupByFingerprint(rects)
|
|
209
267
|
if (!exists) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
268
|
+
// 导出资产缺文件:先按指纹组对既有切图做像素级复用;整组命中 → 不裁切直接复用
|
|
269
|
+
const twin0 = regionTwin(rects[groups[0].idxs[0]])
|
|
270
|
+
if (twin0 && groups.length === 1) {
|
|
271
|
+
exists = true
|
|
272
|
+
verdict = 'content-twin'
|
|
273
|
+
reuseOf = twin0.twin
|
|
274
|
+
file = path.join(twin0.dir, twin0.twin)
|
|
275
|
+
info = analyzeSlice(file)
|
|
276
|
+
} else {
|
|
277
|
+
const base0 = path.parse(name).name
|
|
278
|
+
crops = []
|
|
279
|
+
groups.forEach((g, gi) => {
|
|
280
|
+
const rep = rects[g.idxs[0]]
|
|
281
|
+
const cn = gi === 0 ? `${base0}.png` : `${base0}_alt${gi + 1}.png`
|
|
282
|
+
const twin = gi === 0 ? twin0 : regionTwin(rep)
|
|
283
|
+
const entry = { name: cn, exists: !!resolveSliceFile(sliceDirs, cn, aliases), ...rep }
|
|
284
|
+
if (g.idxs.length > 1) entry.alsoRects = g.idxs.slice(1).map((i) => rects[i])
|
|
285
|
+
if (twin) { entry.exists = true; entry.reuseOf = twin.twin; entry.from = path.basename(twin.dir) }
|
|
286
|
+
crops.push(entry)
|
|
287
|
+
if (!entry.exists) missing.push({ reason: 'slice-file-missing', name, crop: entry })
|
|
288
|
+
})
|
|
289
|
+
if (twin0) { verdict = 'content-twin-partial'; reuseOf = twin0.twin }
|
|
290
|
+
}
|
|
213
291
|
} else if (rects.length > 1 && design) {
|
|
214
|
-
const
|
|
215
|
-
const same = fps.every((f) => f === fps[0])
|
|
292
|
+
const same = groups.length === 1
|
|
216
293
|
verdict = same ? 'reused-identical' : 'reused-differs'
|
|
217
294
|
if (!same) {
|
|
218
|
-
//
|
|
219
|
-
// crops 永远给全(带 exists 标记)——模型需要「矩形 → 该用哪张图」的映射;
|
|
220
|
-
//
|
|
295
|
+
// 同一张切图被标到内容不同的多个位置:除第一组外每组一张派生图(组内矩形共用)。
|
|
296
|
+
// crops 永远给全(带 exists / reuseOf 标记)——模型需要「矩形 → 该用哪张图」的映射;
|
|
297
|
+
// 历史事故①:已派生过时 crops 被过滤成 [],模型拿不到映射就自造 icon_xx_左/右 这类
|
|
221
298
|
// 违反命名约定的新切图,同一设计内容在 src/slices 下出现三套名字。
|
|
299
|
+
// 历史事故②(2026-09-18 ynet-card):旧实现只跟 rects[0] 比指纹、每矩形一个 _altN,
|
|
300
|
+
// 内容相同的 5 个矩形裁出 5 份字节级重复副本;且不做既有切图判重,共用图标已重命名
|
|
301
|
+
// 落地仍被重裁。现按指纹分组 + 像素级复用根治。
|
|
222
302
|
const baseN = path.parse(name).name
|
|
223
|
-
crops =
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
303
|
+
crops = []
|
|
304
|
+
for (let gi = 1; gi < groups.length; gi++) {
|
|
305
|
+
const rep = rects[groups[gi].idxs[0]]
|
|
306
|
+
const cn = `${baseN}_alt${gi + 1}.png`
|
|
307
|
+
const entry = { name: cn, exists: !!resolveSliceFile(sliceDirs, cn, aliases), ...rep }
|
|
308
|
+
if (groups[gi].idxs.length > 1) entry.alsoRects = groups[gi].idxs.slice(1).map((i) => rects[i])
|
|
309
|
+
if (!entry.exists) {
|
|
310
|
+
const twin = regionTwin(rep)
|
|
311
|
+
if (twin) { entry.exists = true; entry.reuseOf = twin.twin; entry.from = path.basename(twin.dir) }
|
|
312
|
+
}
|
|
313
|
+
crops.push(entry)
|
|
314
|
+
if (!entry.exists) missing.push({ reason: 'reused-slice-content-differs', name, crop: entry })
|
|
229
315
|
}
|
|
230
316
|
}
|
|
231
317
|
}
|
|
@@ -233,6 +319,7 @@ function main() {
|
|
|
233
319
|
name,
|
|
234
320
|
exists,
|
|
235
321
|
from: file ? path.basename(path.dirname(file)) : null,
|
|
322
|
+
reuseOf,
|
|
236
323
|
rects,
|
|
237
324
|
px: info ? info.px : null,
|
|
238
325
|
baked: info ? { bg: info.bg, label: info.label } : null,
|