Files
Shuery 3f11426964 feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源
把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
2026-10-02 01:27:02 +08:00

166 lines
6.9 KiB
Python

"""场景的选择:人写的 `sources.yml` 优先,其次机器写的 `selection.yml`,最后才问人。
为什么要第二份文件:`sources.yml` 是人手写的(注释、顺序、措辞都是人的),用 PyYAML 回写会把
这些全吃掉。所以机器只写自己那一份 `selection.yml`,人写的文件永远不被机器改写。
优先级(明确写死,避免两份文件打架):
1. `sources.yml` 里这一页写了 `scene:` / `spines:` → 用它(人的意志最高)
2. `selection.yml` 里有这一页的记录 → 用它(上次交互的结果)
3. 都没有 → 默认取骨架最多的场景 + 该场景全部骨架;
开了 `--interactive` 才问人
**这份选择不再裁剪产物**:`fetch` 会把页面里每个有资源的场景都落成一个壁纸目录(见
`tools/downloader/README.md`)。选择只决定"哪一档是推荐的"——写进 `page.json` 的
`chosenScene` / `chosenDir`,当 `promote` 没给 `--scene` 时的默认值。记录里的 `spines` /
`images` 字段保留只为兼容旧文件,不再有任何裁剪语义。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Sequence
import yaml
__all__ = ["Choice", "load_selection", "save_selection", "resolve", "ask"]
_HEADER = (
"# 场景/骨架选择记录(由 `python -m tools.downloader select` 写入,机器所有)。\n"
"# 人写的来源清单是 wallpapers/sources.yml;这里只记「哪一页推荐哪个场景」。\n"
"# 入库是为了换台机器重跑能复现同一份推荐值。\n"
"# 注意:fetch 会落盘**全部**有资源的场景,这份记录不再裁剪产物。\n"
"# 优先级:sources.yml 的显式声明 > 本文件 > 默认值(骨架最多的场景)。\n"
)
@dataclass
class Choice:
"""一页的选择结果。"""
page: str
scene: str
spines: list[str] = field(default_factory=list)
source: str = "default" # default | sources | selection | interactive
images: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {"scene": self.scene, "spines": list(self.spines), "images": list(self.images)}
def load_selection(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return data if isinstance(data, dict) else {}
def save_selection(path: Path, selection: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
body = yaml.safe_dump(selection, allow_unicode=True, sort_keys=True, default_flow_style=False)
path.write_text(_HEADER + body, encoding="utf-8")
def resolve(
page: str,
scenes: Sequence[Any],
*,
sources_entry: dict[str, Any] | None = None,
selection: dict[str, Any] | None = None,
default_scene: str | None = None,
default_spines: Iterable[str] = (),
interactive: bool = False,
input_fn: Any = input,
output_fn: Any = print,
) -> Choice:
"""按优先级算出一页要抓的场景与骨架。"""
selection = selection or {}
record = selection.get(page) or {}
scene = None
spines: list[str] = []
source = "default"
if sources_entry and sources_entry.get("scene"):
scene = str(sources_entry["scene"])
source = "sources"
if sources_entry and sources_entry.get("spines"):
spines = [str(s) for s in sources_entry["spines"]]
source = "sources"
if scene is None and record.get("scene"):
scene = str(record["scene"])
source = "selection"
if not spines and record.get("spines"):
spines = [str(s) for s in record["spines"]]
if source == "default":
source = "selection"
if scene is None:
scene = default_scene
if not spines:
spines = list(default_spines)
# 记录里的场景若一个骨架都没有,它就是个**选不出壁纸**的记录(交互误选、或页面改版了)。
# 这种情况退回默认并标明来源,别让人拿到一个必然报红的产物。
if scenes and scene and scene != default_scene:
chosen_scene = next((s for s in scenes if s.id == scene), None)
if chosen_scene is not None and not chosen_scene.spine_ids and default_scene:
source = f"{source}(无效→默认)"
scene = default_scene
spines = list(default_spines)
if interactive and scenes:
chosen = ask(page, scenes, scene=scene, spines=spines, input_fn=input_fn, output_fn=output_fn)
if chosen is not None:
scene, spines = chosen
source = "interactive"
return Choice(page=page, scene=scene or "", spines=spines, source=source)
def ask(
page: str,
scenes: Sequence[Any],
*,
scene: str | None,
spines: Sequence[str],
input_fn: Any = input,
output_fn: Any = print,
) -> tuple[str, list[str]] | None:
"""交互式选择:只选**推荐场景**。
为什么不再多选骨架:产物现在是"一个场景一个自包含的壁纸目录",砍掉几具骨架会让这个场景的
`sceneConfig` 缺件——那是坏壁纸,不是精简。要裁就在预设里裁(`preset.template.json` 是纯数据)。
"""
output_fn(f"\n[{page}] 检测到 {len(scenes)} 个场景(**全部**会落盘,这里选的是推荐值):")
for i, s in enumerate(scenes, 1):
mark = " ← 推荐" if s.id == scene else ""
has_assets = any(p.kind in ("spine", "image") for p in s.parts)
note = "" if has_assets else "(无资源,不会落盘)"
output_fn(
f" {i}) {s.id} 骨架 {len(s.spine_ids)} 个,图片 "
f"{sum(1 for p in s.parts if p.kind == 'image')} 个{note}{mark}"
)
raw = input_fn("推荐场景编号(回车取推荐值):").strip()
if raw:
try:
index = int(raw) - 1
if not 0 <= index < len(scenes):
output_fn("编号超出范围,取推荐值。")
else:
scene = scenes[index].id
except ValueError:
output_fn("不是数字,取推荐值。")
target = next((s for s in scenes if s.id == scene), None)
if target is None:
return None
if not any(p.kind in ("spine", "image") for p in target.parts):
# 一件资源都没有的场景落不成壁纸(`fetch` 会跳过它)。与其让人推荐一个必然没有产物的
# 场景,不如当场退回默认并说明原因。
output_fn(f"[{page}] {scene} 里没有任何需要资源的 part,退回默认场景。")
return None
return scene, list(spines)