Files
SpineWallpaper/tools/downloader/promote.py
T
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

231 lines
9.0 KiB
Python
Raw 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.
"""把一个 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