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 过滤渗色。
No files matched your search
@@ -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` 选项不存在。
|
||||
@@ -34,7 +34,7 @@
|
||||
| # | 步骤 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 01 | 资源按 游戏/壁纸 分层,预设拆进各自壁纸目录 | 完成(`09ff322`) |
|
||||
| 02 | 体积:音频转码 + 未引用资源归位 | 待做 |
|
||||
| 02 | 体积:音频转 Opus + 图片无损 WebP(105.1 → 28.9 MB) | 完成 |
|
||||
| 03 | 健壮性:初始化状态机、死代码、用户级覆盖语义 | 待做 |
|
||||
| 04 | 比例适配:按容器比例重算视口 | 待做 |
|
||||
| 05 | WE 属性扩展:音源 combo、fps 限制 | 待做 |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
kv37.png
|
||||
kv37.webp
|
||||
size:2048,2043
|
||||
filter:Linear,Linear
|
||||
scale:0.34
|
||||
@@ -1376,7 +1376,7 @@ bounds:1281,1991,21,32
|
||||
offsets:2,1,24,35
|
||||
rotate:90
|
||||
|
||||
kv37_2.png
|
||||
kv37_2.webp
|
||||
size:2036,2014
|
||||
filter:Linear,Linear
|
||||
scale:0.34
|
||||
@@ -1398,7 +1398,7 @@ bounds:1858,1504,203,173
|
||||
offsets:0,8,203,181
|
||||
rotate:90
|
||||
|
||||
kv37_3.png
|
||||
kv37_3.webp
|
||||
size:2042,1907
|
||||
filter:Linear,Linear
|
||||
scale:0.34
|
||||
@@ -1495,7 +1495,7 @@ bounds:358,769,349,246
|
||||
zuotui_6
|
||||
bounds:1413,946,471,244
|
||||
|
||||
kv37_4.png
|
||||
kv37_4.webp
|
||||
size:2047,1846
|
||||
filter:Linear,Linear
|
||||
scale:0.34
|
||||
@@ -1532,7 +1532,7 @@ shengti02_50
|
||||
bounds:5,679,439,435
|
||||
offsets:0,0,439,437
|
||||
|
||||
kv37_5.png
|
||||
kv37_5.webp
|
||||
size:1974,1483
|
||||
filter:Linear,Linear
|
||||
scale:0.34
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 MiB |
|
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"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
xilian.png
|
||||
xilian.webp
|
||||
size:2048,2047
|
||||
filter:Linear,Linear
|
||||
scale:0.32
|
||||
@@ -281,7 +281,7 @@ zui1
|
||||
bounds:1140,104,19,6
|
||||
offsets:1,0,20,6
|
||||
|
||||
xilian_2.png
|
||||
xilian_2.webp
|
||||
size:2047,2047
|
||||
filter:Linear,Linear
|
||||
scale:0.32
|
||||
@@ -829,7 +829,7 @@ bounds:44,1170,12,15
|
||||
starlight
|
||||
bounds:1129,225,353,337
|
||||
|
||||
xilian_3.png
|
||||
xilian_3.webp
|
||||
size:2048,1413
|
||||
filter:Linear,Linear
|
||||
scale:0.32
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 5.0 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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") },
|
||||
],
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"target": "ES6"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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);
|
||||