feat: 比例适配、bgm 音源选择与 fps 限流,打包 v3

取景(issue 04)
- 新增 viewport-fitter.js:让立绘层缩放跟随背景图的 cover 缩放——背景放大多少倍、
  特效就放大多少倍。原先背景走 cover、立绘层走 contain,比例一变两者就分叉。
  16:9 下与作者设定逐像素一致(kv37 为 0 差异)。实测证伪了"contain 会裁掉作者视口"
  这个最初的判断:contain 在任何比例下都完整含住作者视口。
- 应用方式为原地改写 config.viewport 字段后调 setViewport(),不重建播放器;
  维护挂在 config.frame 上按画布比例增量进行(config.success 早于 setViewport,拿不到视口)。

WE 属性(issue 05)
- project.json 新增 bgm combo(所有壁纸音源的并集 + 「随预设」);所选项不属于当前壁纸时
  音频回落该壁纸默认,选择本身保留。
- fps 限流包裹实例 drawFrame 并接管排程。修掉两个实测缺陷:朴素丢帧会把 rAF 抖动放大成
  整拍丢失(目标 30fps 实测只有 22.5fps);fps:0 被真值判断吞掉导致无法取消限流。

打包(issue 06)
- project.json version 升至 3 并更新描述;preset 的 combo 取值保持 xilian/kv37 不变。
- preview.gif 821 KB → 347 KB(每 2 帧取 1 帧 + 128 色不抖动,逐帧与源帧像素完全相同)。
- index.html 加内联 favicon,消除 CEF 自动请求 /favicon.ico 造成的 404 报错。

其它
- dist/scripts/wallpaper-engine.js 移到 tools/:它没有任何入口引用,且环境判据依赖真实 WE
  并不存在的 wallpaperRegisterListener,一旦按它自己的说明挂进 index.html 会在生产环境自我激活。
