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 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
"""把一个场景组装成「可直接搬走的壁纸目录」。
抓取期产出的**终止形状**就是 `wallpapers/<游戏>/<壁纸id>/` 的同形拷贝
(布局 A,见 `docs/adr/0008-asset-layout-per-skeleton.md`):
<页面>/
├── page.json ← 页面级溯源
└── <场景id>/ ← 一个场景 = 一个壁纸目录
├── meta.json
├── preset.template.json
├── scene.json
├── spines/<骨架名>/ ← <名>.json + <名>.atlas + 贴图页**同居**
├── scene/<场景图>
└── audios/
目录名固定 `spines` / `scene` / `audios`——`tools/lib/generate.ts` 的 `copyWallpaperAssets`
按这三个名字搬文件,改名要两边一起改。
这个模块只做两件事:**写**(`build_preset` / `build_meta`)与**查**(`missing_preset_paths`)。
写与查放在一起是刻意的:产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
__all__ = [
"AUDIO_DIR",
"IMAGE_EXT",
"SCENE_DIR",
"SPINE_DIR",
"build_meta",
"build_preset",
"image_file",
"missing_preset_paths",
"preset_paths",
"scene_dir_name",
"spine_page_file",
"write_json",
]
SPINE_DIR = "spines"
SCENE_DIR = "scene"
AUDIO_DIR = "audios"
IMAGE_EXT = (".png", ".webp", ".jpg", ".jpeg", ".gif")
# 壁纸 id 的字符集(与 tools/lib/vault.ts 的校验逐字一致):它会进 WE 的 combo value。
_UNSAFE = re.compile(r"[^A-Za-z0-9_-]+")
def scene_dir_name(scene_id: str) -> str:
"""场景 id → 目录名。
页面里的场景 id 多数本来就合法(`scene_main` / `P1` / `loading`),但也有中文的
(back-moon 的 `动画预览`)。目录名一旦成为壁纸 id 就必须是 `^[a-z0-9][a-z0-9_-]*$`,
所以这里统一转义:非法字符折成一个 `_`,首字符不是字母数字时补前缀。
**真实 id 不会被丢掉**——它写在同目录的 `meta.json` / `scene.json` 里。
"""
name = _UNSAFE.sub("_", str(scene_id).strip()).strip("_-")
if not name:
return "scene"
if not re.match(r"[A-Za-z0-9]", name):
return f"scene_{name}"
return name
def image_file(scene_dir: Path, name: str) -> Path | None:
"""场景图落盘名:`scene/<逻辑名>.<ext>`(扩展名由资源路径决定,见 mihoyo.image_ext)。"""
for ext in IMAGE_EXT:
candidate = scene_dir / SCENE_DIR / f"{name}{ext}"
if candidate.is_file():
return candidate
return None
def spine_page_file(scene_dir: Path, spine_id: str) -> Path | None:
"""一具骨架的第一张贴图页。
页名**读 atlas 自己声明的第一行**,不按 `<stem>_N` 猜——去 hash 之后页名未必是
`<骨架名>.png`(多页是 `_2.png`,也有完全不同的名字)。这条路径只用于"整个场景一张
贴图平面都没有"时的背景兜底(`wallpapers/README.md` 允许背景指向骨架贴图页)。
"""
atlas = scene_dir / SPINE_DIR / spine_id / f"{spine_id}.atlas"
if not atlas.is_file():
return None
for line in atlas.read_text(encoding="utf-8").splitlines():
page = line.strip().strip('"')
if page.lower().endswith(IMAGE_EXT):
candidate = atlas.parent / Path(page).name
if candidate.is_file():
return candidate
return None
def _rel(scene_dir: Path, path: Path) -> str:
"""磁盘路径 → 预设里的 `./…` 相对路径(**一律正斜杠**,见"跨平台路径"那条坑)。"""
return "./" + path.relative_to(scene_dir).as_posix()
def _part_common(part: dict[str, Any]) -> dict[str, Any]:
"""part 的公共字段(世界变换 + 绘制层级 + 页面指定的动画/皮肤/时间缩放)。"""
common: dict[str, Any] = {
"kind": part.get("kind"),
"id": part["id"],
"order": part.get("order", 0),
"position": part.get("position", [0, 0, 0]),
"scale": part.get("scale", [1, 1, 1]),
}
if part.get("renderOrder"):
common["renderOrder"] = part["renderOrder"]
if part.get("geometrySize"):
common["width"], common["height"] = part["geometrySize"]
if part.get("geometryCenter"):
common["center"] = part["geometryCenter"]
if part.get("rotation") and any(abs(float(v)) > 1e-9 for v in part["rotation"]):
common["rotation"] = part["rotation"]
# 页面指定的动画 / 皮肤:不抄就会去播骨架的第一个动画(常是入场动画 in,姿态不同)。
if part.get("animation"):
common["animation"] = part["animation"]
if part.get("skin"):
common["skin"] = part["skin"]
if part.get("timeScale") is not None:
common["timeScale"] = part["timeScale"]
return common
def build_preset(
scene: dict[str, Any],
scene_dir: Path,
*,
cover: str | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""由 `scene.json` 的内容 + 磁盘上的场景目录,生成 `preset.template.json`。
返回(预设, 说明列表)。说明是"这个场景里没能进预设的东西"——纯色平面、缺文件的 part,
它们不是错误(`verify` 会独立判红),但用户该知道少了几件。
`cover` 给的是**逻辑名**(`scene/<名>.<ext>`);不给就自动挑一张背景图,
因为构建期 `backgroundImage` 是必填且必须真实存在(见 `tools/lib/vault.ts`)。
"""
notes: list[str] = []
parts: list[dict[str, Any]] = []
solids = 0
for part in scene.get("parts") or []:
kind = part.get("kind")
if kind == "solid":
# 纯色平面运行时这一轮画不了(没有贴图),写进预设只是死配置。
solids += 1
continue
if kind not in ("spine", "image"):
notes.append(f"未知的 part 类型 {kind!r}(id={part.get('id')}),已跳过")
continue
common = _part_common(part)
if kind == "spine":
spine_id = str(part["id"])
spine_dir = scene_dir / SPINE_DIR / spine_id
if not (spine_dir / f"{spine_id}.json").is_file():
notes.append(f"骨架 {spine_id} 没有落到 spines/{spine_id}/,未写进预设")
continue
common["jsonUrl"] = f"./{SPINE_DIR}/{spine_id}/{spine_id}.json"
common["atlasUrl"] = f"./{SPINE_DIR}/{spine_id}/{spine_id}.atlas"
else:
if part.get("runtime"):
# 运行时缓冲(cacheContainer / diffuse 指向同场景骨架缓存):由骨架渲染进贴图
# 缓冲,没有独立文件,不该进预设也不该报缺资源(`scene.json` 里标了 runtime)。
continue
source = image_file(scene_dir, str(part["id"]))
if source is None:
notes.append(f"贴图平面 {part['id']} 没有独立文件,未写进预设")
continue
common["image"] = _rel(scene_dir, source)
parts.append(common)
if solids:
notes.append(f"{solids} 块纯色平面未写进预设(运行时没有贴图可画)")
background: str | None = None
if cover:
source = image_file(scene_dir, cover)
if source is None:
notes.append(f"指定的背景图 {cover} 不在 scene/ 里,改为自动挑选")
else:
background = _rel(scene_dir, source)
if background is None:
background = _pick_background(scene_dir, parts)
scene_config: dict[str, Any] = {"ui": scene.get("ui"), "parts": parts}
camera_node = scene.get("camera") or {}
camera = camera_node.get("camera") or {}
if camera.get("type") is not None:
# 相机必须带进预设:透视场景(type 1)忽略 fov 与 z 就会画成一块糊满屏的贴图。
scene_config["camera"] = {
"type": camera.get("type"),
"fov": camera.get("fov"),
"position": camera_node.get("position"),
}
return {"backgroundImage": background or "", "sceneConfig": scene_config}, notes
def _pick_background(scene_dir: Path, parts: list[dict[str, Any]]) -> str | None:
"""自动挑背景图:贴图平面里**面积最大**的那块(画布比例的来源),没有就退到骨架贴图页。
为什么按面积:`backgroundImage` 在运行时决定 `document.body` 的底图与取景用的宽高比
(`src/runtime/index.ts` 的 `measureImageAspect`)。挑到一块小按钮会让整幅画的比例全错,
而背景/天空/远景恰好总是场景里最大的那块平面(实测:nico-tea 的 `main_sky_jpg` 2500×1064)。
"""
images = [p for p in parts if p.get("image")]
if images:
best = max(
images,
key=lambda p: (float(p.get("width") or 0) * float(p.get("height") or 0), -int(p.get("order") or 0)),
)
return str(best["image"])
for part in parts:
if not part.get("jsonUrl"):
continue
source = spine_page_file(scene_dir, str(part["id"]))
if source is not None:
return _rel(scene_dir, source)
return None
def preset_paths(preset: dict[str, Any]) -> list[str]:
"""预设里所有必须真实存在的 `./…` 路径。"""
out: list[str] = []
background = preset.get("backgroundImage")
if isinstance(background, str) and background:
out.append(background)
scene_config = preset.get("sceneConfig") or {}
for part in scene_config.get("parts") or []:
for key in ("image", "jsonUrl", "atlasUrl"):
value = part.get(key)
if isinstance(value, str) and value:
out.append(value)
return out
def missing_preset_paths(preset: dict[str, Any], scene_dir: Path) -> list[str]:
"""预设里指向磁盘上不存在的文件的路径(写出后立刻自检,不留到构建期才炸)。"""
missing: list[str] = []
for rel in preset_paths(preset):
# 映射键一律用**正斜杠**:Windows 的 str(Path) 是反斜杠,写进预设就搬到别的机器上读不到。
if not (scene_dir / rel.replace("\\", "/").removeprefix("./")).is_file():
missing.append(rel)
return missing
def build_meta(
*,
wallpaper_id: str,
name: str,
title: str,
description: str,
game: str,
page: str,
scene: str,
source: str,
fetched_at: str | None = None,
) -> dict[str, Any]:
"""场景目录的 `meta.json`:既有字段语义(id/name/title/description/audio)原样保留,
另加溯源字段(game/page/scene/source),方便搬进 `wallpapers/` 后回查来源。"""
meta: dict[str, Any] = {
"id": wallpaper_id,
"name": name,
"title": title,
"description": description,
"game": game,
"page": page,
"scene": scene,
"source": source,
# 音源清单为空:页面的 BGM 还没抓(构建会据此省掉 bgm 属性)。
"audio": {"choices": []},
}
if fetched_at:
meta["fetchedAt"] = fetched_at
return meta
def write_json(path: Path, data: Any, *, indent: int = 2) -> int:
"""写一份 JSON(UTF-8、不转义中文),返回字节数。"""
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(data, ensure_ascii=False, indent=indent) + "\n"
path.write_text(text, encoding="utf-8")
return len(text.encode("utf-8"))