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:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user