From 4662856963e6aa91894b5edc94453a1123502673 Mon Sep 17 00:00:00 2001
From: Shuery <2463253700@qq.com>
Date: Sat, 19 Sep 2026 19:53:15 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E7=AB=9E?=
=?UTF-8?q?=E6=80=81=E3=80=81=E7=94=A8=E6=88=B7=E7=BA=A7=E8=A6=86=E7=9B=96?=
=?UTF-8?q?=E8=AF=AD=E4=B9=89=E4=B8=8E=E6=AD=BB=E4=BB=A3=E7=A0=81=EF=BC=88?=
=?UTF-8?q?=E9=87=8D=E6=9E=84=E5=9B=9B=E4=B8=AA=E6=8E=A7=E5=88=B6=E5=99=A8?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
初始化竞态(已实证):旧 changeConfig() 整个函数体都在 if (this.player) 里,含配置赋值,
WE 在 window.load 之前下发属性时被静默丢弃,导致背景/音乐已切到新壁纸、立绘还是旧的。
改为「状态先写、应用后置」:index.js 用单一 state 记录(presetId/audioSource/audioVolume),
render() 决定能否立刻应用;setConfig() 无条件记录配置,create() 用记下的配置创建。
- 删除 index.js 里重复的 wallpaperRegisterListener(该 API 在本机 WE 不存在)
- 删除 adjustCanvas():播放器每帧自己做 renderer.resize(1) 且已注册 resize 监听,
而它用 getBoundingClientRect() 写 canvas.width 却不乘 DPR,属于有害的重复实现
- 用户级音源/音量覆盖:切壁纸不再清回预设默认值(旧 PresetController 的启发式已移除)
- 同源设置改为 no-op:否则两档壁纸共用同一首曲子时一切换就从头播放
- 只在壁纸真的变了才重建播放器,否则拖动音量滑块会触发重新下载 3.6MB 骨架+贴图
- 未知 preset id 保持现状并告警,不再静默回退
验证:属性在 DOMContentLoaded 下发时玩家动画正确为 animation(kv37);
用户自定义音源 custom-track.mp3 与音量 0.2 在切壁纸后完整保留;
重发同一预设时音乐从 1.3s 连续到 1.9s 未重启;未知 id 不崩。
对拍:与重构前同强冻结设置 8 批次 6 配置逐像素完全相同(差异 0)。
---
dist/scripts/audio-controller.js | 102 +--
dist/scripts/background-controller.js | 19 +-
dist/scripts/index.js | 173 +++--
dist/scripts/preset-controller.js | 50 +-
dist/scripts/spine-controller.js | 74 +-
dist/scripts/wallpaper-engine.js | 974 ++++++++++++++++++++++++++
tools/diff.mjs | 48 +-
tools/serve.mjs | 9 +-
8 files changed, 1209 insertions(+), 240 deletions(-)
create mode 100644 dist/scripts/wallpaper-engine.js
diff --git a/dist/scripts/audio-controller.js b/dist/scripts/audio-controller.js
index 3680ab9..96c77b0 100644
--- a/dist/scripts/audio-controller.js
+++ b/dist/scripts/audio-controller.js
@@ -1,64 +1,64 @@
+// 背景音乐。
+//
+// 两条交互约定:
+// 1. 音源与音量是「用户级覆盖」:切换壁纸不得把用户自定义的音源/音量清回预设默认值。
+// 2. 音源相同的设置是 no-op:否则两档壁纸共用同一首曲子时,一切换就把音乐从头开始放。
export default class AudioController {
constructor(audioId, options = {}) {
this.audio = document.getElementById(audioId);
- this.audio.src = options.source;
- this.audio.volume = options.volume;
- this.audio.loop = true;
this.isPlaying = false;
- this.isChangingSource = false;
- this.previousSource = null;
+ this.retryTimer = undefined;
+ this.audio.loop = true;
+
+ if (options.source) this.setSource(options.source);
+ if (options.volume !== undefined) this.setVolume(options.volume);
}
+
+ // audio.src 会被解析成绝对 URL;预设里的 source 也是绝对 URL(preset.js 用 import.meta.url 推导)
+ get source() {
+ return this.audio.src || "";
+ }
+
+ setSource(source) {
+ if (!source) return;
+ if (this.source === new URL(source, document.baseURI).href) return;
+
+ const wasPlaying = this.isPlaying;
+ this.audio.src = source;
+ this.audio.load();
+ if (wasPlaying) this.play();
+ }
+
+ setVolume(volume) {
+ const value = Math.max(0, Math.min(1, Number(volume)));
+ this.audio.volume = Number.isFinite(value) ? value : 1;
+
+ if (this.audio.volume <= 0) this.pause();
+ else if (!this.isPlaying) this.play();
+ }
+
play() {
- if (this.isPlaying) {
- return;
- }
- if (this.isChangingSource) {
- return;
- }
- if (this.audio) {
- setTimeout(() => {
+ if (this.isPlaying) return;
+
+ this.audio.play().then(
+ () => {
this.isPlaying = true;
- this.audio.play().catch((error) => {
- this.isPlaying = false;
- this.changeSource(this.previousSource);
- });
- }, 200);
- }
+ },
+ (error) => {
+ this.isPlaying = false;
+ // 只重试一次:自动播放被拒时重试多少次都没用,重试只是覆盖"音频尚未就绪"
+ if (this.retryTimer === undefined) {
+ this.retryTimer = setTimeout(() => {
+ this.retryTimer = undefined;
+ this.play();
+ }, 500);
+ }
+ },
+ );
}
+
pause() {
this.audio.pause();
this.isPlaying = false;
}
- changeSource(newSource) {
- if (this.isChangingSource) {
- return;
- }
- if (this.audio) {
- this.isChangingSource = true;
- this.pause();
- this.previousSource = this.audio.src;
- this.audio.src = newSource;
- this.isChangingSource = false;
- this.play();
- }
- }
- changeVolume(newVolume) {
- const clampedVolume = Math.max(0, Math.min(1, newVolume));
- this.audio.volume = clampedVolume;
- this.changeStateByVolume();
- }
- changeStateByVolume() {
- const currentVolume = this.audio.volume;
- if (currentVolume > 0 && !this.isPlaying) {
- this.play();
- } else if (currentVolume <= 0 && this.isPlaying) {
- this.pause();
- }
- }
- changeOptions(newOptions) {
- const newSource = newOptions.source;
- const newVolume = newOptions.volume;
- this.changeSource(newSource);
- this.changeVolume(newVolume);
- }
}
diff --git a/dist/scripts/background-controller.js b/dist/scripts/background-controller.js
index f0bc02c..05119df 100644
--- a/dist/scripts/background-controller.js
+++ b/dist/scripts/background-controller.js
@@ -1,14 +1,13 @@
+// 背景图:只在真的换了图时写一次样式。
export default class BackgroundController {
- constructor(backgroundImage) {
- this.backgroundImage = backgroundImage;
+ constructor() {
+ this.appliedImage = "";
}
- setImage(backgroundImage = this.backgroundImage) {
- this.backgroundImage = backgroundImage;
- document.getElementsByTagName(
- "body"
- )[0].style.backgroundImage = `url(${backgroundImage})`;
- }
- changeImage(newBackgroundImage) {
- this.setImage(newBackgroundImage);
+
+ setImage(backgroundImage) {
+ if (!backgroundImage || backgroundImage === this.appliedImage) return;
+ this.appliedImage = backgroundImage;
+ // 用引号包住,避免路径里出现空格等字符时被 CSS 解析成多个值
+ document.body.style.backgroundImage = `url("${backgroundImage}")`;
}
}
diff --git a/dist/scripts/index.js b/dist/scripts/index.js
index 556ce68..642302a 100644
--- a/dist/scripts/index.js
+++ b/dist/scripts/index.js
@@ -1,87 +1,86 @@
-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);
- }
- },
-};
+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";
+
+// 与 project.json 里属性的默认值保持一致
+const DEFAULT_PRESET_ID = "xilian";
+const DEFAULT_VOLUME = 0.5;
+
+const presets = new PresetController(Presets);
+
+const backgroundController = new BackgroundController();
+const spineController = new SpineController("spine-container");
+const audioController = new AudioController("background-music");
+
+// 唯一的真相来源。WE 可能在 window.load 之前就下发用户属性,所以这里只记录状态;
+// 渲染时再决定"能不能立刻应用"——只有播放器需要等到 load 之后才存在。
+const state = {
+ ready: false,
+ presetId: "",
+ audioSource: "", // 空字符串 = 用当前预设的默认音源
+ audioVolume: DEFAULT_VOLUME,
+};
+
+let appliedPresetId = "";
+
+function render() {
+ const presetId = state.presetId || DEFAULT_PRESET_ID;
+ const preset = presets.get(presetId);
+ if (!preset) return;
+
+ // 只有壁纸真的换了才碰背景与骨架。否则拖动音量滑块都会重建播放器
+ // (重建 = 重新下载 3.4 MB 骨架与全部贴图)。
+ if (appliedPresetId !== presetId) {
+ appliedPresetId = presetId;
+ backgroundController.setImage(preset.backgroundImage);
+ spineController.setConfig(preset.spineConfig); // 播放器还不存在时只是记下来
+ }
+
+ // 用户自定义音源优先;用户没给就用当前壁纸的默认音源
+ audioController.setSource(state.audioSource || preset.audioOptions.source);
+ audioController.setVolume(state.audioVolume);
+ if (state.ready) audioController.play();
+}
+
+window.addEventListener("load", function () {
+ state.ready = true;
+ render(); // 先把状态落到各控制器(此时播放器尚不存在,只写配置)
+ spineController.create(); // 再用写好的配置创建播放器
+});
+
+// 必须在模块顶层注册:属性事件可能在 window.load 之前到达
+window.wallpaperPropertyListener = {
+ applyUserProperties: function (properties) {
+ // 先写状态:无条件记录,与播放器是否存在、是否加载完成无关
+ if (properties.preset) {
+ const presetId = properties.preset.value;
+ if (presets.has(presetId)) {
+ state.presetId = presetId;
+ } else if (presetId) {
+ console.warn(`忽略未知的预设 id:${presetId}`);
+ }
+ }
+
+ if (properties.audio_file) {
+ state.audioSource = String(properties.audio_file.value || "").trim();
+ }
+
+ if (properties.audio_volume) {
+ const volume = Number(properties.audio_volume.value);
+ if (Number.isFinite(volume)) {
+ state.audioVolume = Math.max(0, Math.min(1, volume));
+ }
+ }
+
+ render();
+ },
+
+ applyGeneralProperties: function (properties) {
+ if (properties.fps) {
+ // fps 限制在第 05 步接入:WE 不替壁纸限流,需要自己按 properties.fps 节流绘制
+ }
+ },
+};
diff --git a/dist/scripts/preset-controller.js b/dist/scripts/preset-controller.js
index fe3db66..d56102a 100644
--- a/dist/scripts/preset-controller.js
+++ b/dist/scripts/preset-controller.js
@@ -1,35 +1,25 @@
+// 预设查表:id → 预设数据。
+// 预设数据是 ES module 里的模块级常量,返回深拷贝,避免调用方改坏它。
+// 注意 id 必须与 project.json 里 combo 选项的 value 一致(见 docs/adr/0001)。
export default class PresetController {
- constructor(presetStore) {
- this.presetStore = presetStore;
- this.previousPreset = "";
- this.currentPreset = "";
+ constructor(presets) {
+ this.presets = presets;
+ this.currentId = "";
}
- getName() {
- return Object.keys(this.presetStore);
+
+ get ids() {
+ return Object.keys(this.presets);
}
- set(newPreset, currentAudioSource, currentAudioVolume) {
- let newPresetData = JSON.parse(JSON.stringify(this.presetStore[newPreset]));
- if (this.previousPreset) {
- if (
- currentAudioSource &&
- this.presetStore[this.previousPreset].audioOptions.source !==
- currentAudioSource
- ) {
- newPresetData.audioOptions.source = currentAudioSource;
- }
- if (
- currentAudioVolume ||
- (currentAudioVolume === 0 &&
- this.presetStore[this.previousPreset].audioOptions.volume !==
- currentAudioVolume)
- ) {
- newPresetData.audioOptions.volume = currentAudioVolume;
- }
- }
- if (this.currentPreset) {
- this.previousPreset = this.currentPreset;
- }
- this.currentPreset = newPreset;
- return newPresetData;
+
+ has(id) {
+ return Boolean(this.presets[id]);
+ }
+
+ // 未知 id 返回 null,由调用方决定是保持现状还是回退——不在这里悄悄改成别的预设
+ get(id) {
+ const preset = this.presets[id];
+ if (!preset) return null;
+ this.currentId = id;
+ return JSON.parse(JSON.stringify(preset));
}
}
diff --git a/dist/scripts/spine-controller.js b/dist/scripts/spine-controller.js
index 9f40ba3..25ff0b2 100644
--- a/dist/scripts/spine-controller.js
+++ b/dist/scripts/spine-controller.js
@@ -1,60 +1,50 @@
+// 负责把「当前预设的 spineConfig」应用到 Spine 播放器上。
+//
+// 核心约定:写入与应用分开。WE 可能在 window.load 之前就下发用户属性,那时播放器还不存在;
+// 旧实现把配置赋值也塞在 `if (this.player)` 里,于是那次配置被静默丢弃,
+// 结果是"背景与音乐已切到新壁纸、立绘还是旧壁纸"的混合状态(已实测复现)。
+// 现在 setConfig() 无论如何都记下配置,create() 用记下的配置创建。
export default class SpineController {
- constructor(containerId, config = {}) {
+ constructor(containerId, baseConfig = {}) {
this.containerId = containerId;
- this.config = {
+ this.baseConfig = {
alpha: true,
premultipliedAlpha: false,
showControls: false,
showLoading: false,
- ...config,
+ ...baseConfig,
};
- this.previousConfig = {};
-
+ this.presetConfig = {};
this.container = document.getElementById(containerId);
this.player = undefined;
}
+ // 只写状态:与播放器是否存在无关,随时可调用
+ setConfig(presetConfig = {}) {
+ this.presetConfig = presetConfig;
+ if (this.player) this.rebuild();
+ }
+
create() {
- if (!this.player) {
- const containerId = this.containerId;
- const config = this.config;
- this.player = new spine.SpinePlayer(containerId, config);
- }
+ if (this.player) return;
+ // 每次都用 baseConfig + 当前 presetConfig 现算,避免上一份预设的键残留
+ this.player = new spine.SpinePlayer(this.containerId, {
+ ...this.baseConfig,
+ ...this.presetConfig,
+ });
+ }
+
+ // 换骨架没有捷径,只能销毁重建
+ rebuild() {
+ this.dispose();
+ this.create();
}
dispose() {
- if (this.player) {
- const container = this.container;
- this.player.dispose();
- container.innerHTML = "";
- this.player = undefined;
- }
- }
-
- adjustCanvas() {
- if (this.player) {
- const container = this.container;
- const canvas = container.querySelector("canvas");
- if (canvas) {
- const rect = container.getBoundingClientRect();
- canvas.style.width = "100%";
- canvas.style.height = "100%";
- canvas.width = rect.width;
- canvas.height = rect.height;
- }
- }
- }
-
- changeConfig(newConfig = {}) {
- if (this.player) {
- this.previousConfig = this.config;
- this.config = {
- ...this.config,
- ...newConfig,
- };
- this.dispose();
- this.create();
- }
+ if (!this.player) return;
+ this.player.dispose();
+ this.container.innerHTML = "";
+ this.player = undefined;
}
}
diff --git a/dist/scripts/wallpaper-engine.js b/dist/scripts/wallpaper-engine.js
new file mode 100644
index 0000000..54a95cc
--- /dev/null
+++ b/dist/scripts/wallpaper-engine.js
@@ -0,0 +1,974 @@
+// wallpaper-engine.js
+// Wallpaper Engine 网页壁纸 API 模拟器(浏览器调试环境)
+// 完全兼容 Wallpaper Engine 网页壁纸 API 规范
+//
+// 使用方式:在 index.html 中以
+// 优先加载本脚本即可。若检测到真实的 Wallpaper Engine 环境则自动跳过。
+
+/* ============================================================
+ * 工具
+ * ============================================================ */
+const WEUtils = {
+ /** Wallpaper Engine 颜色值(归一化浮点 "r g b")→ HEX */
+ colorToHex(value) {
+ if (typeof value !== "string") return "#000000";
+ const parts = value.trim().split(/\s+/).map(Number);
+ if (parts.length < 3 || parts.some(isNaN)) return "#000000";
+ return (
+ "#" +
+ parts
+ .slice(0, 3)
+ .map((n) => {
+ const v = Math.max(0, Math.min(255, Math.round(n * 255)));
+ return v.toString(16).padStart(2, "0");
+ })
+ .join("")
+ );
+ },
+
+ /** HEX → Wallpaper Engine 颜色值 */
+ hexToColor(hex) {
+ if (typeof hex !== "string" || hex[0] !== "#" || hex.length < 7)
+ return "0 0 0";
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
+ return `${r} ${g} ${b}`;
+ },
+
+ /** Wallpaper Engine 文件路径 → 可用 URL */
+ filePathToUrl(path) {
+ if (!path) return "";
+ if (path.startsWith("file:///")) return path;
+ if (path.startsWith("file://")) return "file:///" + path.slice(7);
+ return path;
+ },
+
+ /** 简单 debounce */
+ debounce(fn, wait = 150) {
+ let timer = null;
+ return function (...args) {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(() => fn.apply(this, args), wait);
+ };
+ },
+};
+
+/* ============================================================
+ * 条件表达式求值器
+ * 支持:value == x / value != x / && / || / ! / ()
+ * ============================================================ */
+class ConditionEvaluator {
+ constructor(getPropertyValue) {
+ this.getPropertyValue = getPropertyValue;
+ this._cache = new Map();
+ }
+
+ /**
+ * @param {string} condition 例如 "show_author_info.value == true"
+ * @returns {boolean}
+ */
+ evaluate(condition) {
+ if (!condition || typeof condition !== "string") return true;
+
+ try {
+ // 把 "xxx.value" 替换为对应的字面量
+ const expr = condition.replace(/([A-Za-z_$][\w$]*)\.value/g, (_, key) => {
+ const v = this.getPropertyValue(key);
+ if (typeof v === "string") return JSON.stringify(v);
+ if (v === undefined || v === null) return "undefined";
+ if (typeof v === "boolean" || typeof v === "number") return String(v);
+ return JSON.stringify(v);
+ });
+
+ // 使用 Function 仅在纯布尔表达式上求值
+ // 该表达式来源可信(project.json 由壁纸作者提供),且已做 key 白名单替换
+ const fn =
+ this._cache.get(expr) ||
+ new Function(`"use strict"; return Boolean(${expr});`);
+ this._cache.set(expr, fn);
+ return fn();
+ } catch (err) {
+ console.warn("[WallpaperEngine] 条件求值失败:", condition, err);
+ return true;
+ }
+ }
+}
+
+/* ============================================================
+ * 主模拟器
+ * ============================================================ */
+class WallpaperEngineSimulator {
+ constructor() {
+ this.isInitialized = false;
+ this.isPanelVisible = false;
+ this.projectData = null;
+
+ /** @type {Record} */
+ this.properties = {};
+ /** @type {Record} 属性原始定义 */
+ this.propertyCache = {};
+
+ this.propertyListeners = [];
+ this.generalListeners = [];
+ this.audioListeners = [];
+
+ this.userDataPath = "";
+
+ // DOM 元素缓存
+ this.panelEl = null;
+ this.styleEl = null;
+ this.fieldEls = new Map();
+
+ // 鼠标状态
+ this._mouse = {
+ isOverPanel: false,
+ hideTimer: null,
+ EDGE_ZONE: 60,
+ HIDE_DELAY: 400,
+ };
+
+ this.conditionEvaluator = new ConditionEvaluator(
+ (key) => this.properties[key]?.value,
+ );
+
+ this.initialize();
+ }
+
+ /* --------------------------------------------------------
+ * 初始化
+ * -------------------------------------------------------- */
+ async initialize() {
+ try {
+ await this.loadProjectData();
+ this.initializeProperties();
+ this.initializeAPI();
+ this.setupMouseListeners();
+ this.isInitialized = true;
+ console.log(
+ "[WallpaperEngine] 模拟器已初始化,共",
+ Object.keys(this.properties).length,
+ "个属性",
+ );
+ } catch (err) {
+ console.error("[WallpaperEngine] 初始化失败:", err);
+ }
+ }
+
+ async loadProjectData() {
+ try {
+ const res = await fetch("./project.json", { cache: "no-cache" });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ this.projectData = await res.json();
+ console.log("[WallpaperEngine] project.json 已加载");
+ } catch (err) {
+ console.warn(
+ "[WallpaperEngine] 无法加载 project.json,使用内置默认配置:",
+ err,
+ );
+ this.projectData = this.getFallbackProjectData();
+ }
+ }
+
+ getFallbackProjectData() {
+ return {
+ title: "壁纸设置",
+ general: { properties: {} },
+ };
+ }
+
+ initializeProperties() {
+ const props = this.projectData?.general?.properties;
+ if (!props) {
+ console.warn("[WallpaperEngine] project.json 缺少 general.properties");
+ return;
+ }
+
+ for (const [key, def] of Object.entries(props)) {
+ this.propertyCache[key] = def;
+ this.properties[key] = {
+ value:
+ def.value !== undefined ? def.value : this.getDefaultValue(def.type),
+ };
+ }
+ }
+
+ getDefaultValue(type) {
+ switch (type) {
+ case "bool":
+ return false;
+ case "combo":
+ return "";
+ case "slider":
+ return 0;
+ case "textinput":
+ return "";
+ case "color":
+ return "0 0 0";
+ case "text":
+ return "";
+ default:
+ return null;
+ }
+ }
+
+ /* --------------------------------------------------------
+ * 鼠标监听
+ * -------------------------------------------------------- */
+ setupMouseListeners() {
+ document.addEventListener("mousemove", (e) => {
+ const { EDGE_ZONE } = this._mouse;
+ const nearRightEdge = e.clientX >= window.innerWidth - EDGE_ZONE;
+
+ if (nearRightEdge && !this.isPanelVisible) {
+ this.showSettingsPanel();
+ } else if (
+ !nearRightEdge &&
+ !this._mouse.isOverPanel &&
+ this.isPanelVisible
+ ) {
+ this._scheduleHide();
+ }
+ });
+
+ // 悬停在面板内部时取消隐藏
+ document.addEventListener("mouseover", (e) => {
+ const inside = this.panelEl && this.panelEl.contains(e.target);
+ if (inside !== this._mouse.isOverPanel) {
+ this._mouse.isOverPanel = inside;
+ if (inside) this._cancelHide();
+ }
+ });
+
+ // 鼠标离开窗口
+ document.addEventListener("mouseleave", () => {
+ this._mouse.isOverPanel = false;
+ this._scheduleHide(200);
+ });
+
+ // ESC 关闭
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && this.isPanelVisible) {
+ this.hideSettingsPanel();
+ }
+ });
+ }
+
+ _scheduleHide(delay = this._mouse.HIDE_DELAY) {
+ this._cancelHide();
+ this._mouse.hideTimer = setTimeout(() => {
+ if (!this._mouse.isOverPanel) this.hideSettingsPanel();
+ }, delay);
+ }
+
+ _cancelHide() {
+ if (this._mouse.hideTimer) {
+ clearTimeout(this._mouse.hideTimer);
+ this._mouse.hideTimer = null;
+ }
+ }
+
+ /* --------------------------------------------------------
+ * 面板显示 / 隐藏
+ * -------------------------------------------------------- */
+ showSettingsPanel() {
+ if (!this.projectData?.general?.properties) return;
+
+ // 单例:首次创建
+ if (!this.panelEl) {
+ this.createSettingsPanel();
+ }
+
+ if (!this.isPanelVisible) {
+ this.isPanelVisible = true;
+ // 强制重排以触发 transition
+ void this.panelEl.offsetWidth;
+ this.panelEl.classList.add("we-panel--visible");
+ }
+ }
+
+ hideSettingsPanel() {
+ if (!this.isPanelVisible || !this.panelEl) return;
+ this.isPanelVisible = false;
+ this.panelEl.classList.remove("we-panel--visible");
+ }
+
+ /* --------------------------------------------------------
+ * 面板构造
+ * -------------------------------------------------------- */
+ createSettingsPanel() {
+ if (this.styleEl) this.styleEl.remove();
+ this.styleEl = document.createElement("style");
+ this.styleEl.id = "wallpaper-settings-style";
+ this.styleEl.textContent = this.getPanelStyles();
+ document.head.appendChild(this.styleEl);
+
+ const panel = document.createElement("div");
+ panel.id = "wallpaper-settings-panel";
+ panel.className = "we-panel";
+
+ // 头部
+ const header = document.createElement("div");
+ header.className = "we-panel__header";
+ header.innerHTML = `
+
+
+ `;
+ header.querySelector(".we-panel__title").textContent =
+ this.projectData.title || "壁纸设置";
+ header.querySelector(".we-panel__close").addEventListener("click", () => {
+ this.hideSettingsPanel();
+ });
+
+ // 内容
+ const content = document.createElement("div");
+ content.className = "we-panel__content";
+
+ const sorted = this.getSortedProperties();
+ this.fieldEls.clear();
+
+ for (const { key, def } of sorted) {
+ const row = this.createFieldRow(key, def);
+ if (row) {
+ content.appendChild(row);
+ // 缓存可交互的控件
+ const ctrl = row.querySelector("[data-we-key]");
+ if (ctrl) this.fieldEls.set(key, ctrl);
+ }
+ }
+
+ panel.appendChild(header);
+ panel.appendChild(content);
+ document.body.appendChild(panel);
+
+ this.panelEl = panel;
+
+ // 首次渲染后套用条件显示
+ this.refreshConditionalVisibility();
+ }
+
+ /**
+ * 生成单个属性控件的外层容器
+ */
+ createFieldRow(key, def) {
+ if (!def) return null;
+
+ const row = document.createElement("div");
+ row.className = "we-field";
+ row.id = `we-field-${key}`;
+ row.dataset.condition = def.condition || "";
+
+ const label = document.createElement("label");
+ label.className = "we-field__label";
+ // 支持 text 属性里的
+ label.innerHTML = def.text || key;
+
+ switch (def.type) {
+ case "text":
+ return this.buildTextRow(row, key, def);
+ case "bool":
+ return this.buildBoolRow(row, key, def, label);
+ case "combo":
+ return this.buildComboRow(row, key, def, label);
+ case "slider":
+ return this.buildSliderRow(row, key, def, label);
+ case "textinput":
+ return this.buildTextInputRow(row, key, def, label);
+ case "color":
+ return this.buildColorRow(row, key, def, label);
+ default:
+ return this.buildTextRow(row, key, def);
+ }
+ }
+
+ buildTextRow(row, key, def) {
+ const text = document.createElement("div");
+ text.className = "we-field__text";
+ // Wallpaper Engine 支持在 text 属性内嵌 HTML
+ text.innerHTML = def.text || "";
+ row.appendChild(text);
+ return row;
+ }
+
+ buildBoolRow(row, key, def, label) {
+ label.htmlFor = key;
+ row.appendChild(label);
+
+ const wrap = document.createElement("label");
+ wrap.className = "we-switch";
+ wrap.innerHTML = `
+
+
+ `;
+ const input = wrap.querySelector("input");
+ input.checked = Boolean(this.properties[key]?.value);
+
+ input.addEventListener("change", () => {
+ this.updateProperty(key, input.checked);
+ });
+
+ row.appendChild(wrap);
+ return row;
+ }
+
+ buildComboRow(row, key, def, label) {
+ label.htmlFor = key;
+ row.appendChild(label);
+
+ const select = document.createElement("select");
+ select.className = "we-select";
+ select.id = key;
+ select.dataset.weKey = key;
+
+ const cur = this.properties[key]?.value;
+ for (const opt of def.options || []) {
+ const o = document.createElement("option");
+ o.value = opt.value;
+ o.textContent = opt.label ?? opt.value;
+ if (opt.value === cur) o.selected = true;
+ select.appendChild(o);
+ }
+
+ select.addEventListener("change", () => {
+ this.updateProperty(key, select.value);
+ });
+
+ row.appendChild(select);
+ return row;
+ }
+
+ buildSliderRow(row, key, def, label) {
+ label.htmlFor = key;
+ row.appendChild(label);
+
+ const min = def.min ?? 0;
+ const max = def.max ?? 1;
+ const step = def.step ?? 0.01;
+
+ const slider = document.createElement("input");
+ slider.type = "range";
+ slider.className = "we-slider";
+ slider.id = key;
+ slider.dataset.weKey = key;
+ slider.min = min;
+ slider.max = max;
+ slider.step = step;
+ slider.value = this.properties[key]?.value ?? min;
+
+ const valueLabel = document.createElement("div");
+ valueLabel.className = "we-slider__value";
+ valueLabel.textContent = this._formatSliderValue(slider.value, min, max);
+
+ // 实时更新显示 + 触发属性更新(debounce 写入属性,避免频繁触发监听器)
+ const pushValue = WEUtils.debounce(() => {
+ this.updateProperty(key, parseFloat(slider.value));
+ }, 60);
+
+ slider.addEventListener("input", () => {
+ valueLabel.textContent = this._formatSliderValue(slider.value, min, max);
+ pushValue();
+ });
+ // 松开后强制刷新一次
+ slider.addEventListener("change", () => {
+ this.updateProperty(key, parseFloat(slider.value));
+ });
+
+ row.appendChild(slider);
+ row.appendChild(valueLabel);
+ return row;
+ }
+
+ _formatSliderValue(v, min, max) {
+ const n = parseFloat(v);
+ if (max - min === 1 && min === 0) {
+ return `${Math.round(n * 100)}%`;
+ }
+ return `${n}`;
+ }
+
+ buildTextInputRow(row, key, def, label) {
+ label.htmlFor = key;
+ row.appendChild(label);
+
+ const input = document.createElement("input");
+ input.type = "text";
+ input.className = "we-input";
+ input.id = key;
+ input.dataset.weKey = key;
+ input.value = this.properties[key]?.value ?? "";
+ input.placeholder = def.placeholder || "";
+
+ const pushValue = WEUtils.debounce(() => {
+ this.updateProperty(key, input.value);
+ }, 300);
+
+ input.addEventListener("input", pushValue);
+ input.addEventListener("change", () => {
+ this.updateProperty(key, input.value);
+ });
+
+ row.appendChild(input);
+ return row;
+ }
+
+ buildColorRow(row, key, def, label) {
+ label.htmlFor = key;
+ row.appendChild(label);
+
+ const input = document.createElement("input");
+ input.type = "color";
+ input.className = "we-color";
+ input.id = key;
+ input.dataset.weKey = key;
+ input.value = WEUtils.colorToHex(this.properties[key]?.value || "0 0 0");
+
+ input.addEventListener("input", () => {
+ this.updateProperty(key, WEUtils.hexToColor(input.value));
+ });
+
+ row.appendChild(input);
+ return row;
+ }
+
+ /* --------------------------------------------------------
+ * 属性排序 / 条件刷新
+ * -------------------------------------------------------- */
+ getSortedProperties() {
+ return Object.keys(this.propertyCache)
+ .map((key) => ({
+ key,
+ def: this.propertyCache[key],
+ order: this.propertyCache[key]?.order ?? 0,
+ }))
+ .sort((a, b) => a.order - b.order);
+ }
+
+ refreshConditionalVisibility() {
+ for (const [key, def] of Object.entries(this.propertyCache)) {
+ const row = document.getElementById(`we-field-${key}`);
+ if (!row || !def.condition) continue;
+
+ const visible = this.conditionEvaluator.evaluate(def.condition);
+ row.classList.toggle("we-field--hidden", !visible);
+ }
+ }
+
+ /* --------------------------------------------------------
+ * 属性更新 / 通知
+ * -------------------------------------------------------- */
+ updateProperty(name, value) {
+ if (!this.properties[name]) return;
+ if (this.properties[name].value === value) return;
+
+ this.properties[name].value = value;
+
+ // 更新主题色
+ if (name === "schemecolor" && this.styleEl) {
+ const hex = WEUtils.colorToHex(value);
+ document.documentElement.style.setProperty("--we-theme-color", hex);
+ }
+
+ // 刷新条件显示
+ this.refreshConditionalVisibility();
+
+ // 通知监听器
+ this.notifyPropertyListeners(name, value);
+ }
+
+ notifyPropertyListeners(name, value) {
+ const payload = { [name]: { value } };
+ for (const listener of this.propertyListeners) {
+ try {
+ listener.applyUserProperties?.(payload);
+ } catch (err) {
+ console.error("[WallpaperEngine] applyUserProperties 抛错:", err);
+ }
+ }
+ }
+
+ /* --------------------------------------------------------
+ * 样式
+ * -------------------------------------------------------- */
+ getPanelStyles() {
+ const themeHex = WEUtils.colorToHex(
+ this.properties.schemecolor?.value || "0.388 0.149 0.62",
+ );
+
+ return `
+:root { --we-theme-color: ${themeHex}; }
+
+#wallpaper-settings-panel.we-panel {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: 320px;
+ height: 100vh;
+ transform: translateX(100%);
+ transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
+ background: rgba(22, 22, 30, 0.92);
+ backdrop-filter: blur(14px) saturate(140%);
+ -webkit-backdrop-filter: blur(14px) saturate(140%);
+ border-left: 1px solid rgba(255, 255, 255, 0.08);
+ box-shadow: -8px 0 32px rgba(0, 0, 0, 0.45);
+ color: #f0f0f5;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+ font-size: 14px;
+ z-index: 999999;
+ overflow-y: auto;
+ overflow-x: hidden;
+ will-change: transform;
+}
+#wallpaper-settings-panel.we-panel--visible {
+ transform: translateX(0);
+}
+
+/* 头部 */
+.we-panel__header {
+ position: sticky;
+ top: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ background: rgba(0, 0, 0, 0.25);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ backdrop-filter: blur(10px);
+ z-index: 2;
+}
+.we-panel__title {
+ font-size: 15px;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.we-panel__close {
+ width: 28px; height: 28px;
+ border: none;
+ background: transparent;
+ color: #f0f0f5;
+ font-size: 22px;
+ line-height: 1;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: background 0.15s, transform 0.15s;
+}
+.we-panel__close:hover { background: rgba(255,255,255,0.1); transform: rotate(90deg); }
+
+/* 内容区 */
+.we-panel__content { padding: 16px 20px 40px; }
+
+/* 每个属性块 */
+.we-field { margin-bottom: 18px; }
+.we-field--hidden { display: none !important; }
+
+.we-field__label {
+ display: block;
+ margin-bottom: 8px;
+ font-size: 13px;
+ font-weight: 500;
+ color: #e6e6ee;
+ line-height: 1.4;
+}
+
+.we-field__text {
+ font-size: 12px;
+ color: #9a9aa8;
+ line-height: 1.55;
+}
+.we-field__text a {
+ color: var(--we-theme-color);
+ text-decoration: none;
+ transition: opacity 0.15s;
+}
+.we-field__text a:hover { opacity: 0.8; text-decoration: underline; }
+.we-field__text small { font-size: 12px; }
+.we-field__text ul,
+.we-field__text ol { margin: 4px 0; padding-left: 20px; }
+.we-field__text li { margin: 3px 0; }
+.we-field__text p { margin: 4px 0; }
+.we-field__text code {
+ background: rgba(255,255,255,0.08);
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-size: 11px;
+}
+
+/* 开关 */
+.we-switch {
+ position: relative;
+ display: inline-block;
+ width: 44px; height: 24px;
+ cursor: pointer;
+ vertical-align: middle;
+ float: right;
+ margin-top: -30px;
+}
+.we-switch input { opacity: 0; width: 0; height: 0; }
+.we-switch__slider {
+ position: absolute;
+ inset: 0;
+ background: rgba(255,255,255,0.16);
+ border-radius: 24px;
+ transition: background 0.25s;
+}
+.we-switch__slider::before {
+ content: "";
+ position: absolute;
+ left: 3px; top: 3px;
+ width: 18px; height: 18px;
+ background: #fff;
+ border-radius: 50%;
+ transition: transform 0.25s;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.35);
+}
+.we-switch input:checked + .we-switch__slider {
+ background: var(--we-theme-color);
+}
+.we-switch input:checked + .we-switch__slider::before {
+ transform: translateX(20px);
+}
+
+/* 下拉框 / 输入框 */
+.we-select, .we-input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 9px 11px;
+ background: rgba(255,255,255,0.06);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 6px;
+ color: #f0f0f5;
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.15s, background 0.15s;
+}
+.we-select:hover, .we-input:hover { background: rgba(255,255,255,0.09); }
+.we-select:focus, .we-input:focus {
+ border-color: var(--we-theme-color);
+ background: rgba(255,255,255,0.1);
+}
+.we-select option { background: #1b1b25; color: #f0f0f5; }
+
+/* 滑块 */
+.we-slider {
+ width: 100%;
+ margin: 6px 0 2px;
+ -webkit-appearance: none;
+ appearance: none;
+ height: 4px;
+ border-radius: 2px;
+ background: rgba(255,255,255,0.14);
+ outline: none;
+ cursor: pointer;
+}
+.we-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 16px; height: 16px;
+ border-radius: 50%;
+ background: var(--we-theme-color);
+ border: 2px solid #fff;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.4);
+ cursor: grab;
+ transition: transform 0.12s;
+}
+.we-slider::-webkit-slider-thumb:hover { transform: scale(1.15); }
+.we-slider::-webkit-slider-thumb:active { cursor: grabbing; }
+.we-slider::-moz-range-thumb {
+ width: 14px; height: 14px;
+ border-radius: 50%;
+ background: var(--we-theme-color);
+ border: 2px solid #fff;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.4);
+ cursor: grab;
+}
+.we-slider__value {
+ text-align: right;
+ font-size: 12px;
+ color: #9a9aa8;
+ font-variant-numeric: tabular-nums;
+}
+
+/* 颜色选择器 */
+.we-color {
+ width: 100%;
+ height: 36px;
+ padding: 0;
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 6px;
+ background: transparent;
+ cursor: pointer;
+ -webkit-appearance: none;
+ appearance: none;
+}
+.we-color::-webkit-color-swatch-wrapper { padding: 2px; }
+.we-color::-webkit-color-swatch { border: none; border-radius: 4px; }
+
+/* 滚动条 */
+.we-panel::-webkit-scrollbar { width: 6px; }
+.we-panel::-webkit-scrollbar-track { background: transparent; }
+.we-panel::-webkit-scrollbar-thumb {
+ background: rgba(255,255,255,0.18);
+ border-radius: 3px;
+}
+.we-panel::-webkit-scrollbar-thumb:hover {
+ background: var(--we-theme-color);
+}
+ `;
+ }
+
+ /* --------------------------------------------------------
+ * Wallpaper Engine 全局 API 注入
+ * -------------------------------------------------------- */
+ initializeAPI() {
+ // ---------- wallpaperPropertyListener ----------
+ if (!window.wallpaperPropertyListener) {
+ window.wallpaperPropertyListener = {
+ applyGeneralProperties: (properties) => {
+ this._emit("applyGeneralProperties", properties);
+ },
+ applyUserProperties: (properties) => {
+ this._emit("applyUserProperties", properties);
+ },
+ };
+ }
+
+ // ---------- wallpaperRegisterListener ----------
+ if (!window.wallpaperRegisterListener) {
+ window.wallpaperRegisterListener = (listener) => {
+ if (!listener || typeof listener !== "object") return;
+ if (typeof listener.applyGeneralProperties === "function") {
+ this.generalListeners.push(listener);
+ }
+ if (typeof listener.applyUserProperties === "function") {
+ this.propertyListeners.push(listener);
+ }
+ if (typeof listener.applyAudioProperties === "function") {
+ this.audioListeners.push(listener);
+ }
+ };
+ }
+
+ // ---------- 其它 API 占位 ----------
+ if (!window.wallpaperGetUserDataPath) {
+ window.wallpaperGetUserDataPath = () => this.userDataPath;
+ }
+ if (!window.wallpaperRequestFile) {
+ // Wallpaper Engine 原生:请求文件(steam 云 / 用户数据)
+ // 浏览器无法访问,仅返回 null 占位
+ window.wallpaperRequestFile = () => {
+ console.warn(
+ "[WallpaperEngine] wallpaperRequestFile 在浏览器环境中不可用",
+ );
+ return null;
+ };
+ }
+ if (!window.wallpaperRegisterAudioListener) {
+ window.wallpaperRegisterAudioListener = (cb) => {
+ if (typeof cb === "function")
+ this.audioListeners.push({ applyAudioProperties: cb });
+ };
+ }
+ if (!window.wallpaperGetWallpaperAuthor) {
+ window.wallpaperGetWallpaperAuthor = () =>
+ this.projectData?.general?.properties?.author?.value || "";
+ }
+ if (!window.wallpaperEngine) {
+ window.wallpaperEngine = {
+ setProperty: (name, value) => this.updateProperty(name, value),
+ getProperty: (name) => this.properties[name]?.value,
+ getUserDataPath: () => this.userDataPath,
+ getProjectData: () => this.projectData,
+ };
+ }
+ }
+
+ _emit(method, payload) {
+ const lists =
+ method === "applyGeneralProperties"
+ ? this.generalListeners
+ : this.propertyListeners;
+
+ for (const listener of lists) {
+ try {
+ listener[method]?.(payload);
+ } catch (err) {
+ console.error(`[WallpaperEngine] ${method} 抛错:`, err);
+ }
+ }
+ }
+
+ /* --------------------------------------------------------
+ * 模拟初始属性推送(浏览器调试用)
+ * Wallpaper Engine 在壁纸加载完成后,会先调用 applyGeneralProperties,
+ * 再逐条调用 applyUserProperties。
+ * -------------------------------------------------------- */
+ simulateWallpaperEngine() {
+ const run = () => {
+ // 等待 index.js 中的 wallpaperRegisterListener 注册完成
+ setTimeout(() => {
+ // 1. 通用属性
+ this._emit("applyGeneralProperties", { fps: 60 });
+
+ // 2. 逐条推送用户属性(模拟 Wallpaper Engine 行为)
+ for (const [key, prop] of Object.entries(this.properties)) {
+ this._emit("applyUserProperties", { [key]: { value: prop.value } });
+ }
+
+ console.log("[WallpaperEngine] 已推送初始属性到监听器");
+ }, 100);
+ };
+
+ if (document.readyState === "complete") run();
+ else window.addEventListener("load", run, { once: true });
+ }
+}
+
+/* ============================================================
+ * 环境检测 & 启动
+ * ============================================================ */
+function isWallpaperEngineEnvironment() {
+ return (
+ typeof window.wallpaperRegisterListener === "function" &&
+ typeof window.wallpaperPropertyListener === "object" &&
+ window.wallpaperPropertyListener !== null
+ );
+}
+
+function bootstrap() {
+ if (isWallpaperEngineEnvironment()) {
+ console.log(
+ "[WallpaperEngine] 检测到原生 Wallpaper Engine 环境,跳过模拟器",
+ );
+ return;
+ }
+
+ const simulator = new WallpaperEngineSimulator();
+
+ const check = setInterval(() => {
+ if (simulator.isInitialized) {
+ clearInterval(check);
+ simulator.simulateWallpaperEngine();
+ }
+ }, 50);
+
+ // 兜底:5 秒后仍未初始化则强制停止轮询
+ setTimeout(() => clearInterval(check), 5000);
+
+ // 便于调试
+ window.__WE_SIMULATOR__ = simulator;
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", bootstrap, { once: true });
+} else {
+ bootstrap();
+}
+
+export default {
+ isWallpaperEngineEnvironment,
+ WallpaperEngineSimulator,
+ WEUtils,
+};
diff --git a/tools/diff.mjs b/tools/diff.mjs
index 052063c..5b384bb 100644
--- a/tools/diff.mjs
+++ b/tools/diff.mjs
@@ -1,12 +1,19 @@
-// 两张 PNG 的数值对拍:PSNR / SSIM / 平均亮度差(0-255)。
-// 用法:node tools/diff.mjs [diff-out.png]
-// 判读:平均差 < 1 视为肉眼无差异;1-3 为细微;> 8 通常意味着构图/内容变了。
+// 两张图的数值对拍:PSNR / SSIM / 平均绝对差 / 最大绝对差(均为 0-255 尺度)。
+// 用法:node tools/diff.mjs [diff-out.png]
+//
+// 判读:
+// mean|diff| ≈ 0 且 max|diff| = 0 → 逐像素相同
+// mean|diff| 极小 但 max|diff| 很大 → 只有零星像素变了(多半是高对比边缘的抗锯齿抖动)
+// mean|diff| 明显变大 → 整体变化(构图/内容/色调)
+//
+// 注意:ffmpeg 对很小的数值会用科学计数法(YAVG=4.96721e-05),
+// 解析时必须带上指数,否则会把 0.00005 误读成 4.97——这个坑踩过一次。
import { spawn } from "node:child_process";
const [a, b, outPng] = process.argv.slice(2);
if (!a || !b) {
- console.error("usage: node tools/diff.mjs [diff-out.png]");
+ console.error("usage: node tools/diff.mjs [diff-out.png]");
process.exit(2);
}
@@ -20,28 +27,31 @@ function ff(args) {
});
}
-const psnr = await ff(["-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"]);
-const ssim = await ff(["-i", a, "-i", b, "-lavfi", "ssim", "-f", "null", "-"]);
-const yavg = await ff([
- "-i", a, "-i", b,
- "-lavfi", "blend=all_mode=difference,signalstats,metadata=print:key=lavfi.signalstats.YAVG",
- "-f", "null", "-",
-]);
-
+const NUM = "([-+0-9.eE]+)";
const num = (text, re) => {
const m = text.match(re);
return m ? Number(m[1]) : NaN;
};
-const yavgs = [...yavg.matchAll(/YAVG=([\d.]+)/g)].map((m) => Number(m[1]));
+const psnr = await ff(["-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"]);
+const ssim = await ff(["-i", a, "-i", b, "-lavfi", "ssim", "-f", "null", "-"]);
+const stats = await ff([
+ "-i", a, "-i", b,
+ "-lavfi", "blend=all_mode=difference,format=gray,signalstats,metadata=print",
+ "-f", "null", "-",
+]);
-const report = {
- psnr_avg: /average:inf/.test(psnr) ? Infinity : num(psnr, /average:([\d.]+)/),
- ssim_all: num(ssim, /All:([\d.]+)/),
- mean_abs_diff: yavgs.length ? yavgs[yavgs.length - 1] : NaN,
-};
+const frames = [...stats.matchAll(/frame:(\d+)/g)].length;
+const yavg = num(stats, new RegExp(`YAVG=${NUM}`));
+const ymax = num(stats, new RegExp(`YMAX=${NUM}`));
+const inf = /average:inf/.test(psnr);
-console.log(`psnr=${report.psnr_avg} dB ssim=${report.ssim_all} mean|diff|=${report.mean_abs_diff}/255`);
+console.log(
+ `psnr=${inf ? "∞" : num(psnr, new RegExp(`average:${NUM}`))} dB ` +
+ `ssim=${num(ssim, new RegExp(`All:${NUM}`))} ` +
+ `mean|diff|=${yavg}/255 max|diff|=${ymax}` +
+ (frames > 1 ? ` [${frames} 帧,取首帧]` : ""),
+);
if (outPng) {
await ff(["-y", "-i", a, "-i", b, "-lavfi", "blend=all_mode=difference", "-frames:v", "1", outPng]);
diff --git a/tools/serve.mjs b/tools/serve.mjs
index 6033851..373ed4f 100644
--- a/tools/serve.mjs
+++ b/tools/serve.mjs
@@ -88,6 +88,8 @@ const DRIVER = (q) => `