cardbuddy 0.14.3 → 0.16.0
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/dist/cli.js +0 -0
- package/dist/preview-editor/assets/index-BkbBxDTT.css +1 -0
- package/dist/preview-editor/assets/index-Bro07DTa.js +313 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +3 -0
- package/dist/vendored/card-editor/runtime/adapter.js +2 -2
- package/dist/vendored/mcp/client.mjs +9 -2
- package/dist/vendored/server/core/ai-chat.js +1702 -134
- package/dist/vendored/server/core/browser.js +12 -3
- package/dist/vendored/server/core/cardFiles.js +3 -0
- package/dist/vendored/server/core/cardHistory.js +1 -0
- package/dist/vendored/server/core/cardImportMeta.js +7 -6
- package/dist/vendored/server/core/cardOpenFolder.js +1 -0
- package/dist/vendored/server/core/cardSave.js +1 -0
- package/dist/vendored/server/core/cardSync.js +3 -0
- package/dist/vendored/server/core/cardValidate.js +16 -5
- package/dist/vendored/server/core/codeBridge.js +5 -1
- package/dist/vendored/server/core/logStream.js +1 -0
- package/dist/vendored/server/core/sliceStatic.js +24 -4
- package/dist/vendored/server/core/sliceUpload.js +1 -0
- package/dist/vendored/server/core/turnJournal.js +6 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +257 -201
- package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +4 -1
- package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +279 -90
- package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
- package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +208 -380
- package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
- package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +233 -61
- package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +202 -4
- package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +87 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +42 -33
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +44 -8
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +26 -6
- package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +54 -0
- package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +6 -6
- package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +33 -4
- package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +133 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +226 -19
- package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +277 -27
- package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +147 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +1015 -54
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5727 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4214 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1110 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +322 -71
- package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +87 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -11
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +272 -46
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
- package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +188 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
- package/dist/vendored/skills/shared/exit-codes.md +79 -0
- package/dist/vendored/skills/shared/spec-contract.md +118 -0
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +43 -20
- package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +4 -4
- package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -9
- package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +546 -91
- package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -5
- package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +217 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +64 -34
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-Cm0USikc.js +0 -307
- package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
- package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
- package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +0 -153
|
@@ -16,22 +16,24 @@ parse_meaxure.py — 解析 Sketch MeaXure 标注导出,视觉驱动生成「
|
|
|
16
16
|
card.json 状态 / 交互 / 样式 / 列表 聚合信息
|
|
17
17
|
states/<画板名>/
|
|
18
18
|
artboard.png 整图(参考)
|
|
19
|
-
card-1.png(...) 聚焦裁切图(每张卡片或每板一图,@2x
|
|
19
|
+
card-1.png(...) 聚焦裁切图(每张卡片或每板一图,@2x;text_only 纯文本板不产,含兜底图)
|
|
20
20
|
spec.json 视觉要素标注(该状态)
|
|
21
21
|
slices/ 切图素材
|
|
22
22
|
clusters.json 卡片聚类 + 交互维度(由 cluster_states.py 补全)
|
|
23
23
|
|
|
24
24
|
用法:
|
|
25
25
|
python parse_meaxure.py --list # 仅列画板
|
|
26
|
-
# 多画板选择门禁:不带 --only/--indices/--sample/--scope-all 的生成运行会打印 1..n+全部 清单并 exit 4,
|
|
27
|
-
#
|
|
26
|
+
# 多画板选择门禁:不带 --pick/--only/--indices/--sample/--scope-all 的生成运行会打印 1..n+全部 清单并 exit 4,
|
|
27
|
+
# 等用户回复序号/「全部」后照抄清单行尾的 --pick 重跑(rules.md §十三)。
|
|
28
28
|
python parse_meaxure.py --sample 5 # 抽样代表性画板
|
|
29
29
|
python parse_meaxure.py --only "未登录,有交易" # 按名称子串
|
|
30
|
-
python parse_meaxure.py --
|
|
30
|
+
python parse_meaxure.py --pick 8.1 # 按清单序号(1 起,可带卡号)——推荐,免换算
|
|
31
|
+
python parse_meaxure.py --indices 0,9,18 # 按 0 基序号(老写法,与 --pick 等价)
|
|
31
32
|
python parse_meaxure.py --src <导出目录> --out <UI> --build
|
|
32
33
|
python parse_meaxure.py --build # 一步式(含 build_prompt)
|
|
33
34
|
"""
|
|
34
35
|
import argparse
|
|
36
|
+
import difflib
|
|
35
37
|
import hashlib
|
|
36
38
|
import json
|
|
37
39
|
import math
|
|
@@ -83,17 +85,6 @@ def hex_of(color_obj):
|
|
|
83
85
|
return None
|
|
84
86
|
|
|
85
87
|
|
|
86
|
-
def rgba_of(color_obj):
|
|
87
|
-
"""MeaXure color -> 'rgba(r,g,b,a)'(含透明度)。"""
|
|
88
|
-
if not isinstance(color_obj, dict):
|
|
89
|
-
return None
|
|
90
|
-
css = color_obj.get("css-rgba") or color_obj.get("css-hsla")
|
|
91
|
-
if css:
|
|
92
|
-
return css
|
|
93
|
-
h = hex_of(color_obj)
|
|
94
|
-
return h
|
|
95
|
-
|
|
96
|
-
|
|
97
88
|
def extract_data(index_html: Path) -> dict:
|
|
98
89
|
html = index_html.read_text(encoding="utf-8", errors="ignore")
|
|
99
90
|
marker = "let data = "
|
|
@@ -301,7 +292,8 @@ def _segment_hint(e, card_w, card_rect=None):
|
|
|
301
292
|
|
|
302
293
|
|
|
303
294
|
def detect_segments(card_els, card_rect):
|
|
304
|
-
"""按 y 几何聚类视觉段。返回 [{y, height,
|
|
295
|
+
"""按 y 几何聚类视觉段。返回 [{y, height, hint, items}](y/height 为卡内相对坐标;
|
|
296
|
+
段间距可由相邻 y/height 推导,不再冗余写 gap_top)。
|
|
305
297
|
规则:
|
|
306
298
|
1. 排除卡外装饰(全屏背景/蒙版:元素在卡 y 轴交集 < 元素高 50%);
|
|
307
299
|
2. decor-bg(装饰背景层)元素**强制独立成段**(背景层是独立容器,其下内容必须切段);
|
|
@@ -351,26 +343,18 @@ def detect_segments(card_els, card_rect):
|
|
|
351
343
|
for i, s in enumerate(segs):
|
|
352
344
|
bottom = max((it["rect"]["y"] + (it["rect"]["height"] or 0)) for it in s["items"])
|
|
353
345
|
top = s["y"]
|
|
354
|
-
|
|
346
|
+
# gap_top 已删(2026-09-24):纯冗余推导值——首段等于 y,后续段可由相邻 y/height 推出
|
|
347
|
+
out.append({
|
|
355
348
|
"y": round(top - cy, 2),
|
|
356
349
|
"height": round(bottom - top, 2),
|
|
357
350
|
"hint": s["hint"],
|
|
358
351
|
"items": len(s["items"]),
|
|
359
|
-
}
|
|
360
|
-
if i == 0:
|
|
361
|
-
seg["gap_top"] = round(top - cy, 2)
|
|
362
|
-
else:
|
|
363
|
-
# 修正坐标混用 bug:统一用卡内相对坐标计算 gap_top
|
|
364
|
-
prev_bottom_rel = out[i - 1]["y"] + out[i - 1]["height"]
|
|
365
|
-
seg["gap_top"] = round(seg["y"] - prev_bottom_rel, 2)
|
|
366
|
-
out.append(seg)
|
|
352
|
+
})
|
|
367
353
|
# 后处理:消除段重叠(前一段底部超出后一段顶部时,截断前一段高度)
|
|
368
354
|
for i in range(1, len(out)):
|
|
369
355
|
overlap = (out[i - 1]["y"] + out[i - 1]["height"]) - out[i]["y"]
|
|
370
356
|
if overlap > 0:
|
|
371
357
|
out[i - 1]["height"] = round(out[i - 1]["height"] - overlap, 2)
|
|
372
|
-
# 重算 gap_top(截断后应 ≥ 0)
|
|
373
|
-
out[i]["gap_top"] = round(out[i]["y"] - (out[i - 1]["y"] + out[i - 1]["height"]), 2)
|
|
374
358
|
return out
|
|
375
359
|
|
|
376
360
|
|
|
@@ -515,25 +499,86 @@ def _deduplicate_shell_elements(els):
|
|
|
515
499
|
return output
|
|
516
500
|
|
|
517
501
|
|
|
518
|
-
def
|
|
519
|
-
"""
|
|
502
|
+
def detect_artboard_cards(ab_raw):
|
|
503
|
+
"""画板级几何分析(不读图、不裁切):检出卡片候选 → 逐块聚焦判定。
|
|
520
504
|
|
|
521
|
-
返回
|
|
505
|
+
返回 dict:W / H / layers / overlay / sheet / conversationFlow /
|
|
506
|
+
cards [(x,y,w,h,els,shapes,focusMetrics)] / rejected [{rect,reason,metrics}] /
|
|
507
|
+
focusSkipReason("" | overlay_dialog_geometry | bottom_sheet_geometry)。
|
|
508
|
+
|
|
509
|
+
这是**唯一**的卡片检出入口:focus_artboard(真跑,随后按卡片裁图)与门禁清单的
|
|
510
|
+
board_verdict(只想知道「这个画板有没有卡可建」)都走它,两处判定不会漂移。
|
|
511
|
+
|
|
512
|
+
底部抽屉(bottom_sheet ⇒ artboardType=subcard)走**几何直取**而非候选检测:
|
|
513
|
+
抽屉面板矩形由 `vision._bottom_sheet_rect` 精确给出,落成 1 张卡。所以
|
|
514
|
+
`focusSkipReason=bottom_sheet_geometry` 与 `cards` 非空**同时成立** —— 该字段的
|
|
515
|
+
语义是「跳过了自动候选检测」,不是「0 卡」。
|
|
522
516
|
"""
|
|
523
517
|
W, H = ab_raw.get("width", 0), ab_raw.get("height", 0)
|
|
524
518
|
raw_layers = ab_raw.get("layers") or []
|
|
525
|
-
|
|
526
|
-
overlay = vision.detect_overlay_dialog(
|
|
519
|
+
flattened = vision._flatten_with_tree(raw_layers)
|
|
520
|
+
overlay = vision.detect_overlay_dialog(flattened, W, H)
|
|
527
521
|
bottom_sheet = vision._bottom_sheet_rect(raw_layers, W, H)
|
|
528
|
-
conversation_flow = vision.detect_conversation_flow(
|
|
522
|
+
conversation_flow = vision.detect_conversation_flow(flattened, W, H)
|
|
523
|
+
cards, rejected, focus_skip_reason = [], [], ""
|
|
529
524
|
if overlay or bottom_sheet:
|
|
530
|
-
cards = []
|
|
531
525
|
focus_skip_reason = (
|
|
532
526
|
"overlay_dialog_geometry" if overlay else "bottom_sheet_geometry"
|
|
533
527
|
)
|
|
528
|
+
# 底部抽屉(alertCard 子卡)产 1 张卡:面板几何 `vision._bottom_sheet_rect`
|
|
529
|
+
# 早已算出精确 rect + 顶圆角,此前却只写 focusSkipReason 不产 cards —— 结果是
|
|
530
|
+
# cluster_states 承认它是独立逻辑卡(pageId=card-N、isDialog=true),而 spec 层
|
|
531
|
+
# 给不出任何卡级数据,下游只能读整图自己猜边界。
|
|
532
|
+
# ⚠️ focusSkipReason 必须保持 bottom_sheet_geometry:`classify_artboard`(:1667)
|
|
533
|
+
# 靠它判 artboardType=subcard,改它会连带打翻 `is_text_only_artboard`(:1643)、
|
|
534
|
+
# `board_verdict` 与 trainer 的 `is_popup_case`(build_dataset.py:188)。
|
|
535
|
+
if bottom_sheet and not overlay:
|
|
536
|
+
sx, sy, sw, sh, s_radii = bottom_sheet
|
|
537
|
+
# min_cover=0.7:抽屉上沿会切过相邻元素(气泡、气泡内文字层的大头刚过沿
|
|
538
|
+
# 就会被默认口径收进来),用覆盖率门槛滤掉;跨沿的抽屉标题(覆盖≈98%)保留。
|
|
539
|
+
sheet_els = vision.elements_in_rect(flattened, (sx, sy, sw, sh), min_cover=0.7)
|
|
540
|
+
cards.append((
|
|
541
|
+
sx, sy, sw, sh, sheet_els,
|
|
542
|
+
[((sx, sy, sw, sh), list(s_radii or []))],
|
|
543
|
+
vision.card_focus_metrics(sheet_els, (sx, sy, sw, sh), W, H),
|
|
544
|
+
))
|
|
534
545
|
else:
|
|
535
|
-
|
|
536
|
-
|
|
546
|
+
for x, y, w, h, els, shapes in vision.detect_cards(raw_layers, W, H, conversation_flow):
|
|
547
|
+
eligible, reason, focus_metrics = vision.classify_card_focus(
|
|
548
|
+
els, (x, y, w, h), W, H, conversation_flow)
|
|
549
|
+
if eligible:
|
|
550
|
+
cards.append((x, y, w, h, els, shapes, focus_metrics))
|
|
551
|
+
else:
|
|
552
|
+
rejected.append({
|
|
553
|
+
"rect": {"x": round(x, 2), "y": round(y, 2),
|
|
554
|
+
"width": round(w, 2), "height": round(h, 2)},
|
|
555
|
+
"reason": reason,
|
|
556
|
+
"metrics": focus_metrics,
|
|
557
|
+
})
|
|
558
|
+
return {
|
|
559
|
+
"W": W, "H": H, "layers": raw_layers,
|
|
560
|
+
"overlay": bool(overlay), "sheet": bool(bottom_sheet),
|
|
561
|
+
"conversationFlow": conversation_flow,
|
|
562
|
+
"cards": cards, "rejected": rejected,
|
|
563
|
+
"focusSkipReason": focus_skip_reason,
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def focus_artboard(ab_raw, preview_path, cdir, canvas_name=None):
|
|
568
|
+
"""对一个画板:检测独立卡片区域 → 结构过滤 → 逐一裁切 → 序列化标注。
|
|
569
|
+
|
|
570
|
+
返回 spec dict(含卡片区域、聚焦截图、样式汇总)。
|
|
571
|
+
|
|
572
|
+
`canvas_name`:写进 `spec["canvas"]` 的画板标识。调用方传**唯一化后**的名字
|
|
573
|
+
(同名画板已追加 -2/-3);缺省回落 Sketch 原始名。下游 `spec_tree.mjs:140`
|
|
574
|
+
用 `spec.canvas || 目录名` 当展示名,两板同名时不传就会在清单里撞成两行一模一样的字。
|
|
575
|
+
"""
|
|
576
|
+
found = detect_artboard_cards(ab_raw)
|
|
577
|
+
W, H = found["W"], found["H"]
|
|
578
|
+
raw_layers = found["layers"]
|
|
579
|
+
overlay, bottom_sheet = found["overlay"], found["sheet"]
|
|
580
|
+
conversation_flow = found["conversationFlow"]
|
|
581
|
+
focus_skip_reason = found["focusSkipReason"]
|
|
537
582
|
|
|
538
583
|
for old_png in cdir.glob("card-*.png"):
|
|
539
584
|
old_png.unlink()
|
|
@@ -544,21 +589,8 @@ def focus_artboard(ab_raw, preview_path, cdir):
|
|
|
544
589
|
elements.append(serialize_element(ve, ve.raw))
|
|
545
590
|
|
|
546
591
|
card_regions = []
|
|
547
|
-
rejected_regions = []
|
|
548
|
-
eligible_cards = []
|
|
549
|
-
for x, y, w, h, els, shapes in cards:
|
|
550
|
-
rect = (x, y, w, h)
|
|
551
|
-
eligible, reject_reason, focus_metrics = vision.classify_card_focus(
|
|
552
|
-
els, rect, W, H, conversation_flow)
|
|
553
|
-
if not eligible:
|
|
554
|
-
rejected_regions.append({
|
|
555
|
-
"rect": {"x": round(x, 2), "y": round(y, 2),
|
|
556
|
-
"width": round(w, 2), "height": round(h, 2)},
|
|
557
|
-
"reason": reject_reason,
|
|
558
|
-
"metrics": focus_metrics,
|
|
559
|
-
})
|
|
560
|
-
continue
|
|
561
|
-
eligible_cards.append((x, y, w, h, els, shapes, focus_metrics))
|
|
592
|
+
rejected_regions = found["rejected"]
|
|
593
|
+
eligible_cards = found["cards"]
|
|
562
594
|
|
|
563
595
|
from build_prompt import detect_list_in_card # 列表检测复用 build_prompt(无循环依赖)
|
|
564
596
|
for i, (x, y, w, h, els, shapes, focus_metrics) in enumerate(eligible_cards, 1):
|
|
@@ -607,16 +639,29 @@ def focus_artboard(ab_raw, preview_path, cdir):
|
|
|
607
639
|
region["detectedList"] = detect_list_in_card(region) or None
|
|
608
640
|
card_regions.append(region)
|
|
609
641
|
|
|
642
|
+
# 纯文本板(对话/问答/提示)连兜底截图都不产:一张整屏裁切图叫 card-1.png,下游
|
|
643
|
+
# 会当成「这个板有卡」(spec_tree.mjs 就拿 card-1.png 当预览降级),正好抵消本次过滤。
|
|
644
|
+
# 判定输入与 classify_artboard 同源:候选全被纯文本类理由否掉 + 板上无卡片级大盘切图。
|
|
645
|
+
text_only = is_text_only_artboard(
|
|
646
|
+
{"cards": card_regions,
|
|
647
|
+
"rejectedCards": rejected_regions,
|
|
648
|
+
"focusSkipReason": focus_skip_reason or (
|
|
649
|
+
"no_structural_card_region" if not card_regions else ""),
|
|
650
|
+
"size": {"width": W, "height": H}},
|
|
651
|
+
raw_layers,
|
|
652
|
+
)
|
|
653
|
+
|
|
610
654
|
# Fallback(2026-08-31):视觉检测 0 个独立卡片区域时,仍裁一张图 card-1.png,
|
|
611
655
|
# 保证每个普通画板都有 card-* 截图(下游聚类/训练参考用)。不写入 card_regions,
|
|
612
656
|
# 避免误触发下游卡片生成(spec.cards 仍空 → status 维持 skipped/generated)。
|
|
613
657
|
# 仅对普通画板生效;overlay_dialog / bottom_sheet 是弹框,不应裁卡截图。
|
|
614
|
-
#
|
|
658
|
+
# 纯文本板(text_only)与对话流画板(气泡/输入坞/键盘)都同样不产兜底图:
|
|
659
|
+
# 整屏截图含对话气泡与纯文本,易被误读为「把整屏做成了卡片」
|
|
615
660
|
# 易被误读为「把整屏做成了卡片」,且 artboard.png 已可承担参考职责。
|
|
616
661
|
# 裁切去掉系统 chrome(状态栏 / Home Indicator),保留应用 UI(顶部导航/底部 tab),
|
|
617
662
|
# 对齐「仅卡片主体」约定。偏移按设计稿高度粗判(刘海屏状态栏 44、老屏 20;
|
|
618
663
|
# Home Indicator 仅刘海屏 34),均为设计 px,crop_png_masked 内部乘 PREVIEW_SCALE。
|
|
619
|
-
if (not card_regions and not overlay and not bottom_sheet
|
|
664
|
+
if (not text_only and not card_regions and not overlay and not bottom_sheet
|
|
620
665
|
and not conversation_flow
|
|
621
666
|
and preview_path and preview_path.exists()):
|
|
622
667
|
try:
|
|
@@ -634,8 +679,7 @@ def focus_artboard(ab_raw, preview_path, cdir):
|
|
|
634
679
|
focus_skip_reason = "conversation_flow_geometry"
|
|
635
680
|
|
|
636
681
|
spec = {
|
|
637
|
-
"canvas": ab_raw.get("name", ""),
|
|
638
|
-
"schemaVersion": "1.0",
|
|
682
|
+
"canvas": canvas_name or ab_raw.get("name", ""),
|
|
639
683
|
"size": {"width": W, "height": H},
|
|
640
684
|
"cards": card_regions,
|
|
641
685
|
"rejectedCards": rejected_regions,
|
|
@@ -1115,17 +1159,6 @@ def _foreground_mean_color(image_path, rect=None, scale=1.0):
|
|
|
1115
1159
|
return (red / count, green / count, blue / count), count
|
|
1116
1160
|
|
|
1117
1161
|
|
|
1118
|
-
def foreground_color_distance(asset_path, preview_path, rect, scale):
|
|
1119
|
-
"""资产与设计裁切的前景均值色距离;任一侧无前景时返回 None。"""
|
|
1120
|
-
asset_mean, asset_count = _foreground_mean_color(asset_path)
|
|
1121
|
-
design_mean, design_count = _foreground_mean_color(preview_path, rect, scale)
|
|
1122
|
-
if not asset_mean or not design_mean:
|
|
1123
|
-
return None
|
|
1124
|
-
return math.sqrt(sum(
|
|
1125
|
-
(left - right) ** 2 for left, right in zip(asset_mean, design_mean)
|
|
1126
|
-
))
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
1162
|
def foreground_chroma_distance(asset_path, preview_path, rect, scale):
|
|
1130
1163
|
"""前景均值色的色度距离(r-g / b-g 两分量)。
|
|
1131
1164
|
|
|
@@ -1198,7 +1231,7 @@ def mark_missing_slice_elements(spec, preview_path, asset_index, asset_root=None
|
|
|
1198
1231
|
element.pop("annotationDefect", None)
|
|
1199
1232
|
element.pop("visualIdentity", None)
|
|
1200
1233
|
spec.pop("annotationDefects", None)
|
|
1201
|
-
image_width, _, _
|
|
1234
|
+
image_width, _, _ = png_utils.read_png_size(preview_path)
|
|
1202
1235
|
design_width = float((spec.get("size") or {}).get("width") or 375)
|
|
1203
1236
|
scale = image_width / design_width if design_width else PREVIEW_SCALE
|
|
1204
1237
|
|
|
@@ -1412,6 +1445,25 @@ def copy_slices(src_assets, dst_slices, matched_rel):
|
|
|
1412
1445
|
d.parent.mkdir(parents=True, exist_ok=True)
|
|
1413
1446
|
shutil.copy2(s, d)
|
|
1414
1447
|
return list(matched_rel or [])
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def require_out_dir(target: Path, what: str) -> None:
|
|
1451
|
+
"""落盘前确认输出目录还在。
|
|
1452
|
+
|
|
1453
|
+
目录是本脚本自己建的(main 里 mkdir(parents=True, exist_ok=True)),但**解析途中**可能被
|
|
1454
|
+
外部清理掉:另一次 `npm run drill` / `cardbuddy init` / `rm -rf` 撞上同一个 UI/ 就会这样。
|
|
1455
|
+
此时 write_text 抛的是一句裸 FileNotFoundError: '…/spec.json',看的人只会以为是脚本不会建
|
|
1456
|
+
目录,排查方向整个跑偏(真实事故:一次 drill 把正在跑的回合连同 UI/ 一起清空了)。
|
|
1457
|
+
这里显式判一次,把「是谁把目录删了」写进结论里。
|
|
1458
|
+
"""
|
|
1459
|
+
if not target.is_dir():
|
|
1460
|
+
raise SystemExit(
|
|
1461
|
+
f"[FAIL] {what}的输出目录在解析途中消失了:{target}\n"
|
|
1462
|
+
f" 脚本开头已建过该目录,多半是另一个进程在清理同一个 UI/ ——\n"
|
|
1463
|
+
f" 比如 npm run drill / cardbuddy init / rm -rf 撞上了本次运行。\n"
|
|
1464
|
+
f" 重跑本命令即可;若反复出现,先确认没有别的清理任务在同时跑。"
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1415
1467
|
# ---------- 画板选择(与业务无关,纯通用关键字) ----------
|
|
1416
1468
|
PRIORITY_KEYWORDS = [
|
|
1417
1469
|
"未登录", "无数据", "无记录", "无交易", "空", "成功", "失败", "错误",
|
|
@@ -1420,10 +1472,96 @@ PRIORITY_KEYWORDS = [
|
|
|
1420
1472
|
]
|
|
1421
1473
|
|
|
1422
1474
|
|
|
1475
|
+
def _split_pick_token(t, total):
|
|
1476
|
+
"""单个 --pick token → (板号, 卡号或 None),越界/非数字就在这里报错。"""
|
|
1477
|
+
board_part, _, card_part = t.partition(".")
|
|
1478
|
+
if not board_part.isdigit():
|
|
1479
|
+
raise SystemExit(f"[ERR] --pick 的画板序号必须是数字(1 起,如 8 或 8.1):{t}")
|
|
1480
|
+
board = int(board_part)
|
|
1481
|
+
if board < 1 or board > total:
|
|
1482
|
+
raise SystemExit(f"[ERR] --pick {t} 超范围:本导出共 {total} 个画板,序号 1..{total}")
|
|
1483
|
+
if card_part and not (card_part.isdigit() and int(card_part) >= 1):
|
|
1484
|
+
raise SystemExit(f"[ERR] --pick 的卡号必须是 1 起的数字(如 8.1):{t}")
|
|
1485
|
+
return board, (int(card_part) if card_part else None)
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def parse_pick(spec, total):
|
|
1489
|
+
"""解析 --pick:画板序号 **1 起**(与清单同口径),可带 `.卡号`(1 起),逗号分隔多个。
|
|
1490
|
+
|
|
1491
|
+
存在意义:清单上是 1 起的序号、--indices 是 0 起,差一位靠脑补——实测模型先用
|
|
1492
|
+
--indices 8 建了错的画板(34s 白跑 + 一步 LLM),自己发现后才重跑 --indices 7。
|
|
1493
|
+
用户说「8.1」时,模型只需把用户原话搬进 --pick,不换算。
|
|
1494
|
+
|
|
1495
|
+
也认清单行尾那种区间写法:`7~9`(连板)、`8.1~8.3`(同板连卡,一板多卡的批量建卡就靠它)。
|
|
1496
|
+
清单提示与解析必须是同一套语法——提示打得出来、解析器却不认,等于让模型照抄一句必错的命令。
|
|
1497
|
+
|
|
1498
|
+
返回 (indices, cards):indices 为 0 基画板索引(给 select_canvases 用,去重保序);
|
|
1499
|
+
cards 为 [(板号, 卡号)],卡号供下游 spec_audit --card 用。
|
|
1500
|
+
"""
|
|
1501
|
+
idxs, cards = [], []
|
|
1502
|
+
|
|
1503
|
+
def add_board(b):
|
|
1504
|
+
if b - 1 not in idxs:
|
|
1505
|
+
idxs.append(b - 1)
|
|
1506
|
+
|
|
1507
|
+
for token in str(spec).split(","):
|
|
1508
|
+
t = token.strip()
|
|
1509
|
+
if not t:
|
|
1510
|
+
continue
|
|
1511
|
+
if "~" not in t:
|
|
1512
|
+
board, card = _split_pick_token(t, total)
|
|
1513
|
+
add_board(board)
|
|
1514
|
+
if card:
|
|
1515
|
+
cards.append((board, card))
|
|
1516
|
+
continue
|
|
1517
|
+
lo, _, hi = t.partition("~")
|
|
1518
|
+
b1, c1 = _split_pick_token(lo.strip(), total)
|
|
1519
|
+
b2, c2 = _split_pick_token(hi.strip(), total)
|
|
1520
|
+
if c1 is None and c2 is None:
|
|
1521
|
+
if b2 < b1:
|
|
1522
|
+
raise SystemExit(f"[ERR] --pick 区间写反了(起点应不大于终点):{t}")
|
|
1523
|
+
for b in range(b1, b2 + 1):
|
|
1524
|
+
add_board(b)
|
|
1525
|
+
elif b1 == b2 and c1 and c2:
|
|
1526
|
+
if c2 < c1:
|
|
1527
|
+
raise SystemExit(f"[ERR] --pick 卡号区间写反了(起点应不大于终点):{t}")
|
|
1528
|
+
add_board(b1)
|
|
1529
|
+
for c in range(c1, c2 + 1):
|
|
1530
|
+
cards.append((b1, c))
|
|
1531
|
+
else:
|
|
1532
|
+
raise SystemExit(
|
|
1533
|
+
f"[ERR] --pick 的区间两端要同型(都带卡号或都不带,如 7~9、8.1~8.3):{t}")
|
|
1534
|
+
if not idxs:
|
|
1535
|
+
raise SystemExit("[ERR] --pick 至少要给一个画板序号")
|
|
1536
|
+
return idxs, cards
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def unique_canvas_names(names):
|
|
1540
|
+
"""同名画板消歧:Sketch 允许两个画板**完全同名**(实测「地址管理」稿 25 板里有两个
|
|
1541
|
+
「11…_失败_再次修改」,一个 375×1232/129 层、一个 375×1252/130 层,是两个不同设计)。
|
|
1542
|
+
|
|
1543
|
+
目录名直接用 name 时,后跑的那个会覆盖先跑的(整板产物——artboard.png / card-N.png /
|
|
1544
|
+
spec.json / slices/ ——静默丢失,且 manifest 里两条 entry 指向同一目录);下游也是按
|
|
1545
|
+
name 拼路径定位(`generate_cards.mjs`:`uiDir/<name>/spec.json`),两行同名时用户
|
|
1546
|
+
在选板清单里同样无法区分。
|
|
1547
|
+
|
|
1548
|
+
故第 2 个及以后的同名画板追加 `-2` / `-3` 后缀,**name 与 slug 同步唯一化**——
|
|
1549
|
+
下游零改动即正确(它们只认 name)。
|
|
1550
|
+
"""
|
|
1551
|
+
seen = {}
|
|
1552
|
+
out = []
|
|
1553
|
+
for nm in names:
|
|
1554
|
+
seen[nm] = seen.get(nm, 0) + 1
|
|
1555
|
+
out.append(nm if seen[nm] == 1 else f"{nm}-{seen[nm]}")
|
|
1556
|
+
return out
|
|
1557
|
+
|
|
1558
|
+
|
|
1423
1559
|
def select_canvases(all_names, args):
|
|
1424
1560
|
n = len(all_names)
|
|
1425
1561
|
if args.list:
|
|
1426
1562
|
return None
|
|
1563
|
+
if args.pick:
|
|
1564
|
+
return parse_pick(args.pick, n)[0]
|
|
1427
1565
|
if args.only:
|
|
1428
1566
|
kws = [k.strip() for k in args.only.split(",") if k.strip()]
|
|
1429
1567
|
return [i for i, nm in enumerate(all_names) if any(k in nm for k in kws)]
|
|
@@ -1509,6 +1647,50 @@ def detect_modal_layers(raw_layers, W=375, H=812):
|
|
|
1509
1647
|
return has_scrim, has_bottom_sheet
|
|
1510
1648
|
|
|
1511
1649
|
|
|
1650
|
+
# 判定「这块区域不是卡片」的拒绝理由全集:纯文本 / 散文段落 / 对话气泡 / 输入坞 / 空区域。
|
|
1651
|
+
# 落在这组里说明区域「本来就不是卡片」,而不是「是卡片但结构弱或结构读错了」——
|
|
1652
|
+
# 后者(single_button_or_bar / repeated_control_bar 之类)一律不在此列:汇率牌价那种宽扁卡
|
|
1653
|
+
# 会被读成「按钮条」,3 宫格选项卡会被读成「控件条」,它们明明是卡,不能当纯文本滤掉。
|
|
1654
|
+
TEXT_ONLY_REJECT_REASONS = {
|
|
1655
|
+
"text_only_or_low_structure_region",
|
|
1656
|
+
"prose_paragraph_region",
|
|
1657
|
+
"conversation_bubble_region",
|
|
1658
|
+
"stacked_conversation_bubbles_region",
|
|
1659
|
+
"conversation_inputDock_region",
|
|
1660
|
+
"conversation_keyboard_region",
|
|
1661
|
+
"empty_region",
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def is_text_only_artboard(spec, raw_layers=None):
|
|
1666
|
+
"""画板级「无卡可建」判定:整屏都是文字的对话 / 问答 / 提示页一律不产卡。
|
|
1667
|
+
|
|
1668
|
+
这类画板(AI 应答气泡 + 输入坞、知识问答、纯文字提示)里没有任何值得落地的卡片结构,
|
|
1669
|
+
留在产卡池里只会让选板清单变噪音、让下游白跑一轮——用户 2026-09-20 规则。
|
|
1670
|
+
|
|
1671
|
+
注意「无卡」≠「纯文本」,收口条件要卡死,否则会把「对话里嵌着一张卡」的画板一起丢掉:
|
|
1672
|
+
· 弹框 / 底部抽屉同样 0 卡,但那是 alertDialog / alertCard 的形态,不在这里判;
|
|
1673
|
+
· 候选区域被否的理由只要有一个不属于「本来就不是卡」(例如 single_button_or_bar,
|
|
1674
|
+
那是「读起来像卡但规则没认」的窄卡)就一律保留——汇率牌价这类宽扁卡就靠这条保住;
|
|
1675
|
+
· 板上有卡片级大盘切图(卡片被整体导成一张图)也保留。
|
|
1676
|
+
只有「所有候选都被纯文本类理由否掉、且找不到任何卡片级结构」才判纯文本。
|
|
1677
|
+
其余「0 卡但结构丰富」的画板(骨架屏、全屏状态页等)保持现状,交给下游裁决。
|
|
1678
|
+
"""
|
|
1679
|
+
if spec.get("cards"):
|
|
1680
|
+
return False
|
|
1681
|
+
if spec.get("focusSkipReason") in ("overlay_dialog_geometry", "bottom_sheet_geometry"):
|
|
1682
|
+
return False
|
|
1683
|
+
# 卡片被设计稿整体导成一张切图时(画板里只剩那张大图),检测器看不到结构,但板上
|
|
1684
|
+
# 明明有卡——这类一律保留,否则「对话 + 内嵌卡片」的状态会被当成纯文本丢掉。
|
|
1685
|
+
if raw_layers:
|
|
1686
|
+
size = spec.get("size") or {}
|
|
1687
|
+
if vision.has_card_like_visual(vision._flatten_with_tree(raw_layers),
|
|
1688
|
+
size.get("width") or 375, size.get("height") or 812):
|
|
1689
|
+
return False
|
|
1690
|
+
reasons = {r.get("reason") for r in (spec.get("rejectedCards") or [])}
|
|
1691
|
+
return bool(reasons) and reasons <= TEXT_ONLY_REJECT_REASONS
|
|
1692
|
+
|
|
1693
|
+
|
|
1512
1694
|
def classify_artboard(name, spec, raw_layers, H=812):
|
|
1513
1695
|
"""返回 (artboardType, interaction)。视觉信号优先,名称仅兜底(保守)。
|
|
1514
1696
|
判定顺序:
|
|
@@ -1527,7 +1709,10 @@ def classify_artboard(name, spec, raw_layers, H=812):
|
|
|
1527
1709
|
return "subcard", "alertCard 子卡(视觉信号:底部抽屉面板,由父卡 alertCard 打开)"
|
|
1528
1710
|
if has_scrim:
|
|
1529
1711
|
return "overlay_dialog", "alertDialog 系统弹框(视觉信号:全屏遮罩)"
|
|
1530
|
-
# 2)
|
|
1712
|
+
# 2) 画板级:纯文本(对话/问答/提示)整板无卡 → text_only,下游不得为其建卡
|
|
1713
|
+
if is_text_only_artboard(spec, raw_layers):
|
|
1714
|
+
return "text_only", "无卡可建(纯文本对话/问答/提示,未检出卡片区域)"
|
|
1715
|
+
# 3) 名称兜底:通用 UI 容器类型词(非业务实体名)
|
|
1531
1716
|
nm = normalize_name(name)
|
|
1532
1717
|
if any(rx.search(nm) for rx in _SUBCARD_RE):
|
|
1533
1718
|
return "subcard", "alertCard 子卡(名称兜底:通用容器类型词)"
|
|
@@ -1606,10 +1791,46 @@ def preview_image_for(src, base, ab, name):
|
|
|
1606
1791
|
return None
|
|
1607
1792
|
|
|
1608
1793
|
|
|
1609
|
-
def
|
|
1794
|
+
def board_tag(verdict):
|
|
1795
|
+
"""清单行尾的体检标记:一眼看出这个画板能不能建卡。"""
|
|
1796
|
+
if not verdict:
|
|
1797
|
+
return ""
|
|
1798
|
+
if verdict["kind"] == "text_only":
|
|
1799
|
+
return " [纯文本,无卡可建 · 已过滤]"
|
|
1800
|
+
if verdict["kind"] == "subcard":
|
|
1801
|
+
return " [子卡 alertCard]"
|
|
1802
|
+
if verdict["kind"] == "overlay_dialog":
|
|
1803
|
+
return " [弹框 alertDialog]"
|
|
1804
|
+
if verdict.get("cards"):
|
|
1805
|
+
return f" [{verdict['cards']} 卡]"
|
|
1806
|
+
return " [0 卡 · 结构丰富,按整图参考建卡]"
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
def board_verdict(name, ab):
|
|
1810
|
+
"""画板体检(纯几何,不读图):这个画板进不进「可建卡选项池」,以及它是什么类型。
|
|
1811
|
+
|
|
1812
|
+
返回 {kind, cards, skip, interaction}。kind 取 classify_artboard 的口径(card /
|
|
1813
|
+
subcard / overlay_dialog / text_only),所以清单上写的话与真跑产物里的 artboardType
|
|
1814
|
+
永远一致——两处都走 detect_artboard_cards + classify_artboard,没有第二套判定。
|
|
1815
|
+
"""
|
|
1816
|
+
ab = ab or {}
|
|
1817
|
+
found = detect_artboard_cards(ab)
|
|
1818
|
+
probe = {
|
|
1819
|
+
"cards": found["cards"],
|
|
1820
|
+
"rejectedCards": found["rejected"],
|
|
1821
|
+
"conversationFlow": found["conversationFlow"],
|
|
1822
|
+
"focusSkipReason": found["focusSkipReason"],
|
|
1823
|
+
"size": {"width": found["W"], "height": found["H"]},
|
|
1824
|
+
}
|
|
1825
|
+
at, interaction = classify_artboard(name, probe, found["layers"], found["H"])
|
|
1826
|
+
return {"kind": at, "cards": len(found["cards"]), "interaction": interaction,
|
|
1827
|
+
"skip": at == "text_only"}
|
|
1828
|
+
|
|
1829
|
+
|
|
1830
|
+
def print_board_line(i, nm, ab, src, base, verdict=None):
|
|
1610
1831
|
"""门禁/清单统一行格式:序号行 + 下一行该画板自己的预览图(一一对应;缺图标缺失)。"""
|
|
1611
1832
|
sz = f'{ab.get("width")}x{ab.get("height")}' if ab else "?"
|
|
1612
|
-
print(f" {i + 1:>2}. {nm} ({sz})")
|
|
1833
|
+
print(f" {i + 1:>2}. {nm} ({sz}){board_tag(verdict)}{pick_hint(i, verdict)}")
|
|
1613
1834
|
img = preview_image_for(src, base, ab, nm)
|
|
1614
1835
|
if img:
|
|
1615
1836
|
print(f" 图: {img}")
|
|
@@ -1617,15 +1838,184 @@ def print_board_line(i, nm, ab, src, base):
|
|
|
1617
1838
|
print(" 图: (预览图缺失:preview/<slug>.png 与 UI 产物 artboard.png 均不存在)")
|
|
1618
1839
|
|
|
1619
1840
|
|
|
1841
|
+
def pick_hint(i, verdict):
|
|
1842
|
+
"""清单行尾的「照抄就能用」参数:板号 1 起、卡号 1 起,直接喂 --pick。
|
|
1843
|
+
|
|
1844
|
+
0 基的 --indices 与用户嘴里的「第 8 个画板第 1 张卡」差一位,实测模型会用错、
|
|
1845
|
+
发现后重跑一整次构建(34s + 一步 LLM)。把可复制的 --pick 打在它自己那行上,
|
|
1846
|
+
模型只需要搬字,不需要换算。
|
|
1847
|
+
"""
|
|
1848
|
+
if verdict and verdict.get("kind") == "text_only":
|
|
1849
|
+
return ""
|
|
1850
|
+
cards = (verdict or {}).get("cards") or 0
|
|
1851
|
+
if cards > 1:
|
|
1852
|
+
return f" --pick {i + 1}.1~{i + 1}.{cards}"
|
|
1853
|
+
if cards == 1:
|
|
1854
|
+
return f" --pick {i + 1}.1"
|
|
1855
|
+
return f" --pick {i + 1}"
|
|
1856
|
+
|
|
1857
|
+
|
|
1858
|
+
def print_board_pool(names, artboards, src, base, all_label):
|
|
1859
|
+
"""清单分两段打:可建卡选项池(带序号 + 各自预览图)与已过滤(纯文本,无卡可建)。
|
|
1860
|
+
|
|
1861
|
+
过滤只针对**整屏纯文本**的画板(is_text_only_artboard):它们放在选项池里只会让用户
|
|
1862
|
+
选到没有卡可建的板子。序号保持全量 1..n 不变(回复 k → --indices k-1 仍对得上),
|
|
1863
|
+
被过滤的板子只列名字、不配图,不制造「这是个选项」的错觉。
|
|
1864
|
+
返回 (可建卡序号列表, 已过滤序号列表)。
|
|
1865
|
+
"""
|
|
1866
|
+
verdicts = [board_verdict(names[i], artboards[i] if i < len(artboards) else None)
|
|
1867
|
+
for i in range(len(names))]
|
|
1868
|
+
pool = [i for i, v in enumerate(verdicts) if not v["skip"]]
|
|
1869
|
+
filtered = [i for i, v in enumerate(verdicts) if v["skip"]]
|
|
1870
|
+
for i in pool:
|
|
1871
|
+
print_board_line(i, names[i], artboards[i] if i < len(artboards) else None,
|
|
1872
|
+
src, base, verdicts[i])
|
|
1873
|
+
print(all_label)
|
|
1874
|
+
if filtered:
|
|
1875
|
+
print(f" —— 另有 {len(filtered)} 个纯文本画板(对话/问答/提示)无卡可建,已过滤不进选项池"
|
|
1876
|
+
f"(序号仍按全量 1..n 编,回复其序号也不会产卡):")
|
|
1877
|
+
for i in filtered:
|
|
1878
|
+
print(f" {i + 1}. {names[i]}")
|
|
1879
|
+
return pool, filtered
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def _candidate_export_dirs(roots, max_depth=3, limit=600):
|
|
1883
|
+
"""在若干根目录下做**有限广度**扫描,收集「含 index.html 的目录」(Sketch MeaXure 导出根的判据)。
|
|
1884
|
+
|
|
1885
|
+
- 深度 3 是实测出来的:真实导出根常住在 `~/Downloads/<资料包>/<已确认的>/<稿名>/`,
|
|
1886
|
+
只扫 2 层会正好漏掉它(本次自测就是这么漏的)。
|
|
1887
|
+
- 命中导出根就不再往下钻(它内部还有 preview/ assets/,再钻只会灌满噪音)。
|
|
1888
|
+
- 按 **realpath** 去重:`/tmp` 与 `/private/tmp` 是同一份目录的两种写法,
|
|
1889
|
+
不去重会让「唯一高分候选」变成两个并列项,把下面的自动修正判据直接顶掉。
|
|
1890
|
+
"""
|
|
1891
|
+
found, seen, seen_real = [], set(), set()
|
|
1892
|
+
for root in roots:
|
|
1893
|
+
root = Path(root).expanduser()
|
|
1894
|
+
if not root.is_dir():
|
|
1895
|
+
continue
|
|
1896
|
+
stack = [(root, 0)]
|
|
1897
|
+
while stack:
|
|
1898
|
+
d, depth = stack.pop()
|
|
1899
|
+
key = str(d)
|
|
1900
|
+
if key in seen:
|
|
1901
|
+
continue
|
|
1902
|
+
seen.add(key)
|
|
1903
|
+
# 探测整体兜住 OSError:本机 python 是 3.9,`Path.is_file()` 在无权限目录上**会抛
|
|
1904
|
+
# PermissionError**(3.12 起才改成返回 False)。扫到 /private/var/root 这类目录时不兜,
|
|
1905
|
+
# 整个脚本就崩在探测里——比原来「直接报错」还糟。
|
|
1906
|
+
try:
|
|
1907
|
+
if (d / "index.html").is_file():
|
|
1908
|
+
real = str(d.resolve())
|
|
1909
|
+
if real not in seen_real:
|
|
1910
|
+
seen_real.add(real)
|
|
1911
|
+
found.append(d)
|
|
1912
|
+
continue
|
|
1913
|
+
if depth >= max_depth:
|
|
1914
|
+
continue
|
|
1915
|
+
children = sorted(d.iterdir())
|
|
1916
|
+
except (PermissionError, OSError):
|
|
1917
|
+
continue
|
|
1918
|
+
for child in children:
|
|
1919
|
+
if child.name.startswith("."):
|
|
1920
|
+
continue
|
|
1921
|
+
# 同上的 3.9 坑:`is_dir()` 在受保护目录(如 /private/var/db/DifferentialPrivacy)
|
|
1922
|
+
# 上会抛 PermissionError,逐个子项兜
|
|
1923
|
+
try:
|
|
1924
|
+
if child.is_dir():
|
|
1925
|
+
stack.append((child, depth + 1))
|
|
1926
|
+
except (PermissionError, OSError):
|
|
1927
|
+
continue
|
|
1928
|
+
if len(found) > limit:
|
|
1929
|
+
return found
|
|
1930
|
+
return found
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
def _path_affinity(src, cand):
|
|
1934
|
+
"""候选与请求路径的「同侧程度」= 公共父级目录层数。
|
|
1935
|
+
|
|
1936
|
+
同一份稿常被拷到 Downloads 与 Desktop 两处(同名同分)。此时该选离调用方给的路径更近的那个,
|
|
1937
|
+
而不是让两个同分候选把自动修正判据顶掉。
|
|
1938
|
+
"""
|
|
1939
|
+
n = 0
|
|
1940
|
+
for x, y in zip(src.parent.parts, cand.parent.parts):
|
|
1941
|
+
if x != y:
|
|
1942
|
+
break
|
|
1943
|
+
n += 1
|
|
1944
|
+
return n
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
def _dir_mtime(d):
|
|
1948
|
+
"""目录最近改动时间(探测失败取 0):没给 --src 时用它把「刚拖进来的那份稿」排前面。"""
|
|
1949
|
+
try:
|
|
1950
|
+
return d.stat().st_mtime
|
|
1951
|
+
except (PermissionError, OSError):
|
|
1952
|
+
return 0.0
|
|
1953
|
+
|
|
1954
|
+
|
|
1955
|
+
def resolve_src(raw_src, explicit=True):
|
|
1956
|
+
"""把 --src 解析成真实导出根:能唯一定位就自动修正,否则回一份候选清单。
|
|
1957
|
+
|
|
1958
|
+
实测两次白烧(2026-09-21 建卡回合各烧掉一步工具调用):
|
|
1959
|
+
① 没带 --src ⇒ 默认 cwd(工程根)没有 index.html,直接 [ERR] 退出;
|
|
1960
|
+
② 目录名拼重复(黑马大赛-黑马大赛积分助手ui设计稿)⇒ 路径不存在,同样直接退出。
|
|
1961
|
+
两次都「差一点点就对」。这里把它们变成一次自动修正,或一份可直接照抄的候选清单。
|
|
1962
|
+
|
|
1963
|
+
自动修正只在高置信时发生(名字高度相似且明显强于第二名),且必定打 [WARN] 说明——
|
|
1964
|
+
宁可让调用方多看一眼,也不要在多稿目录里默默建错稿。
|
|
1965
|
+
|
|
1966
|
+
@return (src_or_None, ranked) —— ranked 为 [(相似度, Path), ...],供失败时打印候选。
|
|
1967
|
+
"""
|
|
1968
|
+
src = Path(raw_src).expanduser()
|
|
1969
|
+
if (src / "index.html").is_file():
|
|
1970
|
+
return src, []
|
|
1971
|
+
|
|
1972
|
+
roots = []
|
|
1973
|
+
for r in (Path.cwd(), Path.cwd().parent, Path.home() / "Downloads", Path.home() / "Desktop"):
|
|
1974
|
+
if r not in roots:
|
|
1975
|
+
roots.append(r)
|
|
1976
|
+
# 调用方给的路径本身是条线索:它的父目录最值得先扫
|
|
1977
|
+
if src.parent.is_dir() and src.parent not in roots:
|
|
1978
|
+
roots.insert(0, src.parent)
|
|
1979
|
+
|
|
1980
|
+
cands = _candidate_export_dirs(roots)
|
|
1981
|
+
if not cands:
|
|
1982
|
+
return None, []
|
|
1983
|
+
want = src.name or ""
|
|
1984
|
+
ranked = sorted(
|
|
1985
|
+
((difflib.SequenceMatcher(None, want, c.name).ratio(), _path_affinity(src, c), c) for c in cands),
|
|
1986
|
+
key=lambda x: (-x[0], -x[1]),
|
|
1987
|
+
)
|
|
1988
|
+
# ① 没显式给 --src:全库只有一个导出根 ⇒ 就是它;否则按「最近改动」排候选
|
|
1989
|
+
# (刚拖进来的那份稿排最前,比按名字乱序有用)
|
|
1990
|
+
if not explicit:
|
|
1991
|
+
if len(cands) == 1:
|
|
1992
|
+
return cands[0], ranked
|
|
1993
|
+
recent = sorted(cands, key=_dir_mtime, reverse=True)
|
|
1994
|
+
return None, [(0.0, 0, c) for c in recent]
|
|
1995
|
+
best_ratio, _aff, best = ranked[0]
|
|
1996
|
+
# ② 给了但拼错:名字高度相似、且明显强于**名字不同的**第二名,才敢自动修正。
|
|
1997
|
+
# 同名候选(同一份稿拷到 Downloads 与 Desktop)是同一个答案、不是竞争答案,
|
|
1998
|
+
# 不排除它会让「唯一定位」永远不成立。
|
|
1999
|
+
rival = next((r for r, _a, c in ranked if c.name != best.name), None)
|
|
2000
|
+
if best_ratio >= 0.75 and (rival is None or rival < best_ratio - 0.05):
|
|
2001
|
+
return best, ranked
|
|
2002
|
+
return None, ranked
|
|
2003
|
+
|
|
2004
|
+
|
|
1620
2005
|
def main():
|
|
1621
2006
|
ap = argparse.ArgumentParser(description="Sketch MeaXure 标注 → 卡片要素包(视觉驱动)")
|
|
1622
|
-
ap.add_argument("--src", default=
|
|
2007
|
+
ap.add_argument("--src", default=None,
|
|
2008
|
+
help="Sketch MeaXure 导出根(含 index.html)。缺省或用错时自动探测:能唯一定位就自动修正,"
|
|
2009
|
+
"否则打印候选清单(照抄其一作为 --src)")
|
|
1623
2010
|
ap.add_argument("--out", default=str(DEFAULT_OUT))
|
|
1624
2011
|
ap.add_argument("--list", action="store_true")
|
|
1625
2012
|
ap.add_argument("--scope-all", action="store_true",
|
|
1626
2013
|
help="显式授权跑全部画板:仅当用户回复「全部」后才可加(无人值守批量训练可直接加或设 SKETCH_SPEC_SCOPE_ALL=1)")
|
|
1627
2014
|
ap.add_argument("--only", default=None)
|
|
1628
2015
|
ap.add_argument("--indices", default=None)
|
|
2016
|
+
ap.add_argument("--pick", default=None,
|
|
2017
|
+
help="按清单序号选板(1 起,可带卡号:8 或 8.1,逗号分隔多个)——"
|
|
2018
|
+
"清单行尾的 --pick 值照抄即用,不必换算 0 基的 --indices")
|
|
1629
2019
|
ap.add_argument("--sample", type=int, default=0)
|
|
1630
2020
|
ap.add_argument("--build", action="store_true")
|
|
1631
2021
|
ap.add_argument("--artboard-max-side", type=int, default=640,
|
|
@@ -1634,13 +2024,26 @@ def main():
|
|
|
1634
2024
|
help="按稿分片(可选):画板落盘到 <out>/<draft>/<canvas>/,并写 <out>/<draft>/manifest.json;缺省平铺(兼容旧布局)")
|
|
1635
2025
|
args = ap.parse_args()
|
|
1636
2026
|
|
|
1637
|
-
|
|
2027
|
+
explicit_src = args.src is not None
|
|
2028
|
+
raw_src = args.src or str(DEFAULT_SRC)
|
|
2029
|
+
src, cands = resolve_src(raw_src, explicit=explicit_src)
|
|
1638
2030
|
out = Path(args.out).expanduser().resolve()
|
|
1639
2031
|
# 按稿分片:指定 --draft 时,所有画板与 manifest 落在 <out>/<draft>/ 下(UI 多稿治理,item 19)
|
|
1640
2032
|
base = out / args.draft if args.draft else out
|
|
1641
|
-
if
|
|
1642
|
-
|
|
2033
|
+
if src is None:
|
|
2034
|
+
wanted = Path(raw_src).expanduser() / "index.html"
|
|
2035
|
+
if cands:
|
|
2036
|
+
print(f"[ERR] {wanted} 不存在——它不是 Sketch MeaXure 导出根(导出根必须直接含 index.html)", file=sys.stderr)
|
|
2037
|
+
print("[ERR] 已发现这些导出根,照抄其一作为 --src 重跑:", file=sys.stderr)
|
|
2038
|
+
for _ratio, _aff, c in cands[:8]:
|
|
2039
|
+
print(f" --src {c}", file=sys.stderr)
|
|
2040
|
+
else:
|
|
2041
|
+
print(f"[ERR] 未找到 {wanted},且常见位置(cwd / 上级 / ~/Downloads / ~/Desktop)也没扫到导出根。", file=sys.stderr)
|
|
2042
|
+
print("[ERR] 请用 --src 指向 Sketch MeaXure 导出目录(该目录下应直接有 index.html)。", file=sys.stderr)
|
|
1643
2043
|
sys.exit(1)
|
|
2044
|
+
if src != Path(raw_src).expanduser():
|
|
2045
|
+
why = "未显式指定 --src" if not explicit_src else f"--src={Path(raw_src).expanduser()} 下没有 index.html"
|
|
2046
|
+
print(f"[WARN] {why}:已自动选用 {src}(含 index.html)。若不对,请显式 --src 指定。", file=sys.stderr)
|
|
1644
2047
|
|
|
1645
2048
|
data = extract_data(src / "index.html")
|
|
1646
2049
|
artboards = data.get("artboards", [])
|
|
@@ -1648,36 +2051,52 @@ def main():
|
|
|
1648
2051
|
print(f"[INFO] 解析到 {total} 个画布(来源:{src.name})")
|
|
1649
2052
|
|
|
1650
2053
|
names = [ab.get("name", f"artboard-{i}") for i, ab in enumerate(artboards)]
|
|
2054
|
+
# 同名画板唯一化:name 同时是目录名、manifest 标识与下游定位键,必须唯一(详见
|
|
2055
|
+
# unique_canvas_names)。清单展示也用唯一名——用户看到的行与落盘目录、下游定位键三者一致。
|
|
2056
|
+
dir_names = unique_canvas_names(names)
|
|
1651
2057
|
if args.list:
|
|
1652
2058
|
# 展示统一 1 起(与多画板门禁清单同口径);--indices 仍为 0 起,用户序号 k → k-1
|
|
1653
2059
|
# 每个画板行下面紧跟它自己那张预览图(一一对应;缺图显式标缺失),禁止把图堆在末尾
|
|
1654
|
-
|
|
1655
|
-
print_board_line(i, nm, artboards[i] if i < len(artboards) else None, src, base)
|
|
1656
|
-
print(" 全部. 全部")
|
|
2060
|
+
print_board_pool(dir_names, artboards, src, base, " 全部. 全部")
|
|
1657
2061
|
return
|
|
1658
2062
|
|
|
1659
2063
|
# ── 多画板选择门禁(rules.md §十三 / SKILL.md §2 步骤 2,2026-09-17 机械兜底)──
|
|
1660
2064
|
# 未指定范围的生成运行必须先让用户从 1..n + 全部 里选;「全部」是用户的回答,不是 Agent 的默认值。
|
|
1661
2065
|
# 这里做机械卡点:打印清单后 exit 4,拿到用户回答再用 --indices / --only / --scope-all 重跑。
|
|
1662
|
-
has_scope = bool(args.only or args.indices or args.sample)
|
|
2066
|
+
has_scope = bool(args.only or args.indices or args.pick or args.sample)
|
|
1663
2067
|
scope_all_env = os.environ.get("SKETCH_SPEC_SCOPE_ALL", "") in ("1", "true", "yes")
|
|
1664
2068
|
if not has_scope and not args.scope_all and not scope_all_env:
|
|
1665
2069
|
print(f"[GATE] 多画板选择门禁(rules.md §十三):本导出共 {total} 个画板,本次运行未指定范围"
|
|
1666
2070
|
f"(--only/--indices/--sample/--scope-all 均未给)。")
|
|
1667
2071
|
print("[GATE] 按铁律必须先把下面的清单(1..n + 全部)交给用户并**停止输出等回复**,"
|
|
1668
2072
|
"不得自行跑全量(「生成产物 / 前处理」请求同样适用):")
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
2073
|
+
print("[GATE] 清单已按体检结果分段:纯文本画板(对话/问答/提示)无卡可建,已过滤出选项池。")
|
|
2074
|
+
print_board_pool(dir_names, artboards, src, base,
|
|
2075
|
+
" 全部. 全部(整份导出全量跑 UI 产物;产卡时视为显式批量授权"
|
|
2076
|
+
"——纯文本画板只出参考图,不出卡)")
|
|
2077
|
+
print("[GATE] 拿到回复后重跑:**直接照抄清单行尾的 --pick**(板号 1 起、卡号 1 起,"
|
|
2078
|
+
"如用户说「8.1」就 --pick 8.1;多张逗号分隔 --pick 8.1,9.2);"
|
|
2079
|
+
"等价的老写法是 --indices <k-1>(0 起)。回复「全部」→ --scope-all;"
|
|
2080
|
+
"无人值守批量训练可直接 --scope-all 或 SKETCH_SPEC_SCOPE_ALL=1。")
|
|
1674
2081
|
sys.exit(4)
|
|
1675
2082
|
|
|
1676
|
-
sel = select_canvases(
|
|
2083
|
+
sel = select_canvases(dir_names, args)
|
|
1677
2084
|
if not sel:
|
|
1678
2085
|
print("[WARN] 没有匹配到任何画布。", file=sys.stderr)
|
|
1679
2086
|
sys.exit(0)
|
|
1680
|
-
|
|
2087
|
+
# 显式点名纯文本画板时不拦(用户可能只想拿参考图),但必须说清楚不会出卡
|
|
2088
|
+
for i in sel:
|
|
2089
|
+
v = board_verdict(dir_names[i], artboards[i] if i < len(artboards) else None)
|
|
2090
|
+
if v["skip"]:
|
|
2091
|
+
print(f"[WARN] {dir_names[i]}:纯文本画板(对话/问答/提示),无卡可建——"
|
|
2092
|
+
f"本次只会产出整图参考与判定依据,不会出卡。", file=sys.stderr)
|
|
2093
|
+
print(f"[INFO] 本次生成 {len(sel)} 个画布:{', '.join(dir_names[i] for i in sel)}")
|
|
2094
|
+
if args.pick:
|
|
2095
|
+
_, picked_cards = parse_pick(args.pick, total)
|
|
2096
|
+
if picked_cards:
|
|
2097
|
+
print("[PICK] 卡号提示:" + " / ".join(
|
|
2098
|
+
f"画板 {b} 第 {c} 张卡 → 下游 spec_audit.mjs 用 --card {c}" for b, c in picked_cards))
|
|
2099
|
+
print(f"[PICK] 等价写法 --indices {','.join(str(i) for i in sel)}(0 起)")
|
|
1681
2100
|
|
|
1682
2101
|
assets_dir = src / "assets"
|
|
1683
2102
|
asset_index = build_asset_index(assets_dir)
|
|
@@ -1710,7 +2129,8 @@ def main():
|
|
|
1710
2129
|
previous_entries = {}
|
|
1711
2130
|
|
|
1712
2131
|
for i, ab in enumerate(artboards):
|
|
1713
|
-
name =
|
|
2132
|
+
name = dir_names[i] # 目录名 / manifest 标识 / 下游定位键(同名已唯一化)
|
|
2133
|
+
orig_name = names[i] # Sketch 原始名:只用于分类兜底与预览图兜底探测
|
|
1714
2134
|
status = "generated" if i in sel else "pending"
|
|
1715
2135
|
entry = {
|
|
1716
2136
|
"order": i + 1,
|
|
@@ -1719,6 +2139,9 @@ def main():
|
|
|
1719
2139
|
"status": status,
|
|
1720
2140
|
"trainingEligible": False,
|
|
1721
2141
|
}
|
|
2142
|
+
if name != orig_name:
|
|
2143
|
+
# 同名画板被消歧(name 追加了 -2/-3):留下原始名便于人回溯这板在 Sketch 里叫什么
|
|
2144
|
+
entry["nameOriginal"] = orig_name
|
|
1722
2145
|
if i not in sel and name in previous_entries:
|
|
1723
2146
|
for inherited in ("status", "trainingEligible", "artboardType", "preview"):
|
|
1724
2147
|
if inherited in previous_entries[name]:
|
|
@@ -1734,7 +2157,11 @@ def main():
|
|
|
1734
2157
|
preview_rel = unquote(ab.get("imagePath", "") or "")
|
|
1735
2158
|
src_png = (src / preview_rel) if preview_rel else None
|
|
1736
2159
|
if not (src_png and src_png.is_file()):
|
|
1737
|
-
|
|
2160
|
+
# 注意:只对 orig_name 兜底——唯一化后缀(-2)在导出目录里不存在,
|
|
2161
|
+
# 拿 name 去 glob 必然空手。
|
|
2162
|
+
# 已知局限:同名画板且 imagePath 也缺失时,两板 tail 相同、glob 命中多张
|
|
2163
|
+
# 且顺序不定,可能取到同一张图。这是「双缺失」场景,属兜底兜不住,不在此展开。
|
|
2164
|
+
cand = [c for c in src.glob(f"preview/*{orig_name.split('_', 1)[-1]}*.png") if c.is_file()]
|
|
1738
2165
|
src_png = cand[0] if cand else None
|
|
1739
2166
|
if src_png:
|
|
1740
2167
|
shutil.copy2(src_png, cdir / "artboard.png")
|
|
@@ -1754,16 +2181,20 @@ def main():
|
|
|
1754
2181
|
print(f" [WARN] {name}: 找不到整图 {preview_rel}", file=sys.stderr)
|
|
1755
2182
|
|
|
1756
2183
|
# 视觉聚焦:卡片区域检测 + 裁切
|
|
1757
|
-
spec
|
|
2184
|
+
# 传唯一名:spec.canvas 是下游展示名(spec_tree.mjs:140)与 prompt.md 标题
|
|
2185
|
+
spec = focus_artboard(ab, src_png, cdir, canvas_name=name)
|
|
1758
2186
|
|
|
1759
2187
|
# 交互分类(视觉信号优先,名称兜底 — 详见 classify_artboard)
|
|
1760
|
-
|
|
2188
|
+
# 传 orig_name:名称模式是子串匹配,唯一化后缀(-2)不该参与语义判定
|
|
2189
|
+
at, interaction = classify_artboard(orig_name, spec, ab.get("layers") or [], ab.get("height", 812))
|
|
1761
2190
|
spec["artboardType"] = at
|
|
1762
2191
|
spec["interaction"] = interaction
|
|
1763
2192
|
# JSAPI 交互控件检测(密码/验证码/数字键盘/toast/二次确认)
|
|
1764
2193
|
spec["jsapiInteractions"] = detect_jsapi_interactions(spec, ab.get("layers") or [])
|
|
1765
2194
|
entry["artboardType"] = at
|
|
1766
|
-
|
|
2195
|
+
# 训练资格:卡片类型 + 真检出卡片区域。0 卡的画板(纯文本对话/问答/提示)没有
|
|
2196
|
+
# 可训练对象,标了 true 会一路污染训练集,这里按「有没有卡」收口。
|
|
2197
|
+
entry["trainingEligible"] = at == "card" and bool(spec["cards"])
|
|
1767
2198
|
|
|
1768
2199
|
# 切图:逐元素建立导出映射,未命中素材的标注降级为整图采样纯色。
|
|
1769
2200
|
slice_names = spec.pop("slices", [])
|
|
@@ -1779,13 +2210,36 @@ def main():
|
|
|
1779
2210
|
)
|
|
1780
2211
|
spec["slices"] = slices_rel
|
|
1781
2212
|
|
|
2213
|
+
# 切图索引(C5):slices/ 是嵌套目录(如 8.icon/通用/警示-#999@3x.png),没有索引时
|
|
2214
|
+
# 模型只能 fs_list 逐层摸索真名(gen-1 实测 6 次 fs_list + 2 次 fs_copy 手工搬运)。
|
|
2215
|
+
# 有了索引,第一次写 IR 就能对上真名(引用名按 R31 消毒,expand_ir 递归命中)。
|
|
2216
|
+
try:
|
|
2217
|
+
_slice_root = cdir / "slices"
|
|
2218
|
+
_slice_idx = [
|
|
2219
|
+
{"file": str(p.relative_to(_slice_root)).replace(os.sep, "/"), "bytes": p.stat().st_size}
|
|
2220
|
+
for p in sorted(_slice_root.rglob("*"))
|
|
2221
|
+
if p.is_file() and p.name != "index.json" and p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".svg"}
|
|
2222
|
+
]
|
|
2223
|
+
(_slice_root / "index.json").write_text(
|
|
2224
|
+
json.dumps(_slice_idx, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
2225
|
+
except Exception as e: # noqa
|
|
2226
|
+
print(f" [WARN] slices/index.json 写入失败(不阻塞要素包):{e}", file=sys.stderr)
|
|
2227
|
+
|
|
1782
2228
|
# 画板是否无卡(0 卡 → 标记跳过,保留整图供参考)
|
|
1783
2229
|
# 仅当既无独立卡片、又元素极少(<3)才视为真正空画板;
|
|
1784
2230
|
# 元素丰富但无卡的情况可能是底部弹窗/全屏覆盖层,保留 status 供下游决策。
|
|
1785
|
-
if
|
|
2231
|
+
if at == "text_only":
|
|
2232
|
+
# 整屏纯文本(对话/问答/提示):没有任何可建卡结构,直接跳过不产卡。
|
|
2233
|
+
# 选板清单里也不再作为候选出现(见 print_board_line / board_verdict)。
|
|
2234
|
+
entry["status"] = "skipped"
|
|
2235
|
+
spec["skippedReason"] = (
|
|
2236
|
+
"纯文本画板(对话/问答/提示):未检出任何可建卡区域,不产卡"
|
|
2237
|
+
)
|
|
2238
|
+
elif not spec["cards"] and len(spec.get("elements", [])) < 3:
|
|
1786
2239
|
entry["status"] = "skipped"
|
|
1787
2240
|
spec["skippedReason"] = "视觉检测无独立卡片元素(纯对话/提示/无卡片画板)"
|
|
1788
2241
|
|
|
2242
|
+
require_out_dir(cdir, f"画板 {name} 的要素包")
|
|
1789
2243
|
(cdir / "spec.json").write_text(
|
|
1790
2244
|
json.dumps(spec, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
1791
2245
|
|
|
@@ -1796,6 +2250,7 @@ def main():
|
|
|
1796
2250
|
|
|
1797
2251
|
manifest["canvases"].append(entry)
|
|
1798
2252
|
|
|
2253
|
+
require_out_dir(base, "画板清单")
|
|
1799
2254
|
(base / "manifest.json").write_text(
|
|
1800
2255
|
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
1801
2256
|
print(f"[DONE] manifest.json 已写入 {base / 'manifest.json'}")
|