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

+230
View File
@@ -0,0 +1,230 @@
"""把一个 staging 的**场景目录** promote 成 `wallpapers/<游戏>/<壁纸id>/`。
`fetch` 现在产出的就是终态形状(一个场景 = 一个可直接搬走的壁纸目录),所以这一步退化成
**拷贝 + 校验 + 写 meta**:把 `spines/` / `scene/` / `audios/` 三个目录与 `preset.template.json`
搬过去,把 `meta.json` 的 id / 文案换成发布值,再把预设里每条 `./…` 路径在目标目录上核一遍。
刻意不做的事:不抓音源(页面的 BGM 是另一条链)、不生成预览图、不动已存在的目录(除非 --force)。
"""
from __future__ import annotations
import json
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from . import layout as layout_mod
__all__ = ["promote_page", "resolve_scene_dir", "PromoteResult"]
@dataclass
class PromoteResult:
target: Path
scene_dir: Path
parts: int
spines: int
images: int
solids_skipped: int
bytes: int
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _is_scene_dir(path: Path) -> bool:
"""场景目录的判据:`preset.template.json` + `scene.json` 都在(fetch 的产物形状)。"""
return (path / "preset.template.json").is_file() and (path / "scene.json").is_file()
def scene_dirs(staged: Path) -> list[Path]:
"""页面目录下的全部场景目录(按名字排序,保证可复现)。"""
if not staged.is_dir():
return []
return sorted(p for p in staged.iterdir() if p.is_dir() and _is_scene_dir(p))
def resolve_scene_dir(staged: Path, scene: str | None = None) -> Path:
"""把「场景目录」或「页面目录 + 场景 id」统一解析成**一个**场景目录。
认三种输入(与 CLI 的 `--page` 对应):
1. 场景目录本身(`_out/<游戏>/<页面>/<场景id>`)
2. `.scratch` 式的「页面/场景」路径——由调用方拼好后传进来
3. 页面目录(`_out/<游戏>/<页面>`):用 `--scene` 指定;没指定就取 `page.json` 的
`chosenScene`;再没有就要求页面下只有一个场景(多个时报错,不猜)。
"""
if _is_scene_dir(staged):
return staged
if not staged.is_dir():
raise FileNotFoundError(f"staging 里没有这个东西:{staged}")
candidates = scene_dirs(staged)
if not candidates:
raise FileNotFoundError(f"{staged} 下没有场景目录(先跑:python -m tools.downloader fetch)")
def matches(name: str) -> Path | None:
for candidate in candidates:
if candidate.name == name:
return candidate
try:
meta = _read_json(candidate / "meta.json")
except (OSError, json.JSONDecodeError):
continue
if meta.get("scene") == name or meta.get("id") == name:
return candidate
return None
if scene:
hit = matches(scene)
if hit is None:
names = "、".join(p.name for p in candidates)
raise FileNotFoundError(f"{staged} 下没有场景 {scene}(有:{names})")
return hit
chosen = None
page_file = staged / "page.json"
if page_file.is_file():
try:
chosen = _read_json(page_file).get("chosenDir") or _read_json(page_file).get("chosenScene")
except (OSError, json.JSONDecodeError):
chosen = None
if chosen:
hit = matches(str(chosen))
if hit is not None:
return hit
if len(candidates) == 1:
return candidates[0]
names = "、".join(p.name for p in candidates)
raise FileNotFoundError(
f"{staged} 下有 {len(candidates)} 个场景目录,要用 --scene 指定一个(有:{names})"
)
def promote_page(
staged: Path,
wallpapers_root: Path,
*,
game: str,
wallpaper_id: str,
scene: str | None = None,
name: str | None = None,
title: str | None = None,
description: str | None = None,
cover: str | None = None,
force: bool = False,
) -> PromoteResult:
"""执行一次 promote;返回统计。"""
scene_dir = resolve_scene_dir(staged, scene)
if not (scene_dir / "scene.json").is_file():
raise FileNotFoundError(f"场景目录里没有 scene.json:{scene_dir}")
scene_payload = _read_json(scene_dir / "scene.json")
src_meta: dict[str, Any] = {}
meta_file = scene_dir / "meta.json"
if meta_file.is_file():
try:
src_meta = _read_json(meta_file)
except json.JSONDecodeError as exc:
raise FileNotFoundError(f"{meta_file} 不是合法 JSON:{exc}") from exc
target = wallpapers_root / game / wallpaper_id
if target.exists() and not force:
raise FileExistsError(f"目标已存在(要覆盖就加 --force):{target}")
if target.exists():
shutil.rmtree(target)
# 布局 A:一具骨架一组(json + atlas + 贴图页同居),场景图单独一层。
# 页必须与 atlas 同目录——spine-ts 按 `<atlas 目录>/<页名>` 解析(见 README)。
written = 0
for segment in (layout_mod.SPINE_DIR, layout_mod.SCENE_DIR, layout_mod.AUDIO_DIR):
source = scene_dir / segment
if source.is_dir():
written += _copy_tree(source, target / segment)
# 预设优先沿用场景目录里已生成的那份(fetch 已产出终态);缺失就按 scene.json 现算。
preset_file = scene_dir / "preset.template.json"
if preset_file.is_file():
preset = _read_json(preset_file)
else:
preset, _ = layout_mod.build_preset(scene_payload, scene_dir)
if cover:
source = layout_mod.image_file(scene_dir, cover)
if source is None:
raise FileNotFoundError(f"封面图不在场景目录里:scene/{cover}.*")
dest = target / layout_mod.SCENE_DIR / source.name
if not dest.exists():
written += _copy(source, dest)
preset["backgroundImage"] = f"./{layout_mod.SCENE_DIR}/{source.name}"
if not ((preset.get("sceneConfig") or {}).get("parts") or []):
if target.exists():
shutil.rmtree(target, ignore_errors=True)
raise FileNotFoundError(
f"{scene_dir} 里一件可搬走的 part 都没有(平面全是运行时渲染目标),换一个场景目录"
)
missing = layout_mod.missing_preset_paths(preset, target)
if missing:
# 别把半个目标目录留在 wallpapers/ 里——它看起来像一档发布壁纸,实际缺件。
shutil.rmtree(target, ignore_errors=True)
raise FileNotFoundError(
"promote 后预设里有路径找不到对应文件(已拷贝的资产不自洽):\n - " + "\n - ".join(missing)
)
written += layout_mod.write_json(target / "preset.template.json", preset)
final_meta = dict(src_meta)
# 这两个是 staging 专用字段("这个目录能不能直接搬走"),发布形状里没有它们的位置。
final_meta.pop("usable", None)
final_meta.pop("reason", None)
final_meta["id"] = wallpaper_id
final_meta.setdefault("name", wallpaper_id)
final_meta.setdefault("title", str(final_meta["name"]))
final_meta.setdefault("description", str(final_meta["name"]))
if name:
final_meta["name"] = name
if title:
final_meta["title"] = title
if description:
final_meta["description"] = description
# 音源清单为空:这一页的 BGM 还没抓(构建会据此省掉 bgm 属性)。
final_meta.setdefault("audio", {"choices": []})
final_meta.setdefault("game", game)
final_meta.setdefault("page", scene_dir.parent.name)
written += layout_mod.write_json(target / "meta.json", final_meta)
game_dir = wallpapers_root / game
if not (game_dir / "meta.json").exists():
(game_dir / "meta.json").write_text(
json.dumps({"id": game, "name": game, "audios": []}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
parts = (preset.get("sceneConfig") or {}).get("parts") or []
solids = sum(1 for p in scene_payload.get("parts") or [] if p.get("kind") == "solid")
return PromoteResult(
target=target,
scene_dir=scene_dir,
parts=len(parts),
spines=sum(1 for p in parts if p.get("kind") == "spine"),
images=sum(1 for p in parts if p.get("kind") == "image"),
solids_skipped=solids,
bytes=written,
)
def _copy(src: Path, dest: Path) -> int:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
return dest.stat().st_size
def _copy_tree(src: Path, dest: Path) -> int:
total = 0
for item in sorted(src.rglob("*")):
if item.is_file():
total += _copy(item, dest / item.relative_to(src))
return total