refactor: 资源按 游戏/壁纸 分层,预设拆进各自壁纸目录
- assets/<游戏>/<壁纸>/{effects,images,audios}/,每档壁纸自包含
- 每档壁纸自带 preset.js:用 import.meta.url 推导自身资源路径,目录可整体搬走;
目录名用显示名(含「」),代码键用稳定 id(xilian/kv37,与 WE 属性值绑定)
- preset-store.js -> presets.js:静态 import 两份预设并建 id 索引
- 新增 CONTEXT.md 术语表与 ADR-0001/0002
- 新增 check-paths / compare / heatmap 三个验证工具;serve.mjs 改为每帧强制动画相位归零
验证:check-paths 11/11 路径存在(含此前零引用的 昔涟.flac);浏览器 0 个真实 404;
与基线 worktree 同冻结设置对拍,9 对跨版本比较中 4 对逐像素完全相同(差异 0),
其余 ≤0.19/255,远低于基线自身的双模态抖动(2.27/255)。
This commit is contained in:
1 parent
399f99edd7
commit
09ff322fe8
29 files changed
+388
-127
No files matched your search
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# SpineWallpaper(网页壁纸作品)
|
||||||
|
|
||||||
|
一份已发布的 Wallpaper Engine 网页壁纸,以及围绕它的资源分层与验证约定。这个上下文只关心"一档壁纸由什么组成、怎么被打包和切换"。
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
**作品(Project)**:
|
||||||
|
一个 Wallpaper Engine 打包单元,即 `dist/` 整个目录;`project.json` 是它的清单,`index.html` 是它的入口。
|
||||||
|
_Avoid_: 工程、站点、应用
|
||||||
|
|
||||||
|
**游戏(Game)**:
|
||||||
|
壁纸所出自的作品名(`崩坏:星穹铁道`),资源分层的第一级,也是目录结构的第一层。
|
||||||
|
_Avoid_: 来源、IP
|
||||||
|
|
||||||
|
**壁纸(Wallpaper)**:
|
||||||
|
用户可在 WE 属性里切换的一档壁纸(`昔涟立绘`、`「成为昨日的明天」`);它的显示名就是它的目录名,可以改。
|
||||||
|
_Avoid_: 预设、选项、preset(当指"这一档"本身时)
|
||||||
|
|
||||||
|
**预设(Preset)**:
|
||||||
|
一档壁纸的配置实体,落盘为该壁纸目录下的 `preset.js`,声明这一档要用哪些资源、以什么参数播放。一档壁纸对应且仅对应一个预设。
|
||||||
|
_Avoid_: 配置、profile、manifest
|
||||||
|
|
||||||
|
**预设 id**:
|
||||||
|
预设的稳定标识(`xilian`、`kv37`),必须与 WE 属性值一致,永不随显示名变化——已发布作品的用户设置靠它维系。
|
||||||
|
_Avoid_: slug、键名、代号
|
||||||
|
|
||||||
|
**音源(AudioSource)**:
|
||||||
|
一首可作背景音乐的曲子。每首曲子归属且只归属一档壁纸;WE 的音源列表是所有壁纸音源声明的并集。
|
||||||
|
_Avoid_: BGM、曲目、音频文件
|
||||||
|
|
||||||
|
**视口(Viewport)**:
|
||||||
|
交给 Spine 播放器的世界坐标系矩形,决定构图。它以等比"contain"方式映射到画布:单轴贴边、另一轴会多露出世界,因此负 padding 的裁切只在目标比例下成立。
|
||||||
|
_Avoid_: 相机、取景框、裁剪区
|
||||||
|
|
||||||
|
**对拍(AB capture)**:
|
||||||
|
同一壁纸、同一分辨率、同一动画相位下的改动前后像素比对。噪声底约 0.7/255;低于 1 视为无差异。
|
||||||
|
_Avoid_: 截图测试、视觉回归
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+34
@@ -0,0 +1,34 @@
|
|||||||
|
// 「成为昨日的明天」—— 官方 3.7 版本专题展示页
|
||||||
|
// 这份预设自带全部资源,路径由 import.meta.url 从本文件位置推导,
|
||||||
|
// 因此整个目录可以整体搬走或改名,而不用改这里的任何一行。
|
||||||
|
|
||||||
|
const base = new URL("./", import.meta.url);
|
||||||
|
const asset = (path) => new URL(path, base).href;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "kv37", // 与 project.json 中 combo 选项的 value 一致,勿改
|
||||||
|
name: "「成为昨日的明天」",
|
||||||
|
game: "崩坏:星穹铁道",
|
||||||
|
|
||||||
|
backgroundImage: asset("./images/kv37_xilian.png"),
|
||||||
|
|
||||||
|
spineConfig: {
|
||||||
|
jsonUrl: asset("./effects/kv37.json"),
|
||||||
|
atlasUrl: asset("./images/kv37.atlas"),
|
||||||
|
animation: "animation",
|
||||||
|
viewport: {
|
||||||
|
x: -1215,
|
||||||
|
y: -410,
|
||||||
|
width: 2048,
|
||||||
|
height: 1024,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
audioOptions: {
|
||||||
|
source: asset("./audios/pv37.mp3"),
|
||||||
|
volume: 0.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 本壁纸可选的音源(第 6 步会接进 project.json 的 bgm 属性)
|
||||||
|
audioChoices: [{ id: "pv37", name: "版本 PV", source: asset("./audios/pv37.mp3") }],
|
||||||
|
};
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Vendored
+37
@@ -0,0 +1,37 @@
|
|||||||
|
// 昔涟立绘 —— 官方昔涟动态立绘
|
||||||
|
// 这份预设自带全部资源,路径由 import.meta.url 从本文件位置推导,
|
||||||
|
// 因此整个目录可以整体搬走或改名,而不用改这里的任何一行。
|
||||||
|
|
||||||
|
const base = new URL("./", import.meta.url);
|
||||||
|
const asset = (path) => new URL(path, base).href;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "xilian", // 与 project.json 中 combo 选项的 value 一致,勿改
|
||||||
|
name: "昔涟立绘",
|
||||||
|
game: "崩坏:星穹铁道",
|
||||||
|
|
||||||
|
backgroundImage: asset("./images/ava.jpg"),
|
||||||
|
|
||||||
|
spineConfig: {
|
||||||
|
jsonUrl: asset("./effects/xilian.json"),
|
||||||
|
atlasUrl: asset("./images/xilian.atlas"),
|
||||||
|
animation: "idle",
|
||||||
|
viewport: {
|
||||||
|
padLeft: "-25%",
|
||||||
|
padRight: "-28%",
|
||||||
|
padTop: "-30%",
|
||||||
|
padBottom: "-23%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
audioOptions: {
|
||||||
|
source: asset("./audios/「再度和你」.flac"),
|
||||||
|
volume: 0.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 本壁纸可选的音源(第 6 步会接进 project.json 的 bgm 属性)
|
||||||
|
audioChoices: [
|
||||||
|
{ id: "zaiduheni", name: "再度和你", source: asset("./audios/「再度和你」.flac") },
|
||||||
|
{ id: "xilian", name: "昔涟", source: asset("./audios/昔涟.flac") },
|
||||||
|
],
|
||||||
|
};
|
||||||
Vendored
+87
-87
@@ -1,87 +1,87 @@
|
|||||||
import PresetStore from "./preset-store.js";
|
import Presets from "./presets.js";
|
||||||
|
|
||||||
import PresetController from "./preset-controller.js";
|
import PresetController from "./preset-controller.js";
|
||||||
import BackgroundController from "./background-controller.js";
|
import BackgroundController from "./background-controller.js";
|
||||||
import SpineController from "./spine-controller.js";
|
import SpineController from "./spine-controller.js";
|
||||||
import AudioController from "./audio-controller.js";
|
import AudioController from "./audio-controller.js";
|
||||||
|
|
||||||
let presetController = new PresetController(PresetStore);
|
let presetController = new PresetController(Presets);
|
||||||
|
|
||||||
let { backgroundImage, spineConfig, audioOptions } =
|
let { backgroundImage, spineConfig, audioOptions } =
|
||||||
presetController.set("xilian");
|
presetController.set("xilian");
|
||||||
|
|
||||||
let backgroundController = new BackgroundController(backgroundImage);
|
let backgroundController = new BackgroundController(backgroundImage);
|
||||||
let spineController = new SpineController("spine-container", spineConfig);
|
let spineController = new SpineController("spine-container", spineConfig);
|
||||||
let audioController = new AudioController("background-music", audioOptions);
|
let audioController = new AudioController("background-music", audioOptions);
|
||||||
|
|
||||||
let currentAudioSource = "";
|
let currentAudioSource = "";
|
||||||
let currentAudioVolume = 0;
|
let currentAudioVolume = 0;
|
||||||
|
|
||||||
window.addEventListener("load", function () {
|
window.addEventListener("load", function () {
|
||||||
backgroundController.setImage();
|
backgroundController.setImage();
|
||||||
spineController.create();
|
spineController.create();
|
||||||
audioController.play();
|
audioController.play();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
spineController.adjustCanvas();
|
spineController.adjustCanvas();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.wallpaperRegisterListener = {
|
window.wallpaperRegisterListener = {
|
||||||
applyGeneralProperties: function (properties) {
|
applyGeneralProperties: function (properties) {
|
||||||
if (properties.fps) {
|
if (properties.fps) {
|
||||||
// wallpaperSettings.fps = properties.fps;
|
// wallpaperSettings.fps = properties.fps;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
applyUserProperties: function (properties) {
|
applyUserProperties: function (properties) {
|
||||||
if (properties.preset) {
|
if (properties.preset) {
|
||||||
const preset = properties.preset.value;
|
const preset = properties.preset.value;
|
||||||
let { backgroundImage, spineConfig, audioOptions } =
|
let { backgroundImage, spineConfig, audioOptions } =
|
||||||
presetController.set(preset);
|
presetController.set(preset);
|
||||||
backgroundController.changeImage(backgroundImage);
|
backgroundController.changeImage(backgroundImage);
|
||||||
spineController.changeConfig(spineConfig);
|
spineController.changeConfig(spineConfig);
|
||||||
audioController.changeOptions(audioOptions);
|
audioController.changeOptions(audioOptions);
|
||||||
}
|
}
|
||||||
if (properties.audio_file) {
|
if (properties.audio_file) {
|
||||||
const audio_file = properties.audio_file.value;
|
const audio_file = properties.audio_file.value;
|
||||||
currentAudioSource = audio_file;
|
currentAudioSource = audio_file;
|
||||||
audioController.changeSource(audio_file);
|
audioController.changeSource(audio_file);
|
||||||
}
|
}
|
||||||
if (properties.audio_volume) {
|
if (properties.audio_volume) {
|
||||||
const audio_volume = properties.audio_volume.value;
|
const audio_volume = properties.audio_volume.value;
|
||||||
currentAudioVolume = audio_volume;
|
currentAudioVolume = audio_volume;
|
||||||
audioController.changeVolume(audio_volume);
|
audioController.changeVolume(audio_volume);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
window.wallpaperPropertyListener = {
|
window.wallpaperPropertyListener = {
|
||||||
applyGeneralProperties: function (properties) {
|
applyGeneralProperties: function (properties) {
|
||||||
if (properties.fps) {
|
if (properties.fps) {
|
||||||
// wallpaperSettings.fps = properties.fps;
|
// wallpaperSettings.fps = properties.fps;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
applyUserProperties: function (properties) {
|
applyUserProperties: function (properties) {
|
||||||
if (properties.preset) {
|
if (properties.preset) {
|
||||||
const preset = properties.preset.value;
|
const preset = properties.preset.value;
|
||||||
let { backgroundImage, spineConfig, audioOptions } = presetController.set(
|
let { backgroundImage, spineConfig, audioOptions } = presetController.set(
|
||||||
preset,
|
preset,
|
||||||
currentAudioSource,
|
currentAudioSource,
|
||||||
currentAudioVolume
|
currentAudioVolume
|
||||||
);
|
);
|
||||||
backgroundController.changeImage(backgroundImage);
|
backgroundController.changeImage(backgroundImage);
|
||||||
spineController.changeConfig(spineConfig);
|
spineController.changeConfig(spineConfig);
|
||||||
audioController.changeOptions(audioOptions);
|
audioController.changeOptions(audioOptions);
|
||||||
}
|
}
|
||||||
if (properties.audio_file) {
|
if (properties.audio_file) {
|
||||||
const audio_file = properties.audio_file.value;
|
const audio_file = properties.audio_file.value;
|
||||||
currentAudioSource = audio_file;
|
currentAudioSource = audio_file;
|
||||||
audioController.changeSource(audio_file);
|
audioController.changeSource(audio_file);
|
||||||
}
|
}
|
||||||
if (properties.audio_volume) {
|
if (properties.audio_volume) {
|
||||||
const audio_volume = properties.audio_volume.value;
|
const audio_volume = properties.audio_volume.value;
|
||||||
currentAudioVolume = audio_volume;
|
currentAudioVolume = audio_volume;
|
||||||
audioController.changeVolume(audio_volume);
|
audioController.changeVolume(audio_volume);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Vendored
-40
@@ -1,40 +0,0 @@
|
|||||||
const PresetStore = {
|
|
||||||
kv37: {
|
|
||||||
backgroundImage: "./assets/images/kv37_xilian.png",
|
|
||||||
spineConfig: {
|
|
||||||
jsonUrl: "./assets/effects/kv37.json",
|
|
||||||
atlasUrl: "./assets/images/kv37.atlas",
|
|
||||||
animation: "animation",
|
|
||||||
viewport: {
|
|
||||||
x: -1215,
|
|
||||||
y: -410,
|
|
||||||
width: 2048,
|
|
||||||
height: 1024,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
audioOptions: {
|
|
||||||
source: "./assets/audios/pv37.mp3",
|
|
||||||
volume: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
xilian: {
|
|
||||||
backgroundImage: "./assets/images/ava.jpg",
|
|
||||||
spineConfig: {
|
|
||||||
jsonUrl: "./assets/effects/xilian.json",
|
|
||||||
atlasUrl: "./assets/images/xilian.atlas",
|
|
||||||
animation: "idle",
|
|
||||||
viewport: {
|
|
||||||
padLeft: "-25%",
|
|
||||||
padRight: "-28%",
|
|
||||||
padTop: "-30%",
|
|
||||||
padBottom: "-23%",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
audioOptions: {
|
|
||||||
source: "./assets/audios/「再度和你」.flac",
|
|
||||||
volume: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PresetStore;
|
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
// 预设索引:把每份壁纸自己的 preset.js 汇总成 id → 预设 的查表。
|
||||||
|
//
|
||||||
|
// id 必须与 project.json 里 combo 选项的 value 一致(xilian / kv37),
|
||||||
|
// 改了会让老用户已保存的预设选择失效,所以 id 永远跟着 WE 属性走,
|
||||||
|
// 而目录名(游戏名/壁纸显示名)是给人看的、可以改。
|
||||||
|
|
||||||
|
import xilian from "../assets/崩坏:星穹铁道/昔涟立绘/preset.js";
|
||||||
|
import kv37 from "../assets/崩坏:星穹铁道/「成为昨日的明天」/preset.js";
|
||||||
|
|
||||||
|
const Presets = Object.fromEntries([xilian, kv37].map((preset) => [preset.id, preset]));
|
||||||
|
|
||||||
|
export default Presets;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 资源按 游戏/壁纸 分层,显示名做目录、id 做键
|
||||||
|
|
||||||
|
已发布作品(workshopid 3604974793)原先把两套壁纸的资源混在 `assets/{images,effects,audios}/` 三个平铺目录里,加第三档就必须靠文件名前缀认领。我们改为每档壁纸自包含:`assets/<游戏>/<壁纸>/{effects,images,audios}/`,并让每档壁纸自带一份 `preset.js`。
|
||||||
|
|
||||||
|
关键取舍是**目录名用显示名(含 `「」` 等符号),而代码里的键用 ASCII 的预设 id**。反过来做(目录名用 id)会让目录结构在 WE 的文件夹里毫无可读性;统一用显示名做键则会在改标题时打断老用户已保存的预设选择。所以两者各司其职:显示名服务于人,id 服务于 WE 属性与用户设置,二者用 `preset.js` 里的 `id` 字段绑定。
|
||||||
|
|
||||||
|
保留了 `effects/`、`images/`、`audios/` 这三层同名子目录,是为了让 Spine 骨架 JSON 里的 `"images": "../images/"` 相对路径一字不改。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 预设用 ES module 而不是 JSON
|
||||||
|
|
||||||
|
每档壁纸的 `preset.js` 是 `export default {...}` 的 ES module,而不是由 `fetch` 拉取的 `preset.json`。
|
||||||
|
|
||||||
|
理由是资源路径的自包含性:模块内用 `import.meta.url` 推导 `./images/...` 的绝对 URL,于是整个壁纸目录可以被复制、改名、搬家而不需要改任何一行路径。若用 JSON,路径只能以**页面**为基准写死,一旦目录改名(本项目的目录名就是显示名,改标题=改目录)就会全站 404,且每档壁纸都必须先异步取回 JSON 才能建播放器,给启动时序又加一个竞态点。
|
||||||
|
|
||||||
|
代价:配置文件不再是纯数据,不能直接 `JSON.parse` 消费。本项目接受这一点——预设数据量极小,且始终由代码持有。
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 静态检查:每份 preset.js 里声明的每一个资源路径是否真实存在。
|
||||||
|
// 这能抓到"运行时才会暴露"的拼写错误(比如某首备用 BGM 从未被加载过,写错了也不会有人发现)。
|
||||||
|
// 用法:node tools/check-paths.mjs
|
||||||
|
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { glob } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const root = resolve("dist");
|
||||||
|
const presetFiles = [];
|
||||||
|
for await (const f of glob("assets/**/preset.js", { cwd: root })) presetFiles.push(f);
|
||||||
|
|
||||||
|
let ok = 0;
|
||||||
|
let bad = 0;
|
||||||
|
|
||||||
|
for (const rel of presetFiles.sort()) {
|
||||||
|
const mod = await import(pathToFileURL(resolve(root, rel)).href);
|
||||||
|
const preset = mod.default;
|
||||||
|
const urls = [];
|
||||||
|
const walk = (value, path) => {
|
||||||
|
if (typeof value === "string" && /^(file|https?):/.test(value)) urls.push([path, value]);
|
||||||
|
else if (value && typeof value === "object") {
|
||||||
|
for (const [k, v] of Object.entries(value)) walk(v, `${path}.${k}`);
|
||||||
|
} else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`));
|
||||||
|
};
|
||||||
|
walk(preset, preset.id);
|
||||||
|
|
||||||
|
console.log(`\n${rel} (id=${preset.id}, name=${preset.name})`);
|
||||||
|
for (const [path, url] of urls) {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.protocol !== "file:") {
|
||||||
|
console.log(` ? ${path} -> 非本地路径,跳过: ${url}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const file = fileURLToPath(u);
|
||||||
|
const info = await stat(file).catch(() => null);
|
||||||
|
if (info?.isFile()) {
|
||||||
|
console.log(` ok ${path} (${(info.size / 1024).toFixed(1)} KB)`);
|
||||||
|
ok++;
|
||||||
|
} else {
|
||||||
|
console.log(` MISSING ${path} -> ${file}`);
|
||||||
|
bad++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${ok} 个路径存在,${bad} 个缺失`);
|
||||||
|
process.exit(bad ? 1 : 0);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// 对拍比较:把两批截图逐对做差值,给出"改完到底有没有动到画面"的判定。
|
||||||
|
// 用法:node tools/compare.mjs <A目录> <B目录> [--tag A] [--tag B] [--threshold 1.0]
|
||||||
|
// 例: node tools/compare.mjs before after
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const [dirA, dirB] = argv;
|
||||||
|
if (!dirA || !dirB) {
|
||||||
|
console.error("usage: node tools/compare.mjs <dirA> <dirB> [--threshold 1.0]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const opt = (name, dflt) => {
|
||||||
|
const i = argv.indexOf(`--${name}`);
|
||||||
|
return i >= 0 ? argv[i + 1] : dflt;
|
||||||
|
};
|
||||||
|
const threshold = Number(opt("threshold", "1.0"));
|
||||||
|
|
||||||
|
function run(cmd, args) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const p = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let out = "";
|
||||||
|
p.stdout.on("data", (d) => (out += d));
|
||||||
|
p.stderr.on("data", (d) => (out += d));
|
||||||
|
p.on("close", () => resolve(out));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const A = resolve("tools/shots", dirA);
|
||||||
|
const B = resolve("tools/shots", dirB);
|
||||||
|
const files = (await readdir(A)).filter((f) => f.endsWith(".png") && !f.endsWith(".ref.png")).sort();
|
||||||
|
|
||||||
|
let worst = 0;
|
||||||
|
let worstName = "";
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const a = resolve(A, f);
|
||||||
|
const b = resolve(B, f);
|
||||||
|
const out = await run(process.execPath, ["tools/diff.mjs", a, b]);
|
||||||
|
const m = out.match(/mean\|diff\|=([\d.]+)/);
|
||||||
|
const mean = m ? Number(m[1]) : NaN;
|
||||||
|
const verdict = Number.isNaN(mean) ? "MISSING" : mean < threshold ? "same" : "CHANGED";
|
||||||
|
if (Number.isNaN(mean)) failed++;
|
||||||
|
else if (mean > worst) {
|
||||||
|
worst = mean;
|
||||||
|
worstName = f;
|
||||||
|
}
|
||||||
|
console.log(`${f.padEnd(28)} mean|diff|=${String(mean).padStart(7)} ${verdict}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${dirA} → ${dirB}:${files.length} 对;最大差异 ${worst.toFixed(3)}/255(${worstName || "无"});判定阈值 ${threshold}`);
|
||||||
|
if (failed) {
|
||||||
|
console.log(`${failed} 对缺少对照文件`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// 把两张图的差异降采样成 ASCII 热力图,好在纯文本里看出"差异落在画面哪里"。
|
||||||
|
// 用法:node tools/heatmap.mjs <a.png> <b.png> [cols] [rows]
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
const [a, b, cols = "48", rows = "20"] = process.argv.slice(2);
|
||||||
|
if (!a || !b) {
|
||||||
|
console.error("usage: node tools/heatmap.mjs <a.png> <b.png> [cols] [rows]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = Number(cols);
|
||||||
|
const H = Number(rows);
|
||||||
|
const RAMP = " .:-=+*#%@";
|
||||||
|
|
||||||
|
const buf = await new Promise((resolve) => {
|
||||||
|
const p = spawn(
|
||||||
|
"ffmpeg",
|
||||||
|
[
|
||||||
|
"-hide_banner", "-loglevel", "error",
|
||||||
|
"-i", a, "-i", b,
|
||||||
|
"-lavfi", `blend=all_mode=difference,format=gray,scale=${W}:${H}:flags=area`,
|
||||||
|
"-f", "rawvideo", "-pix_fmt", "gray", "-",
|
||||||
|
],
|
||||||
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
const chunks = [];
|
||||||
|
p.stdout.on("data", (d) => chunks.push(d));
|
||||||
|
p.on("close", () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (buf.length < W * H) {
|
||||||
|
console.error(`ffmpeg 只返回 ${buf.length} 字节,期望 ${W * H}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${a} vs ${b} (${W}x${H} 降采样,越亮=差异越大)`);
|
||||||
|
console.log(" +" + "-".repeat(W) + "+");
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
let line = "";
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const v = buf[y * W + x];
|
||||||
|
line += RAMP[Math.min(RAMP.length - 1, Math.round((v / 255) * (RAMP.length - 1) * 2))];
|
||||||
|
}
|
||||||
|
console.log(String(y).padStart(3) + " |" + line + "|");
|
||||||
|
}
|
||||||
|
console.log(" +" + "-".repeat(W) + "+");
|
||||||
@@ -84,6 +84,16 @@ const DRIVER = (q) => `<script>
|
|||||||
var origDraw = proto.drawFrame;
|
var origDraw = proto.drawFrame;
|
||||||
proto.drawFrame = function () {
|
proto.drawFrame = function () {
|
||||||
state.drawCalls = (state.drawCalls || 0) + 1;
|
state.drawCalls = (state.drawCalls || 0) + 1;
|
||||||
|
// 冻结只做一次是不够的:相位可能在成功回调前后被推进过一帧。这里每帧都强制归零,
|
||||||
|
// 保证"同一份代码的任何两次截图"是同一相位,对拍才有意义。
|
||||||
|
if (freeze) {
|
||||||
|
try {
|
||||||
|
var cur = this.animationState && this.animationState.getCurrent(0);
|
||||||
|
if (cur) cur.trackTime = 0;
|
||||||
|
if (this.skeleton) this.skeleton.updateWorldTransform(2);
|
||||||
|
this.viewportTransitionStart = performance.now() - 1e6;
|
||||||
|
} catch (e) { state.error = "freeze-frame: " + e; }
|
||||||
|
}
|
||||||
return origDraw.apply(this, arguments);
|
return origDraw.apply(this, arguments);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -94,6 +104,9 @@ const DRIVER = (q) => `<script>
|
|||||||
var cur = p.animationState && p.animationState.getCurrent(0);
|
var cur = p.animationState && p.animationState.getCurrent(0);
|
||||||
if (cur) cur.trackTime = 0;
|
if (cur) cur.trackTime = 0;
|
||||||
if (p.skeleton) p.skeleton.updateWorldTransform(2);
|
if (p.skeleton) p.skeleton.updateWorldTransform(2);
|
||||||
|
// 视口过渡(transitionTime 默认 0.25s)是时间相关的插值,会把"冻结"变得不彻底:
|
||||||
|
// 把它推到足够久以前,transitionAlpha >= 1,直接使用 currentViewport。
|
||||||
|
p.viewportTransitionStart = performance.now() - 1e6;
|
||||||
state.info = {
|
state.info = {
|
||||||
animations: p.skeleton ? p.skeleton.data.animations.map(function (a) { return a.name; }) : [],
|
animations: p.skeleton ? p.skeleton.data.animations.map(function (a) { return a.name; }) : [],
|
||||||
skin: p.skeleton ? p.skeleton.data.skins.map(function (s) { return s.name; }) : [],
|
skin: p.skeleton ? p.skeleton.data.skins.map(function (s) { return s.name; }) : [],
|
||||||
|
|||||||
Reference in new issue
Block a user