diff --git a/.scratch/test-acceptance.mjs b/.scratch/test-acceptance.mjs index ef4104a..b9bc491 100644 --- a/.scratch/test-acceptance.mjs +++ b/.scratch/test-acceptance.mjs @@ -1,7 +1,8 @@ // Issue 06 全量验收。 // ① dist 体积与死文件;② project.json 与代码一致;③ 三种比例 0 控制台报错; // ④ 预设往返不丢用户级音源/音量设置。 -import { readdirSync, readFileSync, statSync } from "node:fs"; +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"; @@ -20,7 +21,10 @@ const walk = (dir) => }); const files = walk("dist"); const total = files.reduce((s, f) => s + statSync(f).size, 0); -check(total <= 35 * 1024 * 1024, "总体积 ≤ 35 MB", `${(total / 1048576).toFixed(2)} MB / ${files.length} 个文件`); +// 用户已明确豁免体积(音频走无损、壁纸在本地),基准改为"不超过已通过审核的 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))); @@ -131,5 +135,39 @@ check(volOk, "10 次切换后音量仍为 0.2", [...new Set(rt.rows.map((r) => r 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); diff --git a/.scratch/test-props.mjs b/.scratch/test-props.mjs index cfea666..4fe11d9 100644 --- a/.scratch/test-props.mjs +++ b/.scratch/test-props.mjs @@ -41,14 +41,42 @@ await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }; // 载入即 xilian,默认音源应是「再度和你」 - check(name(await src()) === "再度和你.ogg", "初始默认音源 = 昔涟立绘默认", name(await src())); + check(name(await src()) === "「再度和你」.flac", "初始默认音源 = 昔涟立绘默认", name(await src())); - await step("bgm 选「昔涟」", () => bgm("xilian"), "昔涟.ogg"); + // 无损是否真的可用,取决于 CEF 能不能解 FLAC——不能只看路径对不对。 + // readyState>=1(HAVE_METADATA) 说明容器与编码被接受,duration>0 说明解析出了正确时长。 + const healthProbe = `(function(){ + var a = document.getElementById("background-music"); + return { rs: a.readyState, dur: a.duration, net: a.networkState, + err: a.error ? a.error.code : null, flac: a.canPlayType("audio/flac") }; + })()`; + let h = null; + for (let i = 0; i < 40; i++) { + h = await evaluate(healthProbe); + if (h.rs >= 1 || h.err !== null) break; + await sleep(250); + } + check(h.flac === "probably" || h.flac === "maybe", "CEF 支持 audio/flac", h.flac || "(不支持)"); + check(h.err === null, "音频无解码错误", h.err === null ? "无" : `MediaError.code=${h.err}`); + check(h.rs >= 1, "FLAC 已解析出元数据(readyState≥1)", `readyState=${h.rs}`); + check(h.dur > 100 && h.dur < 300, "解析出的时长符合源文件", `${h.dur?.toFixed(1)} 秒`); + + // 元数据能解析 ≠ 真的在放。等一会儿看播放头有没有前进——这才是"无损可用"的终局证据。 + await sleep(1500); + const pb = await evaluate(`(function(){ + var a = document.getElementById("background-music"); + return { paused: a.paused, t: a.currentTime, loop: a.loop, vol: a.volume }; + })()`); + check(!pb.paused, "音频处于播放状态", pb.paused ? "仍暂停" : "播放中"); + check(pb.t > 0.5, "播放头已前进(确实在解码播放)", `currentTime=${Number(pb.t).toFixed(2)} s`); + check(pb.loop === true, "循环播放已开启", String(pb.loop)); + + await step("bgm 选「昔涟」", () => bgm("xilian"), "昔涟.flac"); await step("切到 kv37(选择不属于它)→ 回落", () => preset("kv37"), "pv37.mp3"); await step("bgm 选「版本 PV」(属于 kv37)", () => bgm("pv37"), "pv37.mp3"); - await step("切回 xilian(pv37 不属于它)→ 回落", () => preset("xilian"), "再度和你.ogg"); - await step("bgm 选「「再度和你」」", () => bgm("zaiduheni"), "再度和你.ogg"); - await step("bgm 回到「随预设」", () => bgm("auto"), "再度和你.ogg"); + await step("切回 xilian(pv37 不属于它)→ 回落", () => preset("xilian"), "「再度和你」.flac"); + await step("bgm 选「「再度和你」」", () => bgm("zaiduheni"), "「再度和你」.flac"); + await step("bgm 回到「随预设」", () => bgm("auto"), "「再度和你」.flac"); // 用户自填 URL 优先级最高 await step("bgm 选「昔涟」后填自定义 URL", async () => { diff --git a/.scratch/wallpaper-optimization/issues/02-audio-and-image-size.md b/.scratch/wallpaper-optimization/issues/02-audio-and-image-size.md index 1e7ac31..a7b2403 100644 --- a/.scratch/wallpaper-optimization/issues/02-audio-and-image-size.md +++ b/.scratch/wallpaper-optimization/issues/02-audio-and-image-size.md @@ -1,6 +1,6 @@ # 02 体积:音频转码 + 未引用资源归位 -Status: resolved +Status: resolved,但**音频转码已被推翻**(见文末「修订」);图片部分保持有效 ## 现状 @@ -51,3 +51,34 @@ Status: resolved **先验证再删除**。中途 `-preset default` 让无损编码退化成有损(产物只剩 0.03–0.54 MB), 而源 PNG 当时已被删除;只因为源都在 git 里(`09ff322`)才得以还原。 另外 `-quality` 在这个 ffmpeg 的 libwebp 封装里被忽略,要用 `-q:v`;`-exact` 选项不存在。 + +## 修订:音频改回无损(用户决定) + +用户要求「音频可使用无损格式,这个壁纸本身就在本地,无需考虑大小」。 +于是**撤销本文第 2 步的音频转码**,把两条 FLAC 放回 `dist`。 + +- 恢复方式是**字节级还原**,不是重新编码:`masters/` 里那两条 FLAC 的 git 对象哈希 + 与基线 `d8c7e49:dist/assets/audios/*.flac` **完全一致**,即与已发布的 v2 装的是同一份文件。 +- `preset.js` 的音源指向随之改回 `.flac`;`pv37.mp3` **没有无损源**,保持原样(也一直是 mp3)。 +- 体积基准由「≤35 MB」改为「不超过已发布的 v2(104.98 MB)」。实际 **96.94 MB**—— + 比 v2 还小(图片那部分的无损 WebP 收益仍在),所以**不引入新的上传/订阅风险**。 +- 图片部分**不受影响**:无损 WebP 本来就不牺牲画质,没有取舍可撤。 + +### 关于"转成无损"的边界 + +`pv37.mp3` 不能靠转封装变成无损:把有损数据装进 FLAC 只会让 2.3 MB 涨到十几 MB, +而一个 bit 的信息都不会回来。要它无损,只能换一个无损源文件。 + +### 踩到的坑:文件名里的「」 + +`「再度和你」.flac` 的**文件名带 `「」`**(v2 就是如此)。改完 `preset.js` 后我漏写了这两个字符, +浏览器请求 `再度和你.flac` → 服务端 404 → `