Files
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

532 lines
25 KiB
TypeScript
Raw Permalink 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.
// 一个新分发目录 = 一个能直接上传 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 };