撤销 issue 02 里的音频转码,把两条 FLAC 放回 dist。 - 恢复方式是**字节级还原**而非重新编码:masters/ 里两份文件的 git 对象哈希与基线 `d8c7e49:dist/assets/audios/*.flac` 完全一致,即与已发布的 v2 是同一份文件。 因此本次提交不新增任何 blob 存储——这两份内容早已在仓库历史里。 - `preset.js` 的音源指向改回 `.flac`。`pv37.mp3` 保持原样:它**没有无损源**, 把有损数据转封装成 FLAC 只会涨体积,而一个 bit 的信息都不会回来。 - 体积基准由「≤35 MB」改为「不超过已发布的 v2(104.98 MB)」,实际 **96.94 MB**, 比 v2 还小(图片部分的无损 WebP 收益仍在),不引入新的上传或订阅风险。 顺带修掉一个自己引入的 bug:改 `preset.js` 时漏写了文件名里的 `「」`, 浏览器请求 404 → `<audio>` 在 **64 ms** 内报 `MediaError.code = 4`(SRC_NOT_SUPPORTED), 看上去像"CEF 不支持 FLAC",实际是路径不存在。判定方法记进 ADR 0004: 把同一份字节交给 `decodeAudioData`,能解出正确时长就说明解码器没问题,问题在传输或路径。 验证: - `test-props`:CEF 支持 audio/flac、无解码错误、readyState=4、时长 205.1 s,14 项音源断言全过。 - `test-acceptance` 新增第 ⑤ 节音频格式不变式:xilian 两个音源全为 flac、无 `.ogg`/`.opus` 残留、`preset.js` 引用的每个音源文件都存在;三种比例 0 报错;预设往返不丢音频设置。 - 分带能量实测:FLAC 高频平滑延伸到 22 kHz(20–22 kHz 处 −50 dB),无有损断崖; 与 Opus 版逐带相差 0.0–0.3 dB。即这是一个由偏好驱动、而非由测量差异驱动的决定。
174 lines
8.4 KiB
JavaScript
174 lines
8.4 KiB
JavaScript
// Issue 06 全量验收。
|
||
// ① dist 体积与死文件;② project.json 与代码一致;③ 三种比例 0 控制台报错;
|
||
// ④ 预设往返不丢用户级音源/音量设置。
|
||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||
import { execFileSync } from "node:child_process";
|
||
import { basename, extname, join } from "node:path";
|
||
import { withPage, sleep } from "../tools/cdp.mjs";
|
||
|
||
let failures = 0;
|
||
const check = (ok, msg, detail) => {
|
||
if (!ok) failures++;
|
||
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${detail}` : ""}`);
|
||
};
|
||
|
||
// ── ① 体积与死文件 ──
|
||
console.log("=== ① dist 体积与死文件 ===");
|
||
const walk = (dir) =>
|
||
readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||
const p = join(dir, e.name);
|
||
return e.isDirectory() ? walk(p) : [p];
|
||
});
|
||
const files = walk("dist");
|
||
const total = files.reduce((s, f) => s + statSync(f).size, 0);
|
||
// 用户已明确豁免体积(音频走无损、壁纸在本地),基准改为"不超过已通过审核的 v2"。
|
||
// 这是真正有意义的界:v2 以 104.98 MB 发布过,所以这个体积不会带来新的上传风险。
|
||
const V2_MB = 104.98;
|
||
check(total <= V2_MB * 1024 * 1024, `总体积不超过已发布的 v2(${V2_MB} MB)`, `${(total / 1048576).toFixed(2)} MB / ${files.length} 个文件`);
|
||
|
||
const TEXT = new Set([".html", ".js", ".json", ".txt", ".css", ".atlas"]);
|
||
const texts = files.filter((f) => TEXT.has(extname(f)));
|
||
const contents = new Map(texts.map((f) => [f, readFileSync(f, "utf8")]));
|
||
// 这四个是 WE 的元数据/入口/说明,本来就不被页面引用
|
||
const ALLOWED = new Set(["project.json", "README.txt", "index.html", "preview.gif"]);
|
||
// 关键:比对时要把文件**自身**的内容排除掉。否则一个文件只要在自己开头写上自己的名字
|
||
// (比如注释里的 "// wallpaper-engine.js")就会被判为"被引用",死文件因此漏网。
|
||
const dead = files.filter((f) => {
|
||
const n = basename(f);
|
||
if (ALLOWED.has(n)) return false;
|
||
const others = texts
|
||
.filter((t) => t !== f)
|
||
.map((t) => contents.get(t))
|
||
.join("\n");
|
||
return !others.includes(n);
|
||
});
|
||
check(dead.length === 0, "0 个死文件", dead.length ? dead.join(", ") : "无孤立资源");
|
||
|
||
// ── ② project.json ──
|
||
console.log("\n=== ② project.json ===");
|
||
const project = JSON.parse(readFileSync("dist/project.json", "utf8"));
|
||
check(project.version === 3, "version = 3", String(project.version));
|
||
check(project.preview === "preview.gif", "preview 指向 preview.gif");
|
||
check(!!project.general.properties.bgm, "新增 bgm combo");
|
||
check(project.general.properties.preset.value === "xilian", "preset 默认值未变");
|
||
const presetValues = project.general.properties.preset.options.map((o) => o.value).sort();
|
||
check(
|
||
JSON.stringify(presetValues) === JSON.stringify(["kv37", "xilian"]),
|
||
"preset 的 combo 取值未变(用户已保存的设置依赖它)",
|
||
presetValues.join(", "),
|
||
);
|
||
const bgmValues = project.general.properties.bgm.options.map((o) => o.value);
|
||
check(bgmValues.includes("auto"), "bgm 含「随预设」档", bgmValues.join(", "));
|
||
const gifSize = statSync("dist/preview.gif").size;
|
||
check(gifSize < 500 * 1024, "preview.gif < 500 KB", `${(gifSize / 1024).toFixed(0)} KB`);
|
||
|
||
// ── ③ 三种比例 0 报错 ──
|
||
console.log("\n=== ③ 21:9 / 16:9 / 9:16 控制台与异常 ===");
|
||
for (const [w, h, tag] of [
|
||
[3440, 1440, "21:9"],
|
||
[1920, 1080, "16:9"],
|
||
[1080, 1920, "9:16"],
|
||
]) {
|
||
const r = await withPage({ width: w, height: h }, async ({ send, evaluate, consoleLines, exceptions }) => {
|
||
await send("Page.navigate", { url: "http://127.0.0.1:8190/?__freeze=1" });
|
||
const deadline = Date.now() + 30000;
|
||
let ready = false;
|
||
while (Date.now() < deadline) {
|
||
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) {
|
||
ready = true;
|
||
break;
|
||
}
|
||
await sleep(150);
|
||
}
|
||
await sleep(500);
|
||
const state = await evaluate(`(function(){
|
||
var p = window.__player, v = p.currentViewport;
|
||
return { w: p.canvas.clientWidth, h: p.canvas.clientHeight,
|
||
vw: +v.width.toFixed(1), vh: +v.height.toFixed(1),
|
||
err: window.__test.error || null };
|
||
})()`);
|
||
return { ready, state, consoleLines, exceptions };
|
||
});
|
||
const noisy = r.consoleLines.filter((l) => /error|uncaught|fail/i.test(l));
|
||
const aspectOk = r.state && Math.abs(r.state.vw / r.state.vh - r.state.w / r.state.h) < 0.002;
|
||
check(r.ready && !r.state?.err, `${tag} 播放器就绪`, r.state?.err ?? "无错");
|
||
check(aspectOk, `${tag} 可见矩形比例与画布一致`, r.state ? `${r.state.vw}×${r.state.vh}` : "—");
|
||
check(noisy.length === 0 && r.exceptions.length === 0, `${tag} 0 控制台报错 / 0 异常`, noisy.concat(r.exceptions).slice(0, 3).join(" | ") || "干净");
|
||
}
|
||
|
||
// ── ④ 预设往返不丢音频设置 ──
|
||
console.log("\n=== ④ 预设往返 10 次,音频设置不得丢失 ===");
|
||
const rt = await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
|
||
await send("Page.navigate", { url: "http://127.0.0.1:8190/?__freeze=1" });
|
||
const deadline = Date.now() + 30000;
|
||
while (Date.now() < deadline) {
|
||
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
|
||
await sleep(150);
|
||
}
|
||
await sleep(500);
|
||
|
||
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({
|
||
audio_file: { value: "https://example.com/custom-track.mp3" },
|
||
audio_volume: { value: 0.2 }
|
||
})`);
|
||
await sleep(300);
|
||
|
||
const rows = [];
|
||
for (let i = 0; i < 10; i++) {
|
||
const id = i % 2 === 0 ? "kv37" : "xilian";
|
||
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: "${id}" } })`);
|
||
await sleep(320);
|
||
rows.push(
|
||
await evaluate(`(function(){
|
||
var a = document.getElementById("background-music");
|
||
return { id: "${id}", src: a.src.split("/").pop(), vol: a.volume,
|
||
draws: window.__test.drawCalls };
|
||
})()`),
|
||
);
|
||
}
|
||
return { rows, exceptions };
|
||
});
|
||
const srcOk = rt.rows.every((r) => r.src === "custom-track.mp3");
|
||
const volOk = rt.rows.every((r) => Math.abs(r.vol - 0.2) < 0.001);
|
||
check(srcOk, "10 次切换后自定义音源仍生效", [...new Set(rt.rows.map((r) => r.src))].join(", "));
|
||
check(volOk, "10 次切换后音量仍为 0.2", [...new Set(rt.rows.map((r) => r.vol))].join(", "));
|
||
check(rt.rows[rt.rows.length - 1].draws > rt.rows[0].draws, "切换后仍在持续绘制", `${rt.rows[0].draws} → ${rt.rows[rt.rows.length - 1].draws}`);
|
||
check(rt.exceptions.length === 0, "往返过程 0 未捕获异常", rt.exceptions.join(" | ") || "无");
|
||
|
||
// ── ⑤ 音频格式:xilian 两个音源必须是无损 ──
|
||
console.log("\n=== ⑤ 音频格式与可达性 ===");
|
||
const audioDirs = [
|
||
["dist/assets/崩坏:星穹铁道/昔涟立绘/audios", "flac", "昔涟立绘(无损)"],
|
||
["dist/assets/崩坏:星穹铁道/「成为昨日的明天」/audios", "mp3", "「成为昨日的明天」"],
|
||
];
|
||
const codecOf = (f) =>
|
||
execFileSync("ffprobe", ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "default=nw=1:nk=1", f])
|
||
.toString()
|
||
.trim();
|
||
for (const [dir, want, label] of audioDirs) {
|
||
const fs2 = readdirSync(dir);
|
||
const codecs = fs2.map((f) => `${f}=${codecOf(join(dir, f))}`);
|
||
check(
|
||
fs2.length > 0 && fs2.every((f) => codecOf(join(dir, f)) === want),
|
||
`${label} 音源全为 ${want}`,
|
||
codecs.join(", "),
|
||
);
|
||
}
|
||
// 有损的 .ogg 必须已经清干净,否则就是残留
|
||
const stale = files.filter((f) => /\.(ogg|opus)$/.test(f));
|
||
check(stale.length === 0, "已无残留的有损音源(.ogg/.opus)", stale.length ? stale.join(", ") : "干净");
|
||
// preset.js 引用的每个音源都必须真实存在——引用错一个字符就是 404 + 立即静音
|
||
const presetFiles = files.filter((f) => basename(f) === "preset.js");
|
||
let broken = [];
|
||
for (const pf of presetFiles) {
|
||
const text = readFileSync(pf, "utf8");
|
||
for (const m of text.matchAll(/asset\("(\.\/audios\/[^"]+)"\)/g)) {
|
||
const target = join(pf, "..", m[1]);
|
||
if (!existsSync(target)) broken.push(`${pf} → ${m[1]}`);
|
||
}
|
||
}
|
||
check(broken.length === 0, "preset.js 引用的音源文件全部存在", broken.length ? broken.join(" | ") : `${presetFiles.length} 份预设,路径全部可达`);
|
||
|
||
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
|
||
process.exit(failures === 0 ? 0 : 1);
|