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

+109
View File
@@ -0,0 +1,109 @@
// 把差分掩码(立绘层 = 正常帧 − 藏 canvas 参考帧)压成 ASCII,
// 这样在没有图像输入能力的情况下也能"看"清构图。
// 用法:
// node tools/ascii.mjs <normal.png> <ref.png> [cols] 差分两幅,打印强度图 + 实体占比图
// node tools/ascii.mjs <image.png> [cols] 单幅,打印亮度图
// 行数按 2.2:1 的字符高宽比自动换算,保证画面不被压扁。
import { spawn } from "node:child_process";
const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
const [fileA, fileB, colsArg] = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const solidOnly = flags.includes("--solid");
if (!fileA) {
console.error("usage: node tools/ascii.mjs <image.png> [ref.png] [cols]");
process.exit(2);
}
function probe(file) {
return new Promise((resolve) => {
const p = spawn("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file]);
let out = "";
p.stdout.on("data", (d) => (out += d));
p.on("close", () => {
const [w, h] = out.trim().split(",").map(Number);
resolve({ w, h });
});
});
}
function gray(file) {
return new Promise((resolve, reject) => {
const p = spawn("ffmpeg", ["-v", "error", "-i", file, "-f", "rawvideo", "-pix_fmt", "gray", "-"], { 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 RAMP = " .:-=+*#%@";
const { w: W, h: H } = await probe(fileA);
const bufA = await gray(fileA);
if (bufA.length < W * H) {
console.error(`像素不足:${bufA.length} < ${W * H}`);
process.exit(1);
}
// 终端字符高约为宽的 2.2 倍,据此把列数换算成行数,保持画面比例
const cols = Math.max(20, Math.min(Number(colsArg) || 110, Math.floor((46 * 2.2 * W) / H)));
const rows = Math.max(6, Math.round((cols * H) / W / 2.2));
const cw = W / cols;
const chh = H / rows;
function grid(fn) {
const out = [];
for (let r = 0; r < rows; r++) {
let line = "";
for (let c = 0; c < cols; c++) {
const x0 = Math.floor(c * cw);
const x1 = Math.min(W, Math.ceil((c + 1) * cw));
const y0 = Math.floor(r * chh);
const y1 = Math.min(H, Math.ceil((r + 1) * chh));
line += RAMP[Math.max(0, Math.min(9, fn(x0, x1, y0, y1)))];
}
out.push(line);
}
return out;
}
function print(title, lines) {
console.log(title);
for (const l of lines) console.log("|" + l + "|");
console.log("");
}
console.log(`画布 ${W}x${H} → ${cols}列 x ${rows}行,单元 ${cw.toFixed(1)}x${chh.toFixed(1)}px;ramp "${RAMP}"`);
if (!fileB) {
print(`亮度图 ${fileA}`, grid((x0, x1, y0, y1) => {
let s = 0;
let n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += bufA[y * W + x]; n++; }
return Math.round((s / n / 255) * 9);
}));
} else {
const bufB = await gray(fileB);
if (bufB.length < W * H) {
console.error(`参考帧像素不足:${bufB.length} < ${W * H}`);
process.exit(1);
}
const d = new Uint8Array(W * H);
for (let i = 0; i < W * H; i++) d[i] = Math.abs(bufA[i] - bufB[i]);
if (!solidOnly)
print(`立绘层 · 平均强度 (${fileA} − ${fileB})`, grid((x0, x1, y0, y1) => {
let s = 0;
let n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += d[y * W + x]; n++; }
return Math.round((s / n / 255) * 9);
}));
print(`立绘层 · 实体占比 (>96)`, grid((x0, x1, y0, y1) => {
let k = 0;
let n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { if (d[y * W + x] > 96) k++; n++; }
return Math.round((k / n) * 9);
}));
}
+54
View File
@@ -0,0 +1,54 @@
// 量出一个预设在某分辨率下的取景几何,用来决定"该给什么视口"。
// 输出:作者声明的取景矩形、播放器扫描出的骨架边界、当前相机实际可见的世界矩形。
// 用法:node tools/frame.mjs <port> <presetId> <W> <H>
import { withPage, sleep } from "./cdp.mjs";
const [port, preset, w, h] = process.argv.slice(2);
if (!port || !preset || !w || !h) {
console.error("usage: node tools/frame.mjs <port> <presetId> <W> <H>");
process.exit(2);
}
const EXPR = `(function () {
var p = window.__player;
if (!p || !p.skeleton) return { error: "player/skeleton 未就绪" };
var cv = p.currentViewport;
var cam = p.sceneRenderer.camera;
var cw = p.canvas.width, ch = p.canvas.height;
// 用播放器自己的投影取真实可见世界矩形,不靠对 zoom 语义的猜测
var tl = cam.screenToWorld(new spine.Vector3(0, 0, 0), cw, ch);
var br = cam.screenToWorld(new spine.Vector3(cw, ch, 0), cw, ch);
var offset = new spine.Vector2(), size = new spine.Vector2();
p.skeleton.getBounds(offset, size, new Array(2));
var vis = { x: tl.x, y: br.y, w: br.x - tl.x, h: tl.y - br.y };
return {
canvas: { w: cw, h: ch, aspect: +(cw / ch).toFixed(4) },
base: { x: +cv.x.toFixed(2), y: +cv.y.toFixed(2), w: +cv.width.toFixed(2), h: +cv.height.toFixed(2) },
pads: { l: +cv.padLeft.toFixed(2), r: +cv.padRight.toFixed(2), b: +cv.padBottom.toFixed(2), t: +cv.padTop.toFixed(2) },
resolved: { x: +(cv.x - cv.padLeft).toFixed(2), y: +(cv.y - cv.padBottom).toFixed(2), w: +(cv.width + cv.padLeft + cv.padRight).toFixed(2), h: +(cv.height + cv.padBottom + cv.padTop).toFixed(2) },
visible: { x: +vis.x.toFixed(2), y: +vis.y.toFixed(2), w: +vis.w.toFixed(2), h: +vis.h.toFixed(2), aspect: +(vis.w / vis.h).toFixed(4) },
skeletonBounds: { x: +offset.x.toFixed(2), y: +offset.y.toFixed(2), w: +size.x.toFixed(2), h: +size.y.toFixed(2) },
skeletonInView: {
coversView: (offset.x <= vis.x && offset.y <= vis.y && offset.x + size.x >= vis.x + vis.w && offset.y + size.y >= vis.y + vis.h),
overflowLeft: +(vis.x - offset.x).toFixed(2),
overflowRight: +((offset.x + size.x) - (vis.x + vis.w)).toFixed(2),
overflowTop: +((offset.y + size.y) - (vis.y + vis.h)).toFixed(2),
overflowBottom: +(vis.y - offset.y).toFixed(2),
},
};
})()`;
const result = await withPage({ width: Number(w), height: Number(h), profile: "tools/.cache/edge-frame" }, async ({ send, evaluate }) => {
const url = `http://127.0.0.1:${port}/index.html?__props=${encodeURIComponent(JSON.stringify({ preset }))}&__freeze=1`;
await send("Page.navigate", { url });
for (let i = 0; i < 200; i++) {
if (await evaluate("!!(window.__player && window.__player.skeleton)")) break;
await sleep(150);
}
await sleep(400);
return evaluate(EXPR);
});
console.log(`${preset} @ ${w}x${h}`);
console.log(JSON.stringify(result, null, 2));
+85
View File
@@ -0,0 +1,85 @@
// 量出「立绘在屏幕上的实际足迹」:正常帧与藏 canvas 参考帧的差值掩码。
// 不依赖对视口/相机数学的任何建模——直接测渲染结果。
// 输出:足迹包围盒、四条边是否被画布切掉、覆盖面积占比、足迹底边距画布底部的距离。
// 用法:node tools/subject.mjs <normal.png> <ref.png> [threshold] [edgeBand]
import { spawn } from "node:child_process";
const [normal, ref, threshold = "8", edgeBand = "2"] = process.argv.slice(2);
if (!normal || !ref) {
console.error("usage: node tools/subject.mjs <normal.png> <ref.png> [threshold] [edgeBand]");
process.exit(2);
}
const T = Number(threshold);
const BAND = Number(edgeBand);
function probe(file) {
return new Promise((resolve) => {
const p = spawn("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file]);
let out = "";
p.stdout.on("data", (d) => (out += d));
p.on("close", () => {
const [w, h] = out.trim().split(",").map(Number);
resolve({ w, h });
});
});
}
const { w: W, h: H } = await probe(normal);
const buf = await new Promise((resolve, reject) => {
const p = spawn("ffmpeg", ["-v", "error", "-i", normal, "-i", ref, "-lavfi", "blend=all_mode=difference,format=gray", "-f", "rawvideo", "-pix_fmt", "gray", "-"], {
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}`))));
});
if (buf.length < W * H) {
console.error(`差分流只返回 ${buf.length} 字节,期望 ${W * H}`);
process.exit(1);
}
let minX = W, maxX = -1, minY = H, maxY = -1, count = 0;
const edge = { left: 0, right: 0, top: 0, bottom: 0 };
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (buf[y * W + x] <= T) continue;
count++;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
if (x < BAND) edge.left++;
if (x >= W - BAND) edge.right++;
if (y < BAND) edge.top++;
if (y >= H - BAND) edge.bottom++;
}
}
if (!count) {
console.log(`${normal} 没有超过阈值 ${T} 的像素——立绘层是空的?`);
process.exit(1);
}
const pct = (n) => ((n / (W * H)) * 100).toFixed(3) + "%";
console.log(`${normal} (${W}x${H}, 阈值 ${T})`);
console.log(` 足迹包围盒 x:[${minX}, ${maxX}] y:[${minY}, ${maxY}] 尺寸 ${maxX - minX + 1}x${maxY - minY + 1}`);
console.log(` 覆盖面积 ${count} 像素 (${pct(count)})`);
console.log(` 底部余量 画布底 ${H} - 足迹底 ${maxY} = ${H - 1 - maxY} px`);
console.log(` 顶部余量 ${minY} px`);
console.log(` 左右余量 左 ${minX} px / 右 ${W - 1 - maxX} px`);
console.log(
` 贴边情况 ${[
edge.left ? `左 ${edge.left}` : "左 -",
edge.right ? `右 ${edge.right}` : "右 -",
edge.top ? `上 ${edge.top}` : "上 -",
edge.bottom ? `下 ${edge.bottom}` : "下 -",
].join(" ")} (${BAND}px 带内)`,
);
const cut = edge.left || edge.right || edge.top || edge.bottom;
console.log(` 判定 ${cut ? "被画布边缘切到" : "四边都未触边"}`);
File diff suppressed because it is too large. Load diff