perf: 体积从 105.1 MB 降到 28.9 MB(音频转 Opus、图片无损 WebP)

- 两首 flac 母带移入 masters/(gitignore,不入库),转 Opus 128k VBR:3.35 + 3.16 MB
- xilian 三张真 PNG RGBA → 无损 WebP(3.39→1.80、5.01→2.83、3.38→1.76 MB)
- kv37_xilian.png → 无损 WebP 5.13→2.75 MB
- kv37 五张图本来就是 WebP(扩展名误写成 .png),本次只改名并把 atlas 页名改为 .webp
- 删除死文件 dist/jsconfig.json
- 新增 tools/rawdiff.mjs(区分可见差异与仅透明下差异)、tools/atlas-pages.mjs

验证:check-paths 11/11;0 个非 favicon 的 404;无损 WebP 可见像素 0 差异;
libwebp 会改写完全透明像素下的 RGB(xilian 三图 32-49% 像素),渲染级对拍
与 PNG 版相差 0.052/255、热力图全空,确认未因 Linear 过滤渗色。
This commit is contained in:
Shuery committed 2026-09-19 19:41:41 +08:00
1 parent 8ad177c822
commit a391021041
27 files changed
+3006 -2879

No files matched your search

+3
View File
@@ -2,5 +2,8 @@
tools/shots/
tools/.cache/
# 无损音频母带:只在本地留存,绝不进 dist、绝不入库(体积大且与发布无关)
masters/
# 杂项
*.log
@@ -1,6 +1,6 @@
# 02 体积:音频转码 + 未引用资源归位
Status: ready-for-agent
Status: resolved
## 现状
@@ -12,13 +12,42 @@ Status: ready-for-agent
## 步骤
1. 无损母带移出 `dist/`(另存工作区外的 `masters/`,不入库)。
2. 两首 flac 转 Opus(`libopus`,约 96–128 kbps,目标每首 ≤4.5 MB):`ffmpeg -i in.flac -c:a libopus -b:a 112k out.ogg`。保留 `pv37.mp3` 原样。
3. 用工具的音频波形/时长核对转码未截断。
4. PNG → WebP **仅在对拍证明无可见差异后**逐张决定(atlas 页要整组一起转,并同步改 `.atlas` 里的页名)。
1. 无损母带移出 `dist/`,存到 `masters/`(已 gitignore,不入库)。
2. 两首 flac 转 Opus(`libopus` 128k VBR)。保留 `pv37.mp3` 原样。
3. 核对转码未截断、无解码错误。
4. 图片转码**仅在对拍证明无可见差异后**决定(atlas 页要整组转,并同步改 `.atlas` 里的页名)。
## 验收
- `dist` 总体积 ≤35 MB。
- `check-paths` 全绿;本地 curl 确认转码后的音频能播放(时长一致)。
- `check-paths` 全绿;转码后的音频可完整解码。
- 每个比例的对拍像素无回归。
## 结果
`dist` 105.1 MB → **28.92 MB**。
- 音频:`再度和你.ogg` 3.35 MB、`昔涟.ogg` 3.16 MB,均通过完整解码与电平检查。
`昔涟.flac` 源文件末尾一帧损坏(186.671 s 处 invalid frame header),但全长音频都在。
- 图片:**全部走无损 WebP**(放弃有损路线,因为目标已达成,没必要冒险)。
- xilian 三张真 PNG RGBA → 无损 WebP:3.39→1.80、5.01→2.83、3.38→1.76 MB
- `kv37_xilian.png` → 无损 WebP 5.13→2.75 MB
- kv37 的五张图**本来就是 WebP,只是扩展名写成 `.png`**,本次只改名——这也顺带证明 WebP 在本机 WE 环境可用(线上版本一直在用)
- atlas 页名同步改为 `.webp`(`tools/atlas-pages.mjs`;注意这两个 .atlas 是 CRLF 行尾)
- 删除死文件 `dist/jsconfig.json`;`README.txt` 是 Workshop 描述(BBcode),保留。
## 验证
- `check-paths`:11/11 声明路径存在。
- 浏览器:0 个非 favicon 的 404。
- 无损 WebP 与源 PNG 的可见像素 **0 差异**。libwebp 会改写完全透明像素下的 RGB
(`tools/rawdiff.mjs` 证实 xilian 三张图有 32–49% 像素属此类),而本项目贴图是 Linear 过滤、
透明像素 RGB 会渗进边缘,所以额外做了渲染级对拍:与 PNG 版同冻结设置相比
**0.052/255、热力图全空**,确认无渗色。
- 全尺寸矩阵留档于 `tools/shots/step02/`。
## 教训
**先验证再删除**。中途 `-preset default` 让无损编码退化成有损(产物只剩 0.03–0.54 MB),
而源 PNG 当时已被删除;只因为源都在 git 里(`09ff322`)才得以还原。
另外 `-quality` 在这个 ffmpeg 的 libwebp 封装里被忽略,要用 `-q:v`;`-exact` 选项不存在。
+1 -1
View File
@@ -34,7 +34,7 @@
| # | 步骤 | 状态 |
| --- | --- | --- |
| 01 | 资源按 游戏/壁纸 分层,预设拆进各自壁纸目录 | 完成(`09ff322`) |
| 02 | 体积:音频转码 + 未引用资源归位 | 待做 |
| 02 | 体积:音频转 Opus + 图片无损 WebP(105.1 → 28.9 MB) | 完成 |
| 03 | 健壮性:初始化状态机、死代码、用户级覆盖语义 | 待做 |
| 04 | 比例适配:按容器比例重算视口 | 待做 |
| 05 | WE 属性扩展:音源 combo、fps 限制 | 待做 |
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

