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

+531
View File
@@ -0,0 +1,531 @@
// 一个新分发目录 = 一个能直接上传 Wallpaper Engine 的作品。
//
// 这一层只负责"合成产物":从 wallpapers/ 的内容算出 preset.js(运行时)与 project.json(发布),
// 并把资源铺进目录结构。校验与去重不在本文件(见 vault.ts 与 build.ts)。
import { join } from "node:path";
import type { AudioChoice, ProjectTemplate, Release, Wallpaper } from "./types.ts";
import { abs, walkFiles } from "./fs.ts";
import { copyFileTo, copyTree, isDir, isFile, writeJson, writeText } from "./fs.ts";
export interface GenerateContext {
template: ProjectTemplate;
version: string;
/** 默认预设 id(来自 wallpapers/meta.json):决定合集分发的 preset 下拉首项与 value。 */
defaultPresetId: string;
/** 分发目录的绝对路径。 */
outDir: string;
/** 往运行时脚本目录里额外放一个可选的 WE 模拟器入口。 */
withSimulator: boolean;
}
/** 该分发里一档壁纸落到哪个子目录(相对分发根)。 */
export function releasePathOf(release: Release, wallpaper: Wallpaper): string {
if (release.scope === "all") return `${wallpaper.gameId}/${wallpaper.id}`;
if (release.scope === "game") return wallpaper.id;
return "";
}
/**
* 一份音频在某分发里的相对路径(相对 preset.js 所在目录)。
*
* 落点键必须与 `planAudio` 算出来的一致:单档分发与共享音频都落在分发根(键为空串),
* 合集分发落在 `audios/<壁纸id>/`。这里刻意不查落点表——查表会让人以为"表是权威",
* 而真正的权威是 planAudio;两者不一致时应当在构建里被发现(见 check:dist)。
*/
export function audioPathIn(release: Release, wallpaper: Wallpaper, file: string, shared = ""): string {
const rootPrefix = release.type === "single" ? "./" : rootPrefixOf(release, wallpaper);
const key = shared !== "" ? shared.replace(/^\/+|\/+$/g, "") : release.type === "single" ? "" : wallpaper.id;
return rootPrefix + "audios/" + (key ? key + "/" : "") + baseName(file);
}
/**
* 从某档壁纸的目录回到**分发根**的相对前缀。
*
* 必须与 build.ts 的 copyWallpaperAssets({skipAudio}) + copyCollectionAudio 的搬移行为严格一致,
* 否则 preset.js 会指向不存在的文件(这条路径算错过一次:多算了一层 `../`)。
*/
export function rootPrefixOf(release: Release, wallpaper: Wallpaper): string {
if (release.type === "single") return "./";
// game 合集里壁纸在 1 层下(../),all 合集里在 2 层下(../../)。
const depth = releasePathOf(release, wallpaper).split("/").filter(Boolean).length;
return "../".repeat(depth);
}
/** 该分发里一档壁纸的目录深度(0 = 单档,1 = 游戏合集,2 = 全部合集)。 */
export function depthOf(release: Release, wallpaper: Wallpaper): number {
return releasePathOf(release, wallpaper).split("/").filter(Boolean).length;
}
/** 一档壁纸目录内的资源(图片/骨架/atlas)在发布产物里的 URL,与 `asset()` 完全同算法。 */
export function assetUrlIn(release: Release, wallpaper: Wallpaper, relInWallpaper: string): string {
return `./${rootPrefixOf(release, wallpaper)}${relInWallpaper.replace(/^\.\//, "")}`;
}
/**
* 同一份资源在**分发根**下的路径(不带 `./`、`../`)。
*
* 与 `assetUrlIn` 的区别是基准:那个以 preset.js 所在目录为基准(`../spines/x/x.webp`),
* 这个以分发根为基准(`kv37/spines/x/x.webp`)。自包含包的资源表以**页面**为基准建键,
* 而页面在 `<分发根>/sim/`,所以要用这个。
*
* 拿 `assetUrlIn` 的结果去建表会丢掉合集里的 `<壁纸id>/` 这一段:
* `./../spines/x/x.webp` 归一化后是 `spines/x/x.webp`,而真实位置是 `kv37/spines/x/x.webp`。
* 单档分发看不出差别(没有这一段),合集里则是"表里 152 个键,骨架却查不中"。
*/
export function assetRelIn(release: Release, wallpaper: Wallpaper, relInWallpaper: string): string {
const sub = releasePathOf(release, wallpaper);
const rel = relInWallpaper.replace(/^\.\//, "");
return sub === "" ? rel : `${sub}/${rel}`;
}
function withDotSlash(path: string): string {
const normalized = path.replace(/\\/g, "/");
return normalized.startsWith("./") || normalized.startsWith("../") ? normalized : "./" + normalized;
}
function baseName(path: string): string {
return path.replace(/\\/g, "/").replace(/^.*\//, "");
}
/**
* 生成一档壁纸的 preset.js。
*
* 关键:每个分发目录里的 preset.js 都是**现算**的——同一档壁纸在三种分发形状下,
* 音频落点与相对深度都不同,所以不能一份源码拷贝到多处:
* 单档分发 图片/骨架/音频全在本目录 → ./audios/<壁纸id>/…
* 游戏合集 图片/骨架在本目录,音频在分发根 → ../audios/<壁纸id>/…
* 全部合集 图片/骨架在本目录,音频在分发根 → ../../audios/<壁纸id>/…
*
* 落点里的 `<壁纸id>/` 一级与 copyCollectionAudio 的分目录一一对应:把不同壁纸的音源分开放,
* 既避免**同名不同曲**的静默冲突(旧的扁平布局会因重名直接报错),也让"这份音频属于谁"一目了然。
* 游戏级共享音频仍在合集根,不进任何壁纸子目录。
*/
export function generatePresetModule(
release: Release,
wallpaper: Wallpaper,
sharedAudio: AudioChoice[],
): string {
const game = wallpaper.game;
if (!game) throw new Error(`内部错误:${wallpaper.srcRel} 没有所属游戏`);
/** 一份音频在该分发里的相对路径(相对 preset.js 所在目录)。 */
const audioPath = (file: string, shared = ""): string => audioPathIn(release, wallpaper, file, shared);
const spineConfig = wallpaper.preset.spineConfig;
const sceneConfig = wallpaper.preset.sceneConfig;
if (spineConfig && sceneConfig) {
throw new Error(`${wallpaper.srcRel}:spineConfig 与 sceneConfig 只能写一个(单骨架 / 场景二选一)`);
}
if (!spineConfig && !sceneConfig) {
throw new Error(`${wallpaper.srcRel}:preset.template.json 里既没有 spineConfig 也没有 sceneConfig`);
}
const lines: string[] = [
`// 由 pnpm build 生成,请勿手工修改。`,
`// 源:${wallpaper.srcRel}/meta.json(音频清单)+ preset.template.json(运行时配置)`,
`//`,
`// 资源路径用 import.meta.url 从本文件位置推导:整个分发目录可以整体搬家而不用改任何一行路径。`,
``,
`const base = new URL("./", import.meta.url);`,
`const asset = (path) => new URL(path, base).href;`,
``,
`export default {`,
` id: ${JSON.stringify(wallpaper.id)},`,
` name: ${JSON.stringify(wallpaper.meta.name)},`,
` game: ${JSON.stringify(game.id)},`,
``,
` backgroundImage: asset(${JSON.stringify(withDotSlash(wallpaper.preset.backgroundImage))}),`,
];
if (spineConfig) {
lines.push(` spineConfig: {`);
for (const [key, value] of Object.entries(spineConfig)) {
const isUrl = key === "jsonUrl" || key === "atlasUrl";
lines.push(
` ${key}: ${isUrl && typeof value === "string" ? `asset(${JSON.stringify(withDotSlash(value))})` : JSON.stringify(value)},`,
);
}
lines.push(` },`);
}
if (sceneConfig) {
lines.push(` sceneConfig: {`);
if (sceneConfig.ui) lines.push(` ui: ${JSON.stringify(sceneConfig.ui)},`);
if (sceneConfig.camera) lines.push(` camera: ${JSON.stringify(sceneConfig.camera)},`);
if (sceneConfig.timelineOffset) lines.push(` timelineOffset: ${JSON.stringify(sceneConfig.timelineOffset)},`);
if (sceneConfig.flipY === false) lines.push(` flipY: false,`);
lines.push(` parts: [`);
for (const part of sceneConfig.parts) {
const fields: string[] = [
`kind: ${JSON.stringify(part.kind)}`,
`id: ${JSON.stringify(part.id)}`,
`order: ${JSON.stringify(part.order ?? 0)}`,
`position: ${JSON.stringify(part.position)}`,
`scale: ${JSON.stringify(part.scale)}`,
];
if (part.renderOrder) fields.push(`renderOrder: ${JSON.stringify(part.renderOrder)}`);
// 三个资源字段都走 asset():与单骨架同一条路径解析规则(相对 preset.js 推导)。
for (const key of ["image", "jsonUrl", "atlasUrl"] as const) {
const value = part[key];
if (typeof value === "string" && value) fields.push(`${key}: asset(${JSON.stringify(withDotSlash(value))})`);
}
if (part.animation) fields.push(`animation: ${JSON.stringify(part.animation)}`);
if (part.skin) fields.push(`skin: ${JSON.stringify(part.skin)}`);
if (part.timeScale !== undefined) fields.push(`timeScale: ${JSON.stringify(part.timeScale)}`);
if (part.width !== undefined) fields.push(`width: ${JSON.stringify(part.width)}`);
if (part.height !== undefined) fields.push(`height: ${JSON.stringify(part.height)}`);
if (part.center !== undefined) fields.push(`center: ${JSON.stringify(part.center)}`);
if (part.rotation !== undefined) fields.push(`rotation: ${JSON.stringify(part.rotation)}`);
if (part.color !== undefined) fields.push(`color: ${JSON.stringify(part.color)}`);
lines.push(` { ${fields.join(", ")} },`);
}
lines.push(` ],`, ` },`);
}
lines.push(``, ` audioChoices: [`);
for (const choice of wallpaper.audioChoices) {
lines.push(
` { id: ${JSON.stringify(choice.id)}, name: ${JSON.stringify(choice.name)}, source: asset(${JSON.stringify(
audioPath(choice.file),
)}) },`,
);
}
for (const choice of sharedAudio) {
lines.push(
` { id: ${JSON.stringify(choice.id)}, name: ${JSON.stringify(choice.name)}, source: asset(${JSON.stringify(
audioPath(choice.file, "shared"),
)}) },`,
);
}
// 默认音源:合集分发里它也随之搬到分发根。
//
// 有一处**刻意的历史不一致**:当前已发布的全部合集里这一行是 asset("./audios/<文件>"),
// 也就是指向壁纸自己目录下并不存在的 audios/。运行时不读它(`index.ts` 用 audioChoices 的
// source 覆盖),所以它一直是死数据。这里改成与 audioChoices 同落点的**正确**路径:
// 死数据不该被继承,且一旦将来有代码读它就立刻是错的。
// 默认音源按 **1 起的位置** 取(meta.json 里不再手写 id):省略 = 第一个。
const defaultChoice = wallpaper.audioChoices[wallpaper.audioDecl.defaultIndex];
// 没有音源时写空串,**不能**写成 `asset("")`:那会解析成 preset.js 自己的 URL,
// 运行时会把它塞进 <audio>.src(实测会去请求一个不存在的音频)。
const defaultSource = defaultChoice ? `asset(${JSON.stringify(withDotSlash(audioPath(defaultChoice.file)))})` : `""`;
lines.push(` ],`, ``, ` audioOptions: { source: ${defaultSource} },`, `};`, ``);
return lines.join("\n");
}
/**
* 生成 scripts/presets.js:预设查表 + 本分发的默认预设 id。
*
* 注意这里生成的是**裸 JS 文本**,编译器管不到它——写错了不会有任何提示,只会在浏览器里
* SyntaxError 白屏。所以格式必须最保守:用 `Object.fromEntries([[id, mod], …])` 这种"数组的数组",
* 别用对象字面量的计算属性名 `[k]: v`(在数组字面量里是非法语法,实测过一次真事故)。
*/
export function generatePresetIndex(release: Release, defaultPresetId: string): string {
const imports: string[] = [];
const entries: string[] = [];
const ordered = [...release.wallpapers].sort((a, b) => {
if (a.id === defaultPresetId) return -1;
if (b.id === defaultPresetId) return 1;
return 0;
});
ordered.forEach((wallpaper, index) => {
const sub = releasePathOf(release, wallpaper);
const local = `preset${index}`;
imports.push(`import ${local} from "../${sub ? sub + "/" : ""}preset.js";`);
entries.push(` [${local}.id, ${local}],`);
});
// 单档分发里 defaultPresetId 必须等于它自己的 id,否则 index.js 会拿表里不存在的 id 去查,整页白屏。
const fallback = ordered.some((w) => w.id === defaultPresetId) ? defaultPresetId : (ordered[0]?.id ?? "");
return [
`// 由 pnpm build 生成,请勿手工修改。`,
`//`,
`// 本分发包含的壁纸:${ordered.map((w) => `${w.id}(${w.meta.name})`).join("、")}`,
`// defaultPresetId 是本分发在 WE 属性缺失时的回落目标。`,
``,
...imports,
``,
`const Presets = Object.fromEntries([`,
...entries,
`]);`,
``,
`export const defaultPresetId = ${JSON.stringify(fallback)};`,
`export default Presets;`,
``,
].join("\n");
}
export interface ProjectJson {
contentrating: string;
description: string;
file: string;
general: { properties: Record<string, unknown> };
preview?: string;
ratingsex: string;
ratingviolence: string;
tags: string[];
title: string;
type: string;
/** WE 的 project.json 里 version 是**数字**(VERSION 文件里是文本,生成时转换)。 */
version: number;
visibility: string;
workshopid?: string;
workshopurl?: string;
}
interface ComboProperty {
options: { label: string; value: string }[];
value: string;
}
/**
* 按固定顺序重建一个属性对象。
*
* 键序不是无所谓的:WE 面板只看字段名,但 project.json 同时是**已发布产物的一部分**,
* 保持与线上逐字节一致的键序,才能让"新旧构建的 diff"只反映真实改动,而不是序列化顺序抖动。
* 顺序取自线上已发布的 project.json。
*/
function orderProperty(source: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of ["index", "options", "order", "text", "type", "value"]) {
if (key in source) out[key] = source[key];
}
for (const [key, value] of Object.entries(source)) {
if (!(key in out)) out[key] = value;
}
return out;
}
/**
* 给属性表自动编号:`index` = 0,1,2…,`order` = 100+index,都按**属性表的书写顺序**。
*
* 这两串数字以前是手写在模板里的。代价在加一个属性时才显出来:后面全部要重编号
* (上一轮加 use_custom_audio 手改了 6 处),而且单档分发删掉 preset/bgm 之后会留下空号
* (0,1,4,5,6…)。自动编号同时解决这两件事。
*
* `$order` 是显式钉住:schemecolor 是 WE 的**内置**属性,线上就是 order 0 且**没有** index。
* 钉住的属性不参与 index 序列——这样产物与线上逐字一致,diff 里只剩真实改动。
*/
function numberProperties(properties: Record<string, unknown>): void {
let index = 0;
for (const definition of Object.values(properties)) {
if (definition === null || typeof definition !== "object") continue;
const def = definition as Record<string, unknown>;
const pinned = def.$order;
delete def.$order;
if (typeof pinned === "number") {
def.order = pinned;
delete def.index;
continue;
}
def.index = index;
def.order = 100 + index;
index += 1;
}
}
/** 生成某分发的 project.json。字段顺序对齐已发布作品,便于人工 diff。 */
export function generateProjectJson(release: Release, ctx: GenerateContext): ProjectJson {
const properties: Record<string, unknown> = structuredClone(ctx.template.general.properties) as Record<string, unknown>;
const isCollection = release.wallpapers.length > 1;
// 下拉顺序以**默认预设**开头。这不是美观问题:默认项决定 preset.value,
// 也决定 WE 属性缺失时 show 的是哪一档;线上值必须逐字保持不变。
const ordered = [...release.wallpapers].sort((a, b) => {
if (a.id === ctx.defaultPresetId) return -1;
if (b.id === ctx.defaultPresetId) return 1;
return 0;
});
// bgm:本分发内所有壁纸音源的并集 + 合集根共享音频。运行时只让"属于当前壁纸"的选择生效,
// 其余回落该壁纸默认音源(既有语义,见 .scratch/wallpaper-optimization/issues/05)。
//
// **单档分发也要算它**:一档壁纸照样可以带多首曲子(xilian 就有两首)。
// 以前整块跟着 preset 一起删,结果 single-hsr-xilian 根本没有切换入口。
const bgmOptions: { label: string; value: string }[] = [{ label: "随预设", value: "auto" }];
const seen = new Set<string>(["auto"]);
for (const wallpaper of ordered) {
for (const choice of wallpaper.audioChoices) {
if (seen.has(choice.id)) continue;
seen.add(choice.id);
bgmOptions.push({ label: choice.name, value: choice.id });
}
}
for (const choice of release.sharedAudio) {
if (seen.has(choice.id)) continue;
seen.add(choice.id);
bgmOptions.push({ label: choice.name, value: choice.id });
}
if (isCollection) {
const multiGame = new Set(release.wallpapers.map((w) => w.gameId)).size > 1;
const preset = properties.preset as ComboProperty;
preset.options = ordered.map((wallpaper) => ({
label: multiGame
? `${wallpaper.game?.name ?? wallpaper.gameId} - ${wallpaper.meta.name}`
: wallpaper.meta.name,
value: wallpaper.id,
}));
preset.value = ordered[0]?.id ?? "";
properties.preset = orderProperty(properties.preset as Record<string, unknown>);
} else {
// 单档分发:只剩一档壁纸,"壁纸预设切换"没有意义。整条属性删掉;运行时对缺失属性是安全的。
delete properties.preset;
delete properties.preset_note;
}
// 只剩"随预设"+ 至多一首时这个下拉是句废话(两个选项效果完全一样),删掉。
// 这样 single-hsr-kv37(一首)没有它、single-hsr-xilian(两首)有它。
if (bgmOptions.length <= 2) {
delete properties.bgm;
delete properties.bgm_note;
} else {
(properties.bgm as ComboProperty).options = bgmOptions;
properties.bgm = orderProperty(properties.bgm as Record<string, unknown>);
}
// 编号必须在**删属性之后**:单档分发删掉 preset/bgm 那四行,编号要跟着补上,不留空号。
numberProperties(properties);
// **键序是有意义的**:上传到创意工坊的 project.json 要和线上那份逐字节对齐,
// 否则每次构建都会产生一个"内容相同但文件不同"的 diff,无法判断是真改动还是噪声。
//
// 线上那份的键序是(WE 自己写出来的):
// contentrating, description, file, general, **preview**, ratingsex, ratingviolence,
// tags, title, type, version, visibility, workshopid, workshopurl
// 注意 `preview` 夹在 general 与 ratingsex 之间,而 workshop* 在最后——不是"可选字段统一追加"。
// 先前把三个可选字段都追加到末尾,字段值一个没差、键序却变了;靠逐键比对才发现。
const out: ProjectJson = {
contentrating: ctx.template.contentrating,
description: release.meta.description,
file: ctx.template.file,
general: { properties },
...(release.meta.preview ? { preview: release.meta.preview } : {}),
ratingsex: ctx.template.ratingsex,
ratingviolence: ctx.template.ratingviolence,
tags: ctx.template.tags,
title: release.meta.title,
type: ctx.template.type,
// WE 的 project.json 里 version 是数字;VERSION 文件里是文本,这里必须转回来。
version: Number(ctx.version),
visibility: ctx.template.visibility,
};
// workshopid / workshopurl 只在有值时出现(Q7):没有就整键省略,而不是留空串。
if (release.meta.workshopid) out.workshopid = release.meta.workshopid;
if (release.meta.workshopurl) out.workshopurl = release.meta.workshopurl;
return out;
}
export interface AudioPlacement {
/** 源文件绝对路径。 */
from: string;
/** 落点键:壁纸 id(→ `audios/<键>/<文件>`)或 `""`(→ 合集根 `audios/<文件>`)。 */
key: string;
/** 文件名(保留中文,是产品内容)。 */
name: string;
/** 相对分发根的落点。 */
destRel: string;
}
export interface AudioPlan {
placements: AudioPlacement[];
/** 合集根共享层的重名冲突(扁平一层,重名即歧义)。 */
collisions: string[];
}
/**
* 算出**整个分发**的音频落点。
*
* 这份映射是三件事的唯一真相来源,所以必须是同一个函数:
* ① copyCollectionAudio 按它搬文件;② generatePresetModule 按它拼 preset.js 里的路径;
* ③ `--sim` 的自包含打包按它建内联表。
* 三处各算一遍的症状是"http 下正常、file:// 下白屏"或"静默无声"——最难在开发期发现的那类。
*
* 落点分两层:
* audios/<壁纸id>/<文件> 该壁纸自己的音源(互相隔离,同名不同曲不再冲突)
* audios/<文件> 游戏级共享音频(扁平一层,重名即歧义)
*/
export async function planAudio(release: Release): Promise<AudioPlan> {
const placements: AudioPlacement[] = [];
const collisions: string[] = [];
const seen = new Set<string>();
const push = (from: string, key: string, name: string): void => {
if (seen.has(from)) return;
seen.add(from);
placements.push({ from, key, name, destRel: `audios/${key ? key + "/" : ""}${name}` });
};
const owner = new Map<string, string>();
for (const wallpaper of release.wallpapers) {
const key = release.type === "single" ? "" : wallpaper.id;
for (const choice of wallpaper.audioChoices) {
const name = baseName(choice.file);
push(join(abs(wallpaper.srcRel), "audios", name), key, name);
owner.set(name, wallpaper.srcRel);
}
}
// 游戏级共享音频(wallpapers/<游戏>/audios/):同一首曲子被该游戏多档壁纸共用时放这里。
for (const gameId of [...new Set(release.wallpapers.map((w) => w.gameId))]) {
const shared = abs(`wallpapers/${gameId}/audios`);
if (!(await isDir(shared))) continue;
for (const name of await walkFiles(shared)) {
const previous = owner.get(name);
if (previous !== undefined) {
collisions.push(`${name}:${previous} 与 共享(${gameId})`);
continue;
}
owner.set(name, `共享(${gameId})`);
push(join(shared, name), "", name);
}
}
return { placements, collisions };
}
/** 把一档壁纸的资源铺进分发目录(骨架、贴图、音频、预览图)。
*
* 合集类分发里音频统一放在**分发根的 audios/**(见 copySharedAudio),所以这里跳过 audios/,
* 否则同一首曲子会在分发里出现两份。单档分发没有合集根,音频必须留在壁纸目录内。 */
export async function copyWallpaperAssets(
wallpaper: Wallpaper,
destDir: string,
options: { skipAudio?: boolean } = {},
): Promise<void> {
const from = abs(wallpaper.srcRel);
// 目录名必须与 wallpapers/README.md 的布局一致:一具骨架一组的 `spines/`、
// 场景图与背景的 `scene/`、音频的 `audios/`(这里改一处,所有分发的搬运都跟着走)。
for (const segment of ["spines", "scene", "audios"]) {
if (segment === "audios" && options.skipAudio) continue;
if (await isDir(join(from, segment))) await copyTree(join(from, segment), join(destDir, segment));
}
if (wallpaper.meta.preview && (await isFile(join(from, baseName(wallpaper.meta.preview))))) {
await copyTree(join(from, baseName(wallpaper.meta.preview)), join(destDir, baseName(wallpaper.meta.preview)));
}
}
/**
* 把**整个分发**的音频铺进分发根。落点完全由 planAudio 决定(见它的注释)。
*
* 只有共享层允许重名冲突:那里重名是真的不知道谁是权威,直接报错而不是静默覆盖。
*/
export async function copyCollectionAudio(
release: Release,
outDir: string,
): Promise<{ names: string[]; audioDirs: Map<string, string> }> {
const plan = await planAudio(release);
if (plan.collisions.length > 0) {
throw new Error(
`合集根共享音频文件重名,无法确定权威版本:\n - ${plan.collisions.join("\n - ")}\n` +
`共享层是扁平的一层,重名即歧义。请改名,或把它放进各自壁纸目录(不同壁纸之间不会冲突)。`,
);
}
const audioDirs = new Map<string, string>();
for (const item of plan.placements) {
audioDirs.set(item.name, item.key);
await copyFileTo(item.from, join(outDir, item.destRel));
}
return { names: plan.placements.map((p) => p.name).sort(), audioDirs };
}
export { writeJson, writeText, baseName, withDotSlash };