"""把一个 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 按 `/<页名>` 解析(见 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