@@ -10,7 +10,7 @@ export default {
name: "「成为昨日的明天」",
game: "崩坏:星穹铁道",
backgroundImage: asset("./images/kv37_xilian.png"),
backgroundImage: asset("./images/kv37_xilian.webp"),
spineConfig: {
jsonUrl: asset("./effects/kv37.json"),
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+3 -3
View File
@@ -25,13 +25,13 @@ export default {
},
audioOptions: {
source: asset("./audios/「再度和你」.flac"),
source: asset("./audios/再度和你.ogg"),
volume: 0.5,
},
// 本壁纸可选的音源(第 6 步会接进 project.json 的 bgm 属性)
audioChoices: [
{ id: "zaiduheni", name: "再度和你", source: asset("./audios/「再度和你」.flac") },
{ id: "xilian", name: "昔涟", source: asset("./audios/昔涟.flac") },
{ id: "zaiduheni", name: "「再度和你」", source: asset("./audios/再度和你.ogg") },
{ id: "xilian", name: "昔涟", source: asset("./audios/昔涟.ogg") },
],
};
-6
View File
@@ -1,6 +0,0 @@
{
"compilerOptions": {
"module": "CommonJS",
"target": "ES6"
}
}
+31
View File
@@ -0,0 +1,31 @@
// 改写 .atlas 里的贴图页名后缀(页名行是形如 `xilian.png` 的独立一行)。
// 逐行处理、只改匹配 `^\S+\.png$` 的行,保留原有行尾与其余内容,避免误伤区域名。
// 用法:node tools/atlas-pages.mjs <a.atlas> [后缀替换: .png=.webp] [--dry]
import { readFile, writeFile } from "node:fs/promises";
const args = process.argv.slice(2);
const file = args[0];
if (!file) {
console.error("usage: node tools/atlas-pages.mjs <a.atlas> [.png=.webp] [--dry]");
process.exit(2);
}
const pair = (args.find((a) => a.includes("=")) ?? ".png=.webp").split("=");
const dry = args.includes("--dry");
const text = await readFile(file, "utf8");
const lines = text.split("\n");
let changed = 0;
const out = lines.map((line) => {
const m = line.match(/^(\S+\.png)(\r?)$/); // 注意保留 CRLF 行尾,实测这两个 .atlas 是 CRLF
if (!m) return line;
changed++;
return line.replace(/\.png(\r?)$/, `${pair[1]}$1`);
});
if (!changed) {
console.log(`${file}: 没有需要改写的页名行`);
process.exit(0);
}
console.log(`${file}: ${changed} 个页名 → ${pair[1]}${dry ? "(试运行,未写入)" : ""}`);
if (!dry) await writeFile(file, out.join("\n"), "utf8");
+70
View File
@@ -0,0 +1,70 @@
// 逐像素比对两张图(解码成 RGBA 后比),并且区分"差异是否只发生在完全透明的像素下"。
// 这个区分很关键:本项目依赖"A=0 处 RGB 已清零",因为贴图是 Linear 过滤,
// 透明像素的 RGB 会在线性插值时渗进边缘像素——所以"仅在 A=0 处不同"的转码仍然是不可接受的。
// 用法:node tools/rawdiff.mjs <a> <b>
import { spawn } from "node:child_process";
function decode(file) {
return new Promise((resolve, reject) => {
const p = spawn("ffmpeg", ["-v", "error", "-i", file, "-f", "rawvideo", "-pix_fmt", "rgba", "-"], {
stdio: ["ignore", "pipe", "pipe"],
});
const chunks = [];
let err = "";
p.stdout.on("data", (d) => chunks.push(d));
p.stderr.on("data", (d) => (err += d));
p.on("close", (code) => (code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(err || `exit ${code}`))));
});
}
const [a, b] = process.argv.slice(2);
if (!a || !b) {
console.error("usage: node tools/rawdiff.mjs <a> <b>");
process.exit(2);
}
const [da, db] = await Promise.all([decode(a), decode(b)]);
if (da.length !== db.length) {
console.log(`尺寸/格式不同:${da.length} vs ${db.length} 字节`);
process.exit(1);
}
const px = da.length / 4;
let diffAny = 0;
let diffHidden = 0; // 仅因为 A=0 而不可见
let diffVisible = 0;
let maxVisible = 0;
let maxHidden = 0;
for (let i = 0; i < da.length; i += 4) {
const dr = Math.abs(da[i] - db[i]);
const dg = Math.abs(da[i + 1] - db[i + 1]);
const db_ = Math.abs(da[i + 2] - db[i + 2]);
const dA = Math.abs(da[i + 3] - db[i + 3]);
const m = Math.max(dr, dg, db_, dA);
if (!m) continue;
diffAny++;
if (dA === 0 && da[i + 3] === 0) {
diffHidden++;
if (m > maxHidden) maxHidden = m;
} else {
diffVisible++;
if (m > maxVisible) maxVisible = m;
}
}
const pct = (n) => ((n / px) * 100).toFixed(4) + "%";
console.log(`${a} vs ${b}`);
console.log(` 总像素 ${px}`);
console.log(` 有差异像素 ${diffAny} (${pct(diffAny)})`);
console.log(` 可见差异 ${diffVisible} (${pct(diffVisible)}) 最大通道差 ${maxVisible}`);
console.log(` 仅透明下差异 ${diffHidden} (${pct(diffHidden)}) 最大通道差 ${maxHidden}`);
console.log(
diffAny === 0
? " 结论: 逐像素相同"
: diffVisible === 0
? " 结论: 只在完全透明像素下有差异——渲染仍可能因 Linear 过滤在边缘渗色,不算安全"
: " 结论: 可见像素被改动",
);
process.exit(diffVisible === 0 && diffAny === 0 ? 0 : 1);