diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..622cb23 --- /dev/null +++ b/CONTEXT.md @@ -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_: 截图测试、视觉回归 diff --git a/dist/assets/audios/pv37.mp3 b/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/audios/pv37.mp3 similarity index 100% rename from dist/assets/audios/pv37.mp3 rename to dist/assets/崩坏:星穹铁道/「成为昨日的明天」/audios/pv37.mp3 diff --git a/dist/assets/effects/kv37.json b/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/effects/kv37.json similarity index 100% rename from dist/assets/effects/kv37.json rename to dist/assets/崩坏:星穹铁道/「成为昨日的明天」/effects/kv37.json diff --git a/dist/assets/images/kv37.atlas b/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37.atlas similarity index 100% rename from dist/assets/images/kv37.atlas rename to dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37.atlas diff --git a/dist/assets/images/kv37.png b/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37.png similarity index 100% rename from dist/assets/images/kv37.png rename to dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37.png diff --git a/dist/assets/images/kv37_2.png b/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37_2.png similarity index 100% rename from dist/assets/images/kv37_2.png rename to dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37_2.png diff --git a/dist/assets/images/kv37_3.png b/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37_3.png similarity index 100% rename from dist/assets/images/kv37_3.png rename to dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37_3.png diff --git a/dist/assets/images/kv37_4.png b/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37_4.png similarity index 100% rename from dist/assets/images/kv37_4.png rename to dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37_4.png diff --git a/dist/assets/images/kv37_5.png b/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37_5.png similarity index 100% rename from dist/assets/images/kv37_5.png rename to dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37_5.png diff --git a/dist/assets/images/kv37_xilian.png b/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37_xilian.png similarity index 100% rename from dist/assets/images/kv37_xilian.png rename to dist/assets/崩坏:星穹铁道/「成为昨日的明天」/images/kv37_xilian.png diff --git a/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/preset.js b/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/preset.js new file mode 100644 index 0000000..3c26e71 --- /dev/null +++ b/dist/assets/崩坏:星穹铁道/「成为昨日的明天」/preset.js @@ -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") }], +}; diff --git a/dist/assets/audios/「再度和你」.flac b/dist/assets/崩坏:星穹铁道/昔涟立绘/audios/「再度和你」.flac similarity index 100% rename from dist/assets/audios/「再度和你」.flac rename to dist/assets/崩坏:星穹铁道/昔涟立绘/audios/「再度和你」.flac diff --git a/dist/assets/audios/昔涟.flac b/dist/assets/崩坏:星穹铁道/昔涟立绘/audios/昔涟.flac similarity index 100% rename from dist/assets/audios/昔涟.flac rename to dist/assets/崩坏:星穹铁道/昔涟立绘/audios/昔涟.flac diff --git a/dist/assets/effects/xilian.json b/dist/assets/崩坏:星穹铁道/昔涟立绘/effects/xilian.json similarity index 100% rename from dist/assets/effects/xilian.json rename to dist/assets/崩坏:星穹铁道/昔涟立绘/effects/xilian.json diff --git a/dist/assets/images/ava.jpg b/dist/assets/崩坏:星穹铁道/昔涟立绘/images/ava.jpg similarity index 100% rename from dist/assets/images/ava.jpg rename to dist/assets/崩坏:星穹铁道/昔涟立绘/images/ava.jpg diff --git a/dist/assets/images/xilian.atlas b/dist/assets/崩坏:星穹铁道/昔涟立绘/images/xilian.atlas similarity index 100% rename from dist/assets/images/xilian.atlas rename to dist/assets/崩坏:星穹铁道/昔涟立绘/images/xilian.atlas diff --git a/dist/assets/images/xilian.png b/dist/assets/崩坏:星穹铁道/昔涟立绘/images/xilian.png similarity index 100% rename from dist/assets/images/xilian.png rename to dist/assets/崩坏:星穹铁道/昔涟立绘/images/xilian.png diff --git a/dist/assets/images/xilian_2.png b/dist/assets/崩坏:星穹铁道/昔涟立绘/images/xilian_2.png similarity index 100% rename from dist/assets/images/xilian_2.png rename to dist/assets/崩坏:星穹铁道/昔涟立绘/images/xilian_2.png diff --git a/dist/assets/images/xilian_3.png b/dist/assets/崩坏:星穹铁道/昔涟立绘/images/xilian_3.png similarity index 100% rename from dist/assets/images/xilian_3.png rename to dist/assets/崩坏:星穹铁道/昔涟立绘/images/xilian_3.png diff --git a/dist/assets/崩坏:星穹铁道/昔涟立绘/preset.js b/dist/assets/崩坏:星穹铁道/昔涟立绘/preset.js new file mode 100644 index 0000000..17a9e9e --- /dev/null +++ b/dist/assets/崩坏:星穹铁道/昔涟立绘/preset.js @@ -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") }, + ], +}; diff --git a/dist/scripts/index.js b/dist/scripts/index.js index 06cdf73..556ce68 100644 --- a/dist/scripts/index.js +++ b/dist/scripts/index.js @@ -1,87 +1,87 @@ -import PresetStore from "./preset-store.js"; - -import PresetController from "./preset-controller.js"; -import BackgroundController from "./background-controller.js"; -import SpineController from "./spine-controller.js"; -import AudioController from "./audio-controller.js"; - -let presetController = new PresetController(PresetStore); - -let { backgroundImage, spineConfig, audioOptions } = - presetController.set("xilian"); - -let backgroundController = new BackgroundController(backgroundImage); -let spineController = new SpineController("spine-container", spineConfig); -let audioController = new AudioController("background-music", audioOptions); - -let currentAudioSource = ""; -let currentAudioVolume = 0; - -window.addEventListener("load", function () { - backgroundController.setImage(); - spineController.create(); - audioController.play(); -}); - -window.addEventListener("resize", () => { - spineController.adjustCanvas(); -}); - -window.wallpaperRegisterListener = { - applyGeneralProperties: function (properties) { - if (properties.fps) { - // wallpaperSettings.fps = properties.fps; - } - }, - applyUserProperties: function (properties) { - if (properties.preset) { - const preset = properties.preset.value; - let { backgroundImage, spineConfig, audioOptions } = - presetController.set(preset); - backgroundController.changeImage(backgroundImage); - spineController.changeConfig(spineConfig); - audioController.changeOptions(audioOptions); - } - if (properties.audio_file) { - const audio_file = properties.audio_file.value; - currentAudioSource = audio_file; - audioController.changeSource(audio_file); - } - if (properties.audio_volume) { - const audio_volume = properties.audio_volume.value; - currentAudioVolume = audio_volume; - audioController.changeVolume(audio_volume); - } - }, -}; - -window.wallpaperPropertyListener = { - applyGeneralProperties: function (properties) { - if (properties.fps) { - // wallpaperSettings.fps = properties.fps; - } - }, - applyUserProperties: function (properties) { - if (properties.preset) { - const preset = properties.preset.value; - let { backgroundImage, spineConfig, audioOptions } = presetController.set( - preset, - currentAudioSource, - currentAudioVolume - ); - backgroundController.changeImage(backgroundImage); - spineController.changeConfig(spineConfig); - audioController.changeOptions(audioOptions); - } - if (properties.audio_file) { - const audio_file = properties.audio_file.value; - currentAudioSource = audio_file; - audioController.changeSource(audio_file); - } - if (properties.audio_volume) { - const audio_volume = properties.audio_volume.value; - currentAudioVolume = audio_volume; - audioController.changeVolume(audio_volume); - } - }, -}; +import Presets from "./presets.js"; + +import PresetController from "./preset-controller.js"; +import BackgroundController from "./background-controller.js"; +import SpineController from "./spine-controller.js"; +import AudioController from "./audio-controller.js"; + +let presetController = new PresetController(Presets); + +let { backgroundImage, spineConfig, audioOptions } = + presetController.set("xilian"); + +let backgroundController = new BackgroundController(backgroundImage); +let spineController = new SpineController("spine-container", spineConfig); +let audioController = new AudioController("background-music", audioOptions); + +let currentAudioSource = ""; +let currentAudioVolume = 0; + +window.addEventListener("load", function () { + backgroundController.setImage(); + spineController.create(); + audioController.play(); +}); + +window.addEventListener("resize", () => { + spineController.adjustCanvas(); +}); + +window.wallpaperRegisterListener = { + applyGeneralProperties: function (properties) { + if (properties.fps) { + // wallpaperSettings.fps = properties.fps; + } + }, + applyUserProperties: function (properties) { + if (properties.preset) { + const preset = properties.preset.value; + let { backgroundImage, spineConfig, audioOptions } = + presetController.set(preset); + backgroundController.changeImage(backgroundImage); + spineController.changeConfig(spineConfig); + audioController.changeOptions(audioOptions); + } + if (properties.audio_file) { + const audio_file = properties.audio_file.value; + currentAudioSource = audio_file; + audioController.changeSource(audio_file); + } + if (properties.audio_volume) { + const audio_volume = properties.audio_volume.value; + currentAudioVolume = audio_volume; + audioController.changeVolume(audio_volume); + } + }, +}; + +window.wallpaperPropertyListener = { + applyGeneralProperties: function (properties) { + if (properties.fps) { + // wallpaperSettings.fps = properties.fps; + } + }, + applyUserProperties: function (properties) { + if (properties.preset) { + const preset = properties.preset.value; + let { backgroundImage, spineConfig, audioOptions } = presetController.set( + preset, + currentAudioSource, + currentAudioVolume + ); + backgroundController.changeImage(backgroundImage); + spineController.changeConfig(spineConfig); + audioController.changeOptions(audioOptions); + } + if (properties.audio_file) { + const audio_file = properties.audio_file.value; + currentAudioSource = audio_file; + audioController.changeSource(audio_file); + } + if (properties.audio_volume) { + const audio_volume = properties.audio_volume.value; + currentAudioVolume = audio_volume; + audioController.changeVolume(audio_volume); + } + }, +}; diff --git a/dist/scripts/preset-store.js b/dist/scripts/preset-store.js deleted file mode 100644 index f5ee421..0000000 --- a/dist/scripts/preset-store.js +++ /dev/null @@ -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; diff --git a/dist/scripts/presets.js b/dist/scripts/presets.js new file mode 100644 index 0000000..39d8b1a --- /dev/null +++ b/dist/scripts/presets.js @@ -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; diff --git a/docs/adr/0001-resource-layout.md b/docs/adr/0001-resource-layout.md new file mode 100644 index 0000000..17a2aa2 --- /dev/null +++ b/docs/adr/0001-resource-layout.md @@ -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/"` 相对路径一字不改。 diff --git a/docs/adr/0002-preset-as-es-module.md b/docs/adr/0002-preset-as-es-module.md new file mode 100644 index 0000000..c48c322 --- /dev/null +++ b/docs/adr/0002-preset-as-es-module.md @@ -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` 消费。本项目接受这一点——预设数据量极小,且始终由代码持有。 diff --git a/tools/check-paths.mjs b/tools/check-paths.mjs new file mode 100644 index 0000000..4624f20 --- /dev/null +++ b/tools/check-paths.mjs @@ -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); diff --git a/tools/compare.mjs b/tools/compare.mjs new file mode 100644 index 0000000..aecf092 --- /dev/null +++ b/tools/compare.mjs @@ -0,0 +1,58 @@ +// 对拍比较:把两批截图逐对做差值,给出"改完到底有没有动到画面"的判定。 +// 用法:node tools/compare.mjs [--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 [--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); +} diff --git a/tools/heatmap.mjs b/tools/heatmap.mjs new file mode 100644 index 0000000..3e27ded --- /dev/null +++ b/tools/heatmap.mjs @@ -0,0 +1,47 @@ +// 把两张图的差异降采样成 ASCII 热力图,好在纯文本里看出"差异落在画面哪里"。 +// 用法:node tools/heatmap.mjs [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 [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) + "+"); diff --git a/tools/serve.mjs b/tools/serve.mjs index 24ead24..6033851 100644 --- a/tools/serve.mjs +++ b/tools/serve.mjs @@ -84,6 +84,16 @@ const DRIVER = (q) => `