- 修正死文件检查:原检查会拿文件名匹配到文件自身的内容,使这类文件漏网。
- 新增离线算式验证与 resize / 属性 / 全量验收三套浏览器测试脚本。
This commit is contained in:
Shuery committed 2026-09-19 20:29:25 +08:00
1 parent 4662856963
commit eb56efbd9e
22 files changed
+2766 -1012

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
// 验收测试:改窗口尺寸不得重建播放器,且取景要跟着画布比例走。
//
// 判据是"播放器实例身份":给加载后的实例打上标记,之后每次改视口都核对
// window.__player 是否还是同一个对象、标记是否还在。重建会换掉实例,
// 标记随之消失——这比数日志可靠得多。
import { withPage, sleep } from "../tools/cdp.mjs";
const URL = "http://127.0.0.1:8190/?__freeze=1";
const sizes = [
[1920, 1080],
[2560, 1440],
[3440, 1440],
[1080, 1920],
[1600, 900],
[2560, 1600],
[3840, 2160],
[1280, 720],
[1920, 1080],
];
const stress = [];
for (let i = 0; i < 20; i++) stress.push(i % 2 ? [1080, 1920] : [1920, 1080]);
const out = await withPage(
{ width: 1920, height: 1080, extraArgs: ["--enable-precise-memory-info", "--js-flags=--expose-gc"] },
async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: URL });
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(600);
const stamp = await evaluate(`(function(){
window.__first = window.__player;
if (window.__player) window.__player.__probe = 'A';
return { hasPlayer: !!window.__player, error: window.__test ? window.__test.error : null };
})()`);
const rows = [];
const measure = async (w, h, tag) => {
await send("Emulation.setDeviceMetricsOverride", {
width: w,
height: h,
deviceScaleFactor: 1,
mobile: false,
});
await sleep(420);
// 先强制回收再读堆,否则量到的是 V8 的延迟回收节奏而不是真实占用
await evaluate('(typeof gc === "function") ? (gc(), true) : false');
const r = await evaluate(`(function(){
var p = window.__player;
if (!p) return null;
var v = p.currentViewport;
return {
same: p === window.__first,
probe: p.__probe,
css: [p.canvas.clientWidth, p.canvas.clientHeight],
resolved: [ +v.x.toFixed(2), +v.y.toFixed(2), +v.width.toFixed(2), +v.height.toFixed(2) ],
pad: [v.padLeft, v.padRight, v.padTop, v.padBottom],
childViews: window.__player.config.viewport.animations ? Object.keys(window.__player.config.viewport.animations).length : -1,
heap: performance.memory ? performance.memory.usedJSHeapSize : 0,
drawCalls: window.__test ? window.__test.drawCalls : 0
};
})()`);
rows.push({ tag, w, h, ...(r ?? { same: false, probe: null, css: null, resolved: null, pad: null, heap: 0, drawCalls: 0 }) });
};
for (const [w, h] of sizes) await measure(w, h, `${w}x${h}`);
const heapBeforeStress = rows[rows.length - 1].heap;
for (const [w, h] of stress) await measure(w, h, `压力 ${w}x${h}`);
const heapAfterStress = rows[rows.length - 1].heap;
return { stamp, rows, heapBeforeStress, heapAfterStress, consoleLines, exceptions };
},
);
const { stamp, rows, heapBeforeStress, heapAfterStress, consoleLines, exceptions } = out;
let failures = 0;
const fail = (msg) => {
failures++;
console.log(` ✗ ${msg}`);
};
console.log(`播放器就绪: ${stamp.hasPlayer ? "是" : "否"} 驱动报错: ${stamp.error ?? "无"}`);
console.log("");
console.log("画布尺寸 实例未重建 内边距 可见矩形 比例核对");
for (const r of rows) {
if (r.tag.startsWith("压力")) continue;
const a = r.w / r.h;
const ra = r.resolved ? r.resolved[2] / r.resolved[3] : NaN;
const ratioOk = Math.abs(ra - a) < 0.002;
const padOk = r.pad && r.pad.every((p) => p === 0);
const rect = r.resolved ? r.resolved.map((n) => String(n).padStart(9)).join(" ") : "—";
console.log(
`${r.tag.padEnd(14)} ${String(r.same).padEnd(10)} ${(padOk ? "全 0 ✓" : JSON.stringify(r.pad)).padEnd(10)} ${rect} ${ratioOk ? "✓" : `✗ 期望 ${a.toFixed(4)} 实得 ${ra.toFixed(4)}`}`,
);
if (!r.same) fail(`${r.tag} 重建了播放器(标记丢失: ${r.probe})`);
if (!padOk) fail(`${r.tag} 内边距没有归零`);
if (!ratioOk) fail(`${r.tag} 可见矩形比例与画布不符`);
}
// 首尾同为 1920x1080:取景必须回到完全相同的值,证明重算可逆、无漂移
const first = rows.find((r) => r.tag === "1920x1080");
const last = [...rows].reverse().find((r) => r.tag === "1920x1080");
const reversible = first.resolved.every((n, i) => Math.abs(n - last.resolved[i]) < 0.01);
if (!reversible) fail(`回到 16:9 后取景漂移:${first.resolved} → ${last.resolved}`);
console.log(`\n回到 16:9 取景可逆: ${reversible ? "✓" : "✗"} ${first.resolved} → ${last.resolved}`);
const growth = heapAfterStress - heapBeforeStress;
console.log(`连续 20 次 resize 堆内存: ${(heapBeforeStress / 1048576).toFixed(1)} MB → ${(heapAfterStress / 1048576).toFixed(1)} MB(增长 ${(growth / 1048576).toFixed(2)} MB)`);
if (growth > 4 * 1048576) fail(`连续 resize 内存增长过大:${(growth / 1048576).toFixed(2)} MB`);
const drawCalls = rows[rows.length - 1].drawCalls;
console.log(`累计绘制帧数: ${drawCalls}`);
if (!(drawCalls > 0)) fail("没有绘制任何帧");
const noisy = consoleLines.filter((l) => /error|warn|uncaught|fail/i.test(l));
console.log(`控制台异常: ${noisy.length ? noisy.slice(0, 5).join(" | ") : "无 ✓"}`);
if (noisy.length) fail("控制台有报错/警告");
console.log(`未捕获异常: ${exceptions.length ? exceptions.slice(0, 4).join(" | ") : "无 ✓"}`);
if (exceptions.length) fail("有未捕获异常");
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
process.exit(failures === 0 ? 0 : 1);