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

290 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把一个场景组装成「可直接搬走的壁纸目录」。
抓取期产出的**终止形状**就是 `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"))