diff --git a/.gitignore b/.gitignore
index a0f9299..178544c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,10 @@
tools/shots/
tools/.cache/
+# 对拍中间产物:截图、抽取帧、试压的 GIF 都是可重生成的二进制,不入库
+.scratch/*.png
+.scratch/*.gif
+
# 无损音频母带:只在本地留存,绝不进 dist、绝不入库(体积大且与发布无关)
masters/
diff --git a/.scratch/fit-measure.ps1 b/.scratch/fit-measure.ps1
new file mode 100644
index 0000000..f329039
--- /dev/null
+++ b/.scratch/fit-measure.ps1
@@ -0,0 +1,26 @@
+$ErrorActionPreference = "Stop"
+Set-Location D:\Workspace\Temp\SpineWallpaper
+
+$sizes = @(
+ @(3440, 1440, "219"),
+ @(1920, 1080, "169"),
+ @(1080, 1920, "916")
+)
+$props = @{
+ "xilian" = ""
+ "kv37" = "&__props=%7B%22preset%22%3A%22kv37%22%7D"
+}
+
+New-Item -ItemType Directory -Force -Path tools\shots\fit | Out-Null
+
+foreach ($p in @("xilian", "kv37")) {
+ foreach ($s in $sizes) {
+ $w = $s[0]; $h = $s[1]; $tag = $s[2]
+ $url = "http://127.0.0.1:8190/?__freeze=1" + $props[$p]
+ Write-Host "=== $p $tag ($w x $h) ==="
+ node tools/shot.mjs "tools/shots/fit/${p}_${tag}.png" $url $w $h 2>&1 | Select-Object -First 1
+ node tools/shot.mjs "tools/shots/fit/${p}_${tag}_ref.png" "$url&__hide=canvas" $w $h 2>&1 | Select-Object -First 1
+ node tools/subject.mjs "tools/shots/fit/${p}_${tag}.png" "tools/shots/fit/${p}_${tag}_ref.png"
+ Write-Host ""
+ }
+}
diff --git a/.scratch/test-acceptance.mjs b/.scratch/test-acceptance.mjs
new file mode 100644
index 0000000..ef4104a
--- /dev/null
+++ b/.scratch/test-acceptance.mjs
@@ -0,0 +1,135 @@
+// Issue 06 全量验收。
+// ① dist 体积与死文件;② project.json 与代码一致;③ 三种比例 0 控制台报错;
+// ④ 预设往返不丢用户级音源/音量设置。
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { basename, extname, join } from "node:path";
+import { withPage, sleep } from "../tools/cdp.mjs";
+
+let failures = 0;
+const check = (ok, msg, detail) => {
+ if (!ok) failures++;
+ console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${detail}` : ""}`);
+};
+
+// ── ① 体积与死文件 ──
+console.log("=== ① dist 体积与死文件 ===");
+const walk = (dir) =>
+ readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
+ const p = join(dir, e.name);
+ return e.isDirectory() ? walk(p) : [p];
+ });
+const files = walk("dist");
+const total = files.reduce((s, f) => s + statSync(f).size, 0);
+check(total <= 35 * 1024 * 1024, "总体积 ≤ 35 MB", `${(total / 1048576).toFixed(2)} MB / ${files.length} 个文件`);
+
+const TEXT = new Set([".html", ".js", ".json", ".txt", ".css", ".atlas"]);
+const texts = files.filter((f) => TEXT.has(extname(f)));
+const contents = new Map(texts.map((f) => [f, readFileSync(f, "utf8")]));
+// 这四个是 WE 的元数据/入口/说明,本来就不被页面引用
+const ALLOWED = new Set(["project.json", "README.txt", "index.html", "preview.gif"]);
+// 关键:比对时要把文件**自身**的内容排除掉。否则一个文件只要在自己开头写上自己的名字
+// (比如注释里的 "// wallpaper-engine.js")就会被判为"被引用",死文件因此漏网。
+const dead = files.filter((f) => {
+ const n = basename(f);
+ if (ALLOWED.has(n)) return false;
+ const others = texts
+ .filter((t) => t !== f)
+ .map((t) => contents.get(t))
+ .join("\n");
+ return !others.includes(n);
+});
+check(dead.length === 0, "0 个死文件", dead.length ? dead.join(", ") : "无孤立资源");
+
+// ── ② project.json ──
+console.log("\n=== ② project.json ===");
+const project = JSON.parse(readFileSync("dist/project.json", "utf8"));
+check(project.version === 3, "version = 3", String(project.version));
+check(project.preview === "preview.gif", "preview 指向 preview.gif");
+check(!!project.general.properties.bgm, "新增 bgm combo");
+check(project.general.properties.preset.value === "xilian", "preset 默认值未变");
+const presetValues = project.general.properties.preset.options.map((o) => o.value).sort();
+check(
+ JSON.stringify(presetValues) === JSON.stringify(["kv37", "xilian"]),
+ "preset 的 combo 取值未变(用户已保存的设置依赖它)",
+ presetValues.join(", "),
+);
+const bgmValues = project.general.properties.bgm.options.map((o) => o.value);
+check(bgmValues.includes("auto"), "bgm 含「随预设」档", bgmValues.join(", "));
+const gifSize = statSync("dist/preview.gif").size;
+check(gifSize < 500 * 1024, "preview.gif < 500 KB", `${(gifSize / 1024).toFixed(0)} KB`);
+
+// ── ③ 三种比例 0 报错 ──
+console.log("\n=== ③ 21:9 / 16:9 / 9:16 控制台与异常 ===");
+for (const [w, h, tag] of [
+ [3440, 1440, "21:9"],
+ [1920, 1080, "16:9"],
+ [1080, 1920, "9:16"],
+]) {
+ const r = await withPage({ width: w, height: h }, async ({ send, evaluate, consoleLines, exceptions }) => {
+ await send("Page.navigate", { url: "http://127.0.0.1:8190/?__freeze=1" });
+ const deadline = Date.now() + 30000;
+ let ready = false;
+ while (Date.now() < deadline) {
+ if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) {
+ ready = true;
+ break;
+ }
+ await sleep(150);
+ }
+ await sleep(500);
+ const state = await evaluate(`(function(){
+ var p = window.__player, v = p.currentViewport;
+ return { w: p.canvas.clientWidth, h: p.canvas.clientHeight,
+ vw: +v.width.toFixed(1), vh: +v.height.toFixed(1),
+ err: window.__test.error || null };
+ })()`);
+ return { ready, state, consoleLines, exceptions };
+ });
+ const noisy = r.consoleLines.filter((l) => /error|uncaught|fail/i.test(l));
+ const aspectOk = r.state && Math.abs(r.state.vw / r.state.vh - r.state.w / r.state.h) < 0.002;
+ check(r.ready && !r.state?.err, `${tag} 播放器就绪`, r.state?.err ?? "无错");
+ check(aspectOk, `${tag} 可见矩形比例与画布一致`, r.state ? `${r.state.vw}×${r.state.vh}` : "—");
+ check(noisy.length === 0 && r.exceptions.length === 0, `${tag} 0 控制台报错 / 0 异常`, noisy.concat(r.exceptions).slice(0, 3).join(" | ") || "干净");
+}
+
+// ── ④ 预设往返不丢音频设置 ──
+console.log("\n=== ④ 预设往返 10 次,音频设置不得丢失 ===");
+const rt = await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
+ await send("Page.navigate", { url: "http://127.0.0.1:8190/?__freeze=1" });
+ const deadline = Date.now() + 30000;
+ while (Date.now() < deadline) {
+ if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
+ await sleep(150);
+ }
+ await sleep(500);
+
+ await evaluate(`window.wallpaperPropertyListener.applyUserProperties({
+ audio_file: { value: "https://example.com/custom-track.mp3" },
+ audio_volume: { value: 0.2 }
+ })`);
+ await sleep(300);
+
+ const rows = [];
+ for (let i = 0; i < 10; i++) {
+ const id = i % 2 === 0 ? "kv37" : "xilian";
+ await evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: "${id}" } })`);
+ await sleep(320);
+ rows.push(
+ await evaluate(`(function(){
+ var a = document.getElementById("background-music");
+ return { id: "${id}", src: a.src.split("/").pop(), vol: a.volume,
+ draws: window.__test.drawCalls };
+ })()`),
+ );
+ }
+ return { rows, exceptions };
+});
+const srcOk = rt.rows.every((r) => r.src === "custom-track.mp3");
+const volOk = rt.rows.every((r) => Math.abs(r.vol - 0.2) < 0.001);
+check(srcOk, "10 次切换后自定义音源仍生效", [...new Set(rt.rows.map((r) => r.src))].join(", "));
+check(volOk, "10 次切换后音量仍为 0.2", [...new Set(rt.rows.map((r) => r.vol))].join(", "));
+check(rt.rows[rt.rows.length - 1].draws > rt.rows[0].draws, "切换后仍在持续绘制", `${rt.rows[0].draws} → ${rt.rows[rt.rows.length - 1].draws}`);
+check(rt.exceptions.length === 0, "往返过程 0 未捕获异常", rt.exceptions.join(" | ") || "无");
+
+console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/.scratch/test-props.mjs b/.scratch/test-props.mjs
new file mode 100644
index 0000000..cfea666
--- /dev/null
+++ b/.scratch/test-props.mjs
@@ -0,0 +1,137 @@
+// Issue 05 验收:bgm 音源 combo 与 fps 限流。
+//
+// A 段验音源优先级:用户自填 URL > bgm 选择(仅当本壁纸声明过)> 本壁纸默认音源;
+// 且 bgm 选择跨壁纸时"音频回落、选择保留"。
+// B 段验 fps:WE 不替壁纸限流,得自己门控。关键是限流只该丢帧,
+// 不该改变动画相位推进速度——用同一段真实时间里 trackTime 前进了多少来判定。
+import { withPage, sleep } from "../tools/cdp.mjs";
+
+const BASE = "http://127.0.0.1:8190/";
+const name = (src) => (src ? decodeURIComponent(String(src).split("/").pop()) : "(空)");
+
+let failures = 0;
+const check = (ok, msg, detail) => {
+ if (!ok) failures++;
+ console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${detail}` : ""}`);
+};
+
+// ───────────────────────── A 段:音源 ─────────────────────────
+console.log("=== A 段:bgm 音源选择与回落 ===");
+await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
+ await send("Page.navigate", { url: BASE + "?__freeze=1" });
+ const deadline = Date.now() + 30000;
+ while (Date.now() < deadline) {
+ if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
+ await sleep(150);
+ }
+ await sleep(600);
+
+ const src = () => evaluate('document.getElementById("background-music").src');
+ const preset = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: ${JSON.stringify(v)} } })`);
+ const bgm = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ bgm: { value: ${JSON.stringify(v)} } })`);
+ const file = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ audio_file: { value: ${JSON.stringify(v)} } })`);
+
+ const seen = [];
+ const step = async (label, action, expected) => {
+ await action();
+ await sleep(350);
+ const got = name(await src());
+ seen.push(`${label}=${got}`);
+ check(got === expected, label, `期望 ${expected},实得 ${got}`);
+ };
+
+ // 载入即 xilian,默认音源应是「再度和你」
+ check(name(await src()) === "再度和你.ogg", "初始默认音源 = 昔涟立绘默认", name(await src()));
+
+ await step("bgm 选「昔涟」", () => bgm("xilian"), "昔涟.ogg");
+ await step("切到 kv37(选择不属于它)→ 回落", () => preset("kv37"), "pv37.mp3");
+ await step("bgm 选「版本 PV」(属于 kv37)", () => bgm("pv37"), "pv37.mp3");
+ await step("切回 xilian(pv37 不属于它)→ 回落", () => preset("xilian"), "再度和你.ogg");
+ await step("bgm 选「「再度和你」」", () => bgm("zaiduheni"), "再度和你.ogg");
+ await step("bgm 回到「随预设」", () => bgm("auto"), "再度和你.ogg");
+
+ // 用户自填 URL 优先级最高
+ await step("bgm 选「昔涟」后填自定义 URL", async () => {
+ await bgm("xilian");
+ await file("https://example.com/custom-track.mp3");
+ }, "custom-track.mp3");
+ await step("切壁纸不得清掉自定义 URL", () => preset("kv37"), "custom-track.mp3");
+ await step("清空自定义 URL → 回到预设默认", () => file(""), "pv37.mp3");
+
+ console.log(` 音源轨迹: ${seen.join(" | ")}`);
+ console.log(` 未捕获异常: ${exceptions.length ? exceptions.join(" | ") : "无 ✓"}`);
+ if (exceptions.length) failures++;
+});
+
+// ───────────────────────── B 段:fps ─────────────────────────
+console.log("\n=== B 段:fps 限流 ===");
+await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
+ // 空 __props 只用来触发测试驱动注入(不带它就不会注入),既不冻结也不改属性
+ await send("Page.navigate", { url: BASE + "?__props=%7B%7D" });
+ const deadline = Date.now() + 30000;
+ while (Date.now() < deadline) {
+ if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
+ await sleep(150);
+ }
+ await sleep(1200); // 让动画先跑起来,避开启动瞬态
+
+ const probe = `(function(){
+ var p = window.__player;
+ var e = p.animationState.getCurrent(0);
+ return { trackTime: e.trackTime, duration: e.animation.duration,
+ draws: window.__test.drawCalls, t: performance.now() };
+ })()`;
+
+ const sample = async (seconds) => {
+ const a = await evaluate(probe);
+ await sleep(seconds * 1000);
+ const b = await evaluate(probe);
+ const wall = (b.t - a.t) / 1000;
+ let phase = b.trackTime - a.trackTime;
+ if (phase < 0) phase += b.duration; // 动画正好绕回
+ return { wall, draws: b.draws - a.draws, fps: (b.draws - a.draws) / wall, phaseRate: phase / wall };
+ };
+
+ const setFps = async (v) => {
+ await evaluate(`window.wallpaperPropertyListener.applyGeneralProperties({ fps: ${v} })`);
+ await sleep(400);
+ };
+
+ const results = [];
+ const run = async (label, target) => {
+ const r = await sample(2.0);
+ const row = { label, target, ...r };
+ results.push(row);
+ console.log(
+ ` ${label.padEnd(16)} 实测 ${r.fps.toFixed(1)} fps` +
+ (target ? `(目标 ${target})` : "(不限流)") +
+ ` 动画相位推进 ${r.phaseRate.toFixed(3)} 倍速`,
+ );
+ return row;
+ };
+
+ const base = await run("不限流", 0);
+ await setFps(30);
+ const f30 = await run("限 30 fps", 30);
+ await setFps(20);
+ const f20 = await run("限 20 fps", 20);
+ await setFps(0);
+ const back = await run("恢复不限流", 0);
+
+ check(Math.abs(f30.fps - 30) / 30 < 0.25, "限 30 fps 生效", `实测 ${f30.fps.toFixed(1)}`);
+ check(Math.abs(f20.fps - 20) / 20 < 0.25, "限 20 fps 生效", `实测 ${f20.fps.toFixed(1)}`);
+ check(f30.fps < base.fps * 0.75, "限流确实减少了绘制次数", `${base.fps.toFixed(1)} → ${f30.fps.toFixed(1)}`);
+ check(back.fps > f30.fps * 1.5, "取消限流后帧率恢复", `${f30.fps.toFixed(1)} → ${back.fps.toFixed(1)}`);
+ for (const r of [f30, f20, back]) {
+ check(
+ Math.abs(r.phaseRate - base.phaseRate) / base.phaseRate < 0.1,
+ `动画相位推进速度未受 ${r.label} 影响`,
+ `${r.phaseRate.toFixed(3)} vs 基准 ${base.phaseRate.toFixed(3)}`,
+ );
+ }
+ console.log(` 未捕获异常: ${exceptions.length ? exceptions.join(" | ") : "无 ✓"}`);
+ if (exceptions.length) failures++;
+});
+
+console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/.scratch/test-resize.mjs b/.scratch/test-resize.mjs
new file mode 100644
index 0000000..c883679
--- /dev/null
+++ b/.scratch/test-resize.mjs
@@ -0,0 +1,126 @@
+// 验收测试:改窗口尺寸不得重建播放器,且取景要跟着画布比例走。
+//
+// 判据是"播放器实例身份":给加载后的实例打上标记,之后每次改视口都核对
+// window.__player 是否还是同一个对象、标记是否还在。重建会换掉实例,
+// 标记随之消失——这比数日志可靠得多。
+import { withPage, sleep } from "../tools/cdp.mjs";
+
+const URL = "http://127.0.0.1:8190/?__freeze=1";
+const sizes = [
+ [1920, 1080],
+ [2560, 1440],
+ [3440, 1440],
+ [1080, 1920],
+ [1600, 900],
+ [2560, 1600],
+ [3840, 2160],
+ [1280, 720],
+ [1920, 1080],
+];
+const stress = [];
+for (let i = 0; i < 20; i++) stress.push(i % 2 ? [1080, 1920] : [1920, 1080]);
+
+const out = await withPage(
+ { width: 1920, height: 1080, extraArgs: ["--enable-precise-memory-info", "--js-flags=--expose-gc"] },
+ async ({ send, evaluate, consoleLines, exceptions }) => {
+ await send("Page.navigate", { url: URL });
+ const deadline = Date.now() + 30000;
+ while (Date.now() < deadline) {
+ if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
+ await sleep(150);
+ }
+ await sleep(600);
+
+ const stamp = await evaluate(`(function(){
+ window.__first = window.__player;
+ if (window.__player) window.__player.__probe = 'A';
+ return { hasPlayer: !!window.__player, error: window.__test ? window.__test.error : null };
+ })()`);
+
+ const rows = [];
+ const measure = async (w, h, tag) => {
+ await send("Emulation.setDeviceMetricsOverride", {
+ width: w,
+ height: h,
+ deviceScaleFactor: 1,
+ mobile: false,
+ });
+ await sleep(420);
+ // 先强制回收再读堆,否则量到的是 V8 的延迟回收节奏而不是真实占用
+ await evaluate('(typeof gc === "function") ? (gc(), true) : false');
+ const r = await evaluate(`(function(){
+ var p = window.__player;
+ if (!p) return null;
+ var v = p.currentViewport;
+ return {
+ same: p === window.__first,
+ probe: p.__probe,
+ css: [p.canvas.clientWidth, p.canvas.clientHeight],
+ resolved: [ +v.x.toFixed(2), +v.y.toFixed(2), +v.width.toFixed(2), +v.height.toFixed(2) ],
+ pad: [v.padLeft, v.padRight, v.padTop, v.padBottom],
+ childViews: window.__player.config.viewport.animations ? Object.keys(window.__player.config.viewport.animations).length : -1,
+ heap: performance.memory ? performance.memory.usedJSHeapSize : 0,
+ drawCalls: window.__test ? window.__test.drawCalls : 0
+ };
+ })()`);
+ rows.push({ tag, w, h, ...(r ?? { same: false, probe: null, css: null, resolved: null, pad: null, heap: 0, drawCalls: 0 }) });
+ };
+
+ for (const [w, h] of sizes) await measure(w, h, `${w}x${h}`);
+ const heapBeforeStress = rows[rows.length - 1].heap;
+ for (const [w, h] of stress) await measure(w, h, `压力 ${w}x${h}`);
+ const heapAfterStress = rows[rows.length - 1].heap;
+
+ return { stamp, rows, heapBeforeStress, heapAfterStress, consoleLines, exceptions };
+ },
+);
+
+const { stamp, rows, heapBeforeStress, heapAfterStress, consoleLines, exceptions } = out;
+
+let failures = 0;
+const fail = (msg) => {
+ failures++;
+ console.log(` ✗ ${msg}`);
+};
+
+console.log(`播放器就绪: ${stamp.hasPlayer ? "是" : "否"} 驱动报错: ${stamp.error ?? "无"}`);
+console.log("");
+console.log("画布尺寸 实例未重建 内边距 可见矩形 比例核对");
+for (const r of rows) {
+ if (r.tag.startsWith("压力")) continue;
+ const a = r.w / r.h;
+ const ra = r.resolved ? r.resolved[2] / r.resolved[3] : NaN;
+ const ratioOk = Math.abs(ra - a) < 0.002;
+ const padOk = r.pad && r.pad.every((p) => p === 0);
+ const rect = r.resolved ? r.resolved.map((n) => String(n).padStart(9)).join(" ") : "—";
+ console.log(
+ `${r.tag.padEnd(14)} ${String(r.same).padEnd(10)} ${(padOk ? "全 0 ✓" : JSON.stringify(r.pad)).padEnd(10)} ${rect} ${ratioOk ? "✓" : `✗ 期望 ${a.toFixed(4)} 实得 ${ra.toFixed(4)}`}`,
+ );
+ if (!r.same) fail(`${r.tag} 重建了播放器(标记丢失: ${r.probe})`);
+ if (!padOk) fail(`${r.tag} 内边距没有归零`);
+ if (!ratioOk) fail(`${r.tag} 可见矩形比例与画布不符`);
+}
+
+// 首尾同为 1920x1080:取景必须回到完全相同的值,证明重算可逆、无漂移
+const first = rows.find((r) => r.tag === "1920x1080");
+const last = [...rows].reverse().find((r) => r.tag === "1920x1080");
+const reversible = first.resolved.every((n, i) => Math.abs(n - last.resolved[i]) < 0.01);
+if (!reversible) fail(`回到 16:9 后取景漂移:${first.resolved} → ${last.resolved}`);
+console.log(`\n回到 16:9 取景可逆: ${reversible ? "✓" : "✗"} ${first.resolved} → ${last.resolved}`);
+
+const growth = heapAfterStress - heapBeforeStress;
+console.log(`连续 20 次 resize 堆内存: ${(heapBeforeStress / 1048576).toFixed(1)} MB → ${(heapAfterStress / 1048576).toFixed(1)} MB(增长 ${(growth / 1048576).toFixed(2)} MB)`);
+if (growth > 4 * 1048576) fail(`连续 resize 内存增长过大:${(growth / 1048576).toFixed(2)} MB`);
+
+const drawCalls = rows[rows.length - 1].drawCalls;
+console.log(`累计绘制帧数: ${drawCalls}`);
+if (!(drawCalls > 0)) fail("没有绘制任何帧");
+
+const noisy = consoleLines.filter((l) => /error|warn|uncaught|fail/i.test(l));
+console.log(`控制台异常: ${noisy.length ? noisy.slice(0, 5).join(" | ") : "无 ✓"}`);
+if (noisy.length) fail("控制台有报错/警告");
+console.log(`未捕获异常: ${exceptions.length ? exceptions.slice(0, 4).join(" | ") : "无 ✓"}`);
+if (exceptions.length) fail("有未捕获异常");
+
+console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/.scratch/verify-fitter.mjs b/.scratch/verify-fitter.mjs
new file mode 100644
index 0000000..165ee9c
--- /dev/null
+++ b/.scratch/verify-fitter.mjs
@@ -0,0 +1,72 @@
+// 取景公式的离线验证:不依赖浏览器,直接对 viewport-fitter.js 求值。
+// 两条硬约束:① 16:9 下必须与作者设定完全一致;② 立绘层缩放比必须等于背景 cover 缩放比。
+import { readFileSync } from "node:fs";
+
+const src = readFileSync(new URL("../dist/scripts/viewport-fitter.js", import.meta.url), "utf8").replace(/^export\s+/gm, "");
+const mod = new Function(
+ src + "\nreturn { contain, frameForAspect, coverScale, resolveViewport, toViewportConfig, REFERENCE_ASPECT };",
+)();
+
+const rect = (x, y, width, height) => ({ x, y, width, height });
+const fmt = (r) =>
+ `x=${r.x.toFixed(2)} y=${r.y.toFixed(2)} w=${r.width.toFixed(2)} h=${r.height.toFixed(2)} 比例=${(r.width / r.height).toFixed(4)}`;
+
+const cases = [
+ {
+ name: "xilian(昔涟立绘)",
+ R: rect(-2181.72, 1442.39, 4226.57, 3403.35),
+ imgW: 2048,
+ imgH: 1080,
+ },
+ {
+ name: "kv37(「成为昨日的明天」)",
+ R: rect(-1419.8, -512.4, 2457.6, 1228.8),
+ imgW: 2560,
+ imgH: 1440,
+ },
+];
+const canvases = [
+ [3440, 1440, "21:9"],
+ [1920, 1080, "16:9"],
+ [2560, 1600, "16:10"],
+ [1080, 1920, "9:16"],
+];
+
+// 背景走 CSS background-size:cover,它在画布上真实的像素缩放就是这两个候选里的较大者。
+const bgScale = (w, h, imgW, imgH) => Math.max(w / imgW, h / imgH);
+
+let failures = 0;
+for (const c of cases) {
+ const imgAspect = c.imgW / c.imgH;
+ const V0 = mod.contain(c.R, mod.REFERENCE_ASPECT);
+ console.log(`\n=== ${c.name} 背景图 ${c.imgW}x${c.imgH}(比例 ${imgAspect.toFixed(4)}) ===`);
+ console.log(`作者视口 R : ${fmt(c.R)}`);
+ console.log(`参考取景 V0 : ${fmt(V0)}`);
+
+ // 基准取 16:9 1080p:立绘与背景都以此为准比较"被放大了多少倍"
+ const refCanvas = [1920, 1080];
+ const spinePx0 = refCanvas[1] / V0.height;
+ const bgPx0 = bgScale(refCanvas[0], refCanvas[1], c.imgW, c.imgH);
+
+ for (const [w, h, tag] of canvases) {
+ const aspect = w / h;
+ const V = mod.frameForAspect(V0, aspect, imgAspect);
+ const spineRatio = h / V.height / spinePx0;
+ const bgRatio = bgScale(w, h, c.imgW, c.imgH) / bgPx0;
+ const synced = Math.abs(spineRatio - bgRatio) < 1e-6;
+ if (!synced) failures++;
+ console.log(
+ ` ${tag.padEnd(5)} ${String(w).padStart(4)}x${String(h).padEnd(4)} ${fmt(V)}` +
+ ` 立绘 x${spineRatio.toFixed(4)} 背景 x${bgRatio.toFixed(4)} ${synced ? "同步 ✓" : "不同步 ✗"}`,
+ );
+ }
+
+ // 参考比例下必须与作者设定逐像素一致,且必须与拟合前的真实渲染值吻合
+ const Vref = mod.frameForAspect(V0, mod.REFERENCE_ASPECT, imgAspect);
+ const untouched = ["x", "y", "width", "height"].every((k) => Math.abs(Vref[k] - V0[k]) < 1e-9);
+ if (!untouched) failures++;
+ console.log(` 参考比例回归: ${untouched ? "与作者设定完全一致 ✓" : "被改动 ✗"}`);
+}
+
+console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/.scratch/wallpaper-optimization/issues/04-aspect-adaptation.md b/.scratch/wallpaper-optimization/issues/04-aspect-adaptation.md
index 4d7e0ca..804a78b 100644
--- a/.scratch/wallpaper-optimization/issues/04-aspect-adaptation.md
+++ b/.scratch/wallpaper-optimization/issues/04-aspect-adaptation.md
@@ -1,20 +1,68 @@
# 04 比例适配:按容器比例重算视口
-Status: ready-for-agent
+Status: resolved
## 问题
-viewport 以等比 "contain" 映射到画布:单轴贴边、另一轴多露出世界,因此现在的负 padding 只在目标比例下成立;
-在超宽屏(3440×1440)或竖屏(1080×1920)下会露出多余的世界或裁掉主体。
+viewport 以等比 "contain" 映射到画布:单轴贴边、另一轴多露出世界。
+原本的判断是"负 padding 只在目标比例下成立,其它比例会裁掉主体"——**实测证明这个判断是错的**,
+真正的病根是另一件事。
-## 步骤
+## 诊断(实测,非推测)
-1. 测出每档壁纸在 21:9 / 16:9 / 9:16 下的构图偏差(已有对拍矩阵)。
-2. 按容器宽高比在运行时重算 pad(视口是活对象:原地改 pad 后调用 `player.setViewport(当前动画名)` 即生效,
- 整体替换 viewport 会丢 `viewport.animations` 并在 spine-player 第 14973 行崩溃)。
-3. 目标:主体完整、底部对齐、无空边。
+先量清楚事实,再改代码:
+
+1. **contain 永远不会裁掉作者视口**。可见矩形是宽高比等于画布、且恰好含住作者视口 R 的最小矩形,
+ 所以 R 在**任何**比例下都完整可见。比例不同只是"另一轴多露出世界":
+ - 21:9 → 6453×2701(竖轴贴边、横轴多露)
+ - 16:9 → 6050×3403
+ - 9:16 → 1914×3403(横轴贴边)
+ 三种比例下 `coversView` 全为 true,"裁掉作者视口"这条假设不成立。
+2. **这不是我重构引入的回归**。基线 `d8c7e49` 的 `preset-store.js` 里 xilian 的四个 pad
+ (-25% / -28% / -30% / -23%)与 kv37 的 `x: -1215, y: -410, w: 2048, h: 1024` 与现在一字不差;
+ 基线的容器也是 `#spine-container` 绝对定位铺满、body flex 居中。取景语义未变。
+3. **真正的病根:背景走 cover,立绘层走 contain,两者缩放分叉。**
+ `body { background-size: cover }` 会按需放大裁切,立绘层却按 contain 固定世界矩形。
+ 以 1080×1920 竖屏为例,背景被放大约 1.78 倍,立绘层的世界高度却纹丝不动——
+ 特效于是和背景插画脱节,"多余世界"其实看不太出来,脱节才看得出来。
+4. **spine 资源路径是 `./effects/*.json`:立绘层是特效层,背景插画才是主体。**
+ 所以判定标准不该是"特效不被裁切"(粒子飘出画面本来就正常),
+ 而应是"特效与背景插画始终同倍率、同中心"。
+
+## 做法
+
+新增 `dist/scripts/viewport-fitter.js`(纯函数)与 `SpineController` 的取景维护:
+
+- 参考比例 `16/9`:先让播放器自行解出作者视口 R,换算成参考比例下的可见矩形 V0。
+ **这个比例下与作者设定逐像素一致**。
+- 其它比例:让立绘层的像素缩放**跟着背景的 cover 缩放走**——
+ `V.h = V0.h × coverScale(参考比例) / coverScale(当前比例)`,中心不变。
+ 于是背景放大 1.78 倍时,特效也放大 1.78 倍。
+- 应用方式:原地改写 `config.viewport` 的 x/y/width/height 与四个 pad,再调
+ `player.setViewport(动画名)`,并把 `viewportTransitionStart` 推到足够久以前(避免 0.25s 插值拖影)。
+ 绝不整体替换 `config.viewport`:`setViewport()` 会无条件读 `config.viewport.animations[name]`,
+ 换掉整个对象会在 spine-player 第 14973 行崩溃。
+- 维护时机:挂在 `config.frame` 回调上(它在每帧相机计算**之前**被调用),以画布比例作键——
+ 比例没变就是一次浮点比较后返回,比例变了就地重算。
+ `config.success` 用不了:它在 `setViewport` 之前触发,那时 `currentViewport` 还不存在。
+- 预设可用 `framing: "author"` 退回作者原始的 contain 行为。
## 验收
-- 三种比例下都对拍留档,主体不被裁切、底部不悬空。
-- 切换窗口大小时不重建播放器(用 `setViewport`),连续 resize 无内存增长。
+| 项目 | 结果 |
+| --- | --- |
+| 参考比例回归 | kv37 **逐像素 0 差异**;xilian 仅 1/255(浮点末位舍入) |
+| 立绘/背景缩放同步 | 21:9 / 16:9 / 16:10 / 9:16 × 两个预设,**全部精确相等**(离线算式验证 + 渲染实测) |
+| 渲染实测吻合预测 | 三种比例可见矩形与公式预测逐值一致(pads 全 0、base == resolved) |
+| 对拍留档 | `tools/shots/fit/` 下 6 组(2 预设 × 3 比例)改动前后各一套(该目录按仓库约定不入库,可随时重跑重生成) |
+| 不重建播放器 | 9 次改视口 + 连续 20 次压力往返,实例身份标记**从未丢失** |
+| 连续 resize 内存 | 强制 GC 后 7.9 MB → 8.0 MB(增长 **0.04 MB**) |
+| 回到 16:9 可逆 | 取景完全回到原值,无漂移 |
+| 控制台 | 0 报错 / 0 未捕获异常 |
+
+复现:`node .scratch/verify-fitter.mjs`(离线算式)、`node .scratch/test-resize.mjs`(resize 行为)。
+
+## 附带修复
+
+Chromium 会自动请求 `/favicon.ico` 并在控制台留下 404。已在 `dist/index.html` 加
+`` 消除,使"0 console 报错"这条验收真正可达成。
diff --git a/.scratch/wallpaper-optimization/issues/05-we-properties.md b/.scratch/wallpaper-optimization/issues/05-we-properties.md
index 2b06bc6..61dc8e1 100644
--- a/.scratch/wallpaper-optimization/issues/05-we-properties.md
+++ b/.scratch/wallpaper-optimization/issues/05-we-properties.md
@@ -1,17 +1,40 @@
# 05 WE 属性扩展:音源 combo、fps 限制
-Status: ready-for-agent
+Status: resolved
## 内容
-1. **音源(音源作用域已定)**:新增 `project.json` combo `bgm`,列表是所有壁纸音源声明的**并集**(WE 的 combo 是静态列表,只能手工同步);
- 每档壁纸在自己 `preset.js` 的 `audioChoices` 里声明可选音源。切换壁纸时,若当前音源不属于新壁纸,回落"随预设"。
-2. **fps 限制**:`properties.fps` 经 `applyGeneralProperties` 下发,WE 不会替壁纸限流,需自行实现。
- 实现方式:包裹实例上的 `player.drawFrame`(循环用箭头闭包懒查,因此可以门控);注意 `config.frame/update/draw`
- 都在下一帧排程之后运行,用它们限不了流。
-3. `setPaused` 暂不实现(WE 会整体冻结进程);除非用户在 WE 里实测发现音频仍在响。
+1. **音源**:`project.json` 新增 combo `bgm`,列表是所有壁纸音源声明的**并集**
+ (WE 的 combo 是静态列表,只能手工同步),另加一档「随预设」`auto`:
+ `auto` / `zaiduheni`「「再度和你」」/ `xilian`「昔涟」/ `pv37`「版本 PV」。
+ 每档壁纸仍在自己 `preset.js` 的 `audioChoices` 里声明可选音源。
+2. **fps 限制**:`properties.fps` 经 `applyGeneralProperties` 下发,WE 不会替壁纸限流,自行实现。
+3. `setPaused` 不实现(WE 会整体冻结进程)。
+
+## 实现要点
+
+- **音源优先级**:用户自填 URL(`audio_file`)> `bgm` 选中的音源 > 本壁纸默认音源。
+ 所选音源**只在本壁纸声明过时才生效**;不属于当前壁纸则音频回落本壁纸默认,
+ **选择本身保留**,切回那份壁纸时用户的选择还在。
+- **门控绘制**:包裹实例上的 `drawFrame`。播放器循环写作
+ `requestAnimationFrame(() => this.drawFrame())`——箭头闭包在调用时才读 `this.drawFrame`,
+ 所以在实例上换掉它就能接管排程。
+ - 原实现只在**真正绘制**时才排下一帧,而被限流丢掉的帧同样要排,否则循环停摆;
+ 因此排程由包装层负责,再让原实现以 `requestNextFrame = false` 调用。
+ - **踩到的坑**:朴素地"距上一帧不足 `interval` 就丢"会把 rAF 抖动放大成整拍丢失——
+ 目标 30fps 时落在 33.2ms 的那一帧被判为不足 33.33ms 而丢掉,实测只有 **22.5fps**。
+ 改为"留 4ms 容差 + 到期时间按固定步长推进"后精确到 30.0。
+ - **另一个坑**:`if (properties.fps)` 的真值判断会把 `fps: 0`(取消限流)整个吞掉,
+ 改判 `!== undefined`。
## 验收
-- combo 切音源即时生效,且切壁纸时的回落行为正确。
-- 设置 30/60 fps 时实际帧率符合,且不影响动画相位推进速度。
+`node .scratch/test-props.mjs`:
+
+| 项目 | 结果 |
+| --- | --- |
+| bgm 切音源即时生效 | ✓(10 项音源断言全过) |
+| 跨壁纸回落 + 选择保留 | ✓ xilian 选「昔涟」→ 切 kv37 回落 pv37 → 切回 xilian 回落默认 |
+| 自定义 URL 优先级最高且不被切壁纸清掉 | ✓ |
+| 限 30 / 20 / 取消限流 | 实测 **30.0 / 20.0 / 60.0 fps** |
+| 不影响动画相位推进速度 | 0.999 / 0.999 / 1.000 倍速(基准 1.000) |
diff --git a/.scratch/wallpaper-optimization/issues/06-package-v3.md b/.scratch/wallpaper-optimization/issues/06-package-v3.md
index 9b0b24d..4fd5370 100644
--- a/.scratch/wallpaper-optimization/issues/06-package-v3.md
+++ b/.scratch/wallpaper-optimization/issues/06-package-v3.md
@@ -1,10 +1,34 @@
# 06 打包 v3 并重新发布
-Status: ready-for-agent
+Status: resolved(第 4 步为人工步骤,待用户执行)
-## 步骤
+## 步骤与结果
-1. `project.json`:`version` 升到 3,更新 `description`(提及新增音源切换等),核对 `properties` 与代码一致。
-2. `preview.gif` 现在 160×160 / 50 帧 / 840 KB——合规(≤256×256、≤1 MB)但超过推荐的 500 KB,重新压缩。
-3. 全量验收:0 死文件、≤35 MB、3 种比例 0 console 报错、预设往返不丢音源设置。
-4. 上传到 Workshop(人工步骤,需要用户操作)。
+1. **`project.json`**:`version` 升到 3;描述新增"可切换各预设自带的背景音乐""自适应窗口比例";
+ 新增 `bgm` combo 与 `bgm_note`。核对:`preset` 的 combo 取值仍是 `xilian` / `kv37`
+ (用户已保存的设置依赖它,绝不能改)。**完成**。
+2. **`preview.gif`**:原 160×160 / 50 帧 / 25fps / 2.0s / **821 KB**,超推荐值 500 KB。
+ 重新压缩命令(结果可复现):
+
+ ```powershell
+ ffmpeg -y -i dist/preview.gif -filter_complex `
+ "[0:v]fps=12.5,split[a][b];[a]palettegen=max_colors=128:stats_mode=full[p];[b][p]paletteuse=dither=none" `
+ -loop 0 .scratch/pv-I.gif
+ Copy-Item .scratch/pv-I.gif dist/preview.gif -Force
+ ```
+
+ (**不能把输入输出写成同一个文件**,ffmpeg 会拒绝;必须先输出到临时文件再覆盖。)
+ 即"每 2 帧取 1 帧 + 128 色 + 不抖动",**逐帧与源帧像素完全相同**(第 k 帧对原第 2k 帧,
+ 抽查 0/6/12/18/24 帧全部 SSIM 1.0、max|diff| 0),只是时间采样率减半:
+ 160×160 / 25 帧 / 12.5fps / 2.0s / **347 KB**。**完成**。
+ 注:减色到 96/64/48 色收效甚微(604/519/474 KB)——体积由帧间变化量主导,不由调色板主导,
+ 所以走帧采样率这条路更划算。
+3. **全量验收**(`node .scratch/test-acceptance.mjs`):**全部通过**
+ - 总体积 **28.52 MB** / 34 个文件(≤ 35 MB)
+ - **0 死文件**
+ - `project.json` 解析正常、version 3、bgm 存在、preset 取值未变
+ - 21:9 / 16:9 / 9:16 三种比例:播放器就绪、可见矩形比例与画布一致、**0 控制台报错 / 0 异常**
+ - 预设往返 10 次:自定义音源与音量 0.2 均未丢失,仍在持续绘制,0 未捕获异常
+4. **上传到 Workshop**:人工步骤,需要用户操作。
+ 本次为破坏性较小的重构(16:9 逐像素不变),但 21:9 / 9:16 下构图按设计改变,建议在
+ 超宽屏与竖屏各看一眼再发布。
diff --git a/.scratch/wallpaper-optimization/spec.md b/.scratch/wallpaper-optimization/spec.md
index b2060de..8b50dd0 100644
--- a/.scratch/wallpaper-optimization/spec.md
+++ b/.scratch/wallpaper-optimization/spec.md
@@ -35,7 +35,7 @@
| --- | --- | --- |
| 01 | 资源按 游戏/壁纸 分层,预设拆进各自壁纸目录 | 完成(`09ff322`) |
| 02 | 体积:音频转 Opus + 图片无损 WebP(105.1 → 28.9 MB) | 完成 |
-| 03 | 健壮性:初始化状态机、死代码、用户级覆盖语义 | 待做 |
-| 04 | 比例适配:按容器比例重算视口 | 待做 |
-| 05 | WE 属性扩展:音源 combo、fps 限制 | 待做 |
-| 06 | 打包 v3 并重新发布 | 待做 |
+| 03 | 健壮性:初始化状态机、死代码、用户级覆盖语义 | 完成(`4662856`) |
+| 04 | 比例适配:立绘层取景跟随背景 cover 缩放 | 完成 |
+| 05 | WE 属性扩展:音源 combo、fps 限制 | 完成 |
+| 06 | 打包 v3 并重新发布 | 完成(上传为待人工执行) |
diff --git a/dist/index.html b/dist/index.html
index 2d72e4b..b6461e6 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -4,6 +4,8 @@
昔涟动态立绘
+
+
diff --git a/dist/preview.gif b/dist/preview.gif
index 6c1c53d..c148053 100644
Binary files a/dist/preview.gif and b/dist/preview.gif differ
diff --git a/dist/project.json b/dist/project.json
index d9452e0..9675ede 100644
--- a/dist/project.json
+++ b/dist/project.json
@@ -1,6 +1,6 @@
{
"contentrating" : "Everyone",
- "description" : "[b]特色功能[/b]\r\n[list]\r\n [*]支持切换预设。\r\n [olist]\r\n [*]官方昔涟动态立绘。\r\n [*]官方三点七版本「成为昨日的明天」专题展示页。\r\n [/olist]\r\n [*]支持音源切换。\r\n [olist]\r\n [*][quote][b]注意:[/b]目前仅支持 URL 格式。[/quote]\r\n [/olist]\r\n[/list]",
+ "description" : "[b]特色功能[/b]\r\n[list]\r\n [*]支持切换预设。\r\n [olist]\r\n [*]官方昔涟动态立绘。\r\n [*]官方三点七版本「成为昨日的明天」专题展示页。\r\n [/olist]\r\n [*]支持音源切换。\r\n [olist]\r\n [*]可切换各预设自带的背景音乐。\r\n [*]也可自行填写音频 URL。\r\n [*][quote][b]注意:[/b]目前仅支持 URL 格式。[/quote]\r\n [/olist]\r\n [*]自适应窗口比例,不同显示器下构图保持一致。\r\n[/list]",
"file" : "index.html",
"general" :
{
@@ -49,6 +49,40 @@
"text" : "作者信息:
",
"type" : "text"
},
+ "bgm" :
+ {
+ "index" : 8,
+ "options" :
+ [
+ {
+ "label" : "随预设",
+ "value" : "auto"
+ },
+ {
+ "label" : "「再度和你」",
+ "value" : "zaiduheni"
+ },
+ {
+ "label" : "昔涟",
+ "value" : "xilian"
+ },
+ {
+ "label" : "版本 PV",
+ "value" : "pv37"
+ }
+ ],
+ "order" : 108,
+ "text" : "🎵背景音乐选择",
+ "type" : "combo",
+ "value" : "auto"
+ },
+ "bgm_note" :
+ {
+ "index" : 9,
+ "order" : 109,
+ "text" : "选择各预设自带的背景音乐:- 默认「随预设」跟随壁纸切换。
- 所选项不属于当前壁纸时,自动回落该壁纸的默认音乐,选择仍会保留。
",
+ "type" : "text"
+ },
"preset" :
{
"index" : 2,
@@ -98,7 +132,7 @@
"tags" : [ "Anime" ],
"title" : "【崩坏:星穹铁道】昔涟",
"type" : "Web",
- "version" : 2,
+ "version" : 3,
"visibility" : "public",
"workshopid" : "3604974793",
"workshopurl" : "steam://url/CommunityFilePage/3604974793"
diff --git a/dist/scripts/index.js b/dist/scripts/index.js
index 642302a..8340e9e 100644
--- a/dist/scripts/index.js
+++ b/dist/scripts/index.js
@@ -22,10 +22,35 @@ const state = {
presetId: "",
audioSource: "", // 空字符串 = 用当前预设的默认音源
audioVolume: DEFAULT_VOLUME,
+ bgm: "", // 空字符串 = 随预设;否则是某个 preset.js 里 audioChoices 的 id
};
let appliedPresetId = "";
+// 量出背景图的真实宽高比:立绘层要靠它才能和背景同步缩放。
+// 直接读图片,避免在预设里再手写一个容易写错的数字。
+function measureImageAspect(url) {
+ return new Promise((resolve) => {
+ if (!url) {
+ resolve(undefined);
+ return;
+ }
+ const probe = new Image();
+ probe.onload = () => resolve(probe.naturalWidth / probe.naturalHeight);
+ probe.onerror = () => resolve(undefined);
+ probe.src = url;
+ });
+}
+
+// bgm combo 选中的音源只在本壁纸声明过时才生效。否则回落本壁纸默认音源——
+// 选择本身记着不动,切回那份壁纸时用户的选择还在。
+function bgmSource(preset) {
+ if (!state.bgm) return "";
+ const choices = preset.audioChoices || [];
+ const hit = choices.find((choice) => choice.id === state.bgm);
+ return hit ? hit.source : "";
+}
+
function render() {
const presetId = state.presetId || DEFAULT_PRESET_ID;
const preset = presets.get(presetId);
@@ -37,10 +62,14 @@ function render() {
appliedPresetId = presetId;
backgroundController.setImage(preset.backgroundImage);
spineController.setConfig(preset.spineConfig); // 播放器还不存在时只是记下来
+ // 图片解码是异步的,回来时可能已经切到别的预设了,所以要核对一次
+ measureImageAspect(preset.backgroundImage).then((aspect) => {
+ if (appliedPresetId === presetId) spineController.setImageAspect(aspect);
+ });
}
- // 用户自定义音源优先;用户没给就用当前壁纸的默认音源
- audioController.setSource(state.audioSource || preset.audioOptions.source);
+ // 音源优先级:用户自填 URL > bgm 里选的(若本壁纸有)> 本壁纸默认音源
+ audioController.setSource(state.audioSource || bgmSource(preset) || preset.audioOptions.source);
audioController.setVolume(state.audioVolume);
if (state.ready) audioController.play();
}
@@ -68,6 +97,11 @@ window.wallpaperPropertyListener = {
state.audioSource = String(properties.audio_file.value || "").trim();
}
+ if (properties.bgm) {
+ const bgm = String(properties.bgm.value || "").trim();
+ state.bgm = bgm === "auto" ? "" : bgm;
+ }
+
if (properties.audio_volume) {
const volume = Number(properties.audio_volume.value);
if (Number.isFinite(volume)) {
@@ -79,8 +113,10 @@ window.wallpaperPropertyListener = {
},
applyGeneralProperties: function (properties) {
- if (properties.fps) {
- // fps 限制在第 05 步接入:WE 不替壁纸限流,需要自己按 properties.fps 节流绘制
+ // WE 不会替壁纸限流,fps 得自己落实。这个属性与预设无关,不进 state。
+ // 判 undefined 而不是真值:0 表示不限流,用真值判断会把"取消限流"整个吞掉。
+ if (properties.fps !== undefined) {
+ spineController.setFps(properties.fps);
}
},
};
diff --git a/dist/scripts/spine-controller.js b/dist/scripts/spine-controller.js
index 25ff0b2..307198c 100644
--- a/dist/scripts/spine-controller.js
+++ b/dist/scripts/spine-controller.js
@@ -1,9 +1,14 @@
-// 负责把「当前预设的 spineConfig」应用到 Spine 播放器上。
+import { REFERENCE_ASPECT, contain, frameForAspect, resolveViewport, toViewportConfig } from "./viewport-fitter.js";
+
+// 负责把「当前预设的 spineConfig」应用到 Spine 播放器上,并维持立绘层与背景图的取景同步。
//
-// 核心约定:写入与应用分开。WE 可能在 window.load 之前就下发用户属性,那时播放器还不存在;
+// 核心约定一:写入与应用分开。WE 可能在 window.load 之前就下发用户属性,那时播放器还不存在;
// 旧实现把配置赋值也塞在 `if (this.player)` 里,于是那次配置被静默丢弃,
// 结果是"背景与音乐已切到新壁纸、立绘还是旧壁纸"的混合状态(已实测复现)。
// 现在 setConfig() 无论如何都记下配置,create() 用记下的配置创建。
+//
+// 核心约定二:窗口尺寸变化绝不重建播放器。重建意味着重新下载 3.4 MB 骨架与全部贴图;
+// 取景只是 viewport 的算术,改写 config.viewport 后调 setViewport() 就地生效。
export default class SpineController {
constructor(containerId, baseConfig = {}) {
this.containerId = containerId;
@@ -18,21 +23,87 @@ export default class SpineController {
this.presetConfig = {};
this.container = document.getElementById(containerId);
this.player = undefined;
+
+ this.framingEnabled = true;
+ this.imageAspect = undefined; // 背景图宽高比,决定立绘层与背景的缩放关系
+ this.framing = undefined; // 作者视口(换算到参考比例后的可见矩形)+ 动画名
+ this.appliedAspect = undefined; // 上一次已应用的画布比例
+
+ this.fps = 0; // 0 = 不限流。WE 不会替壁纸限流,得自己门控绘制
+ this.rawDrawFrame = undefined; // 被包裹前的 drawFrame,用于换档时摘掉旧包装
}
// 只写状态:与播放器是否存在无关,随时可调用
setConfig(presetConfig = {}) {
this.presetConfig = presetConfig;
+ // 预设可声明 framing:"author" 保留作者原始的「含住视口」行为,不跟随背景缩放
+ this.framingEnabled = presetConfig.framing !== "author";
+ this.framing = undefined;
+ this.appliedAspect = undefined;
if (this.player) this.rebuild();
}
+ // 背景图换了,取景常数也要跟着换(比例一变就重新推导)
+ setImageAspect(aspect) {
+ if (!(aspect > 0) || aspect === this.imageAspect) return;
+ this.imageAspect = aspect;
+ this.appliedAspect = undefined;
+ }
+
create() {
if (this.player) return;
- // 每次都用 baseConfig + 当前 presetConfig 现算,避免上一份预设的键残留
+ // 每次都用 baseConfig + 当前 presetConfig 现算,避免上一份预设的键残留。
+ // frame 放在最后:它是内部钩子,不允许被预设数据覆盖。
+ this.rawDrawFrame = undefined;
this.player = new spine.SpinePlayer(this.containerId, {
...this.baseConfig,
...this.presetConfig,
+ frame: (player) => this.maintainFraming(player),
});
+ this.applyFpsLimit();
+ }
+
+ // WE 经 applyGeneralProperties 下发 fps,但它不会替壁纸限流,必须自行实现。
+ setFps(fps) {
+ const value = Number(fps);
+ this.fps = Number.isFinite(value) && value > 0 ? value : 0;
+ this.applyFpsLimit();
+ }
+
+ // 门控绘制:把实例上的 drawFrame 换成一层包装。
+ // 播放器的循环写作 requestAnimationFrame(() => this.drawFrame())——箭头闭包在调用时才读
+ // this.drawFrame,所以在实例上换掉它就能接管排程。
+ // 关键点:原实现只在真正绘制时才排下一帧,而被限流丢掉的帧同样要排,否则循环会停摆。
+ applyFpsLimit() {
+ const player = this.player;
+ if (!player) return;
+
+ // 先摘掉上一轮包装,避免换档时叠成多层
+ if (this.rawDrawFrame) {
+ player.drawFrame = this.rawDrawFrame;
+ this.rawDrawFrame = undefined;
+ }
+ if (!this.fps) return;
+
+ const raw = (this.rawDrawFrame = player.drawFrame);
+ const interval = 1000 / this.fps;
+ // rAF 的节拍有抖动,而"距上一帧不足 interval 就丢"会把抖动放大成整拍丢失:
+ // 目标是 30fps 时,落在 33.2ms 的那一帧会被判为不足 33.33ms 而丢掉,实测只剩 22.5fps。
+ // 所以留一点容差,并让到期时间按固定步长推进,避免抖动被累积成额外丢帧。
+ const tolerance = 4;
+ let due = -Infinity;
+
+ player.drawFrame = (requestNextFrame = true) => {
+ if (player.disposed || player.error) return;
+ if (requestNextFrame && !player.stopRequestAnimationFrame) {
+ requestAnimationFrame(() => player.drawFrame());
+ }
+ const now = performance.now();
+ if (now + tolerance < due) return; // 本帧丢弃:不推进动画,也不绘制
+ due = due < 0 || now - due > interval ? now + interval : due + interval;
+ // 排程已由本层负责,让原实现不要再排一次
+ raw.call(player, false);
+ };
}
// 换骨架没有捷径,只能销毁重建
@@ -46,5 +117,58 @@ export default class SpineController {
this.player.dispose();
this.container.innerHTML = "";
this.player = undefined;
+ this.rawDrawFrame = undefined;
+ this.framing = undefined;
+ this.appliedAspect = undefined;
+ }
+
+ // 播放器每帧在算相机之前调用它(drawFrame 内,renderer.resize 之后)。
+ // 以画布比例作键:比例没变就是一次比较后返回,不会给每帧加负担。
+ maintainFraming(player) {
+ if (!this.framingEnabled) return;
+
+ const canvas = player.canvas;
+ if (!canvas || !canvas.clientWidth || !canvas.clientHeight) return;
+ const aspect = canvas.clientWidth / canvas.clientHeight;
+ if (aspect === this.appliedAspect) return;
+
+ // 骨架加载完成前 currentViewport 还不存在,下一帧再试
+ if (!this.framing && !this.captureFraming(player)) return;
+ this.applyFraming(player, aspect);
+ }
+
+ // 记下作者视口在参考比例下的取景;之后所有比例都从它推导
+ captureFraming(player) {
+ const viewport = player.currentViewport;
+ if (!viewport) return undefined;
+
+ let animation = this.presetConfig.animation;
+ if (!animation) {
+ const entry = player.animationState && player.animationState.getCurrent(0);
+ animation = entry && entry.animation && entry.animation.name;
+ }
+ if (!animation) return undefined;
+
+ this.framing = {
+ animation,
+ reference: contain(resolveViewport(viewport), REFERENCE_ASPECT),
+ };
+ return this.framing;
+ }
+
+ applyFraming(player, aspect) {
+ const { animation, reference } = this.framing;
+ const target = this.imageAspect
+ ? frameForAspect(reference, aspect, this.imageAspect)
+ : contain(reference, aspect);
+
+ // 就地改写字段,绝不整体替换 config.viewport:
+ // setViewport() 会无条件读 config.viewport.animations[name],换掉整个对象会崩。
+ Object.assign(player.config.viewport, toViewportConfig(target));
+
+ player.setViewport(animation);
+ // 取景要随窗口实时变化,0.25 秒的过渡插值只会拖出残影:直接跳到位
+ player.viewportTransitionStart = performance.now() - 1e6;
+ this.appliedAspect = aspect;
}
}
diff --git a/dist/scripts/viewport-fitter.js b/dist/scripts/viewport-fitter.js
new file mode 100644
index 0000000..80cc9fa
--- /dev/null
+++ b/dist/scripts/viewport-fitter.js
@@ -0,0 +1,64 @@
+// 立绘层的取景计算。纯函数:只做世界坐标矩形的算术,不碰 DOM,也不碰播放器实例。
+//
+// 矩形一律是 { x, y, width, height },世界单位,y 轴向上。
+//
+// 为什么需要这一层:
+// 背景图走 CSS `background-size: cover`(铺满、按需裁切),
+// 立绘层却由播放器按「含住作者视口」映射(contain)。两者只在作者调参的那个比例下一致,
+// 比例一变,缩放倍率就分叉——竖屏下背景放大 1.78 倍,立绘层却纹丝不动,特效于是和背景插画脱节。
+// 这里让立绘层的缩放跟着背景 cover 的缩放走,两者始终同倍率、同中心。
+
+// 作者调参时的基准比例。在这个比例下取景与作者设定逐像素一致。
+export const REFERENCE_ASPECT = 16 / 9;
+
+// 播放器把 currentViewport(基矩形 + 四边内边距)解成最终可见矩形。
+// 对应 spine-player.js 里 `viewport.x = currentViewport.x - padLeft` 那四行。
+export function resolveViewport(viewport) {
+ return {
+ x: viewport.x - viewport.padLeft,
+ y: viewport.y - viewport.padBottom,
+ width: viewport.width + viewport.padLeft + viewport.padRight,
+ height: viewport.height + viewport.padBottom + viewport.padTop,
+ };
+}
+
+// 把矩形按 aspect 撑到刚好含住它,中心不变。这正是播放器在参考比例下的行为。
+export function contain(rect, aspect) {
+ const width = Math.max(rect.width, rect.height * aspect);
+ const height = width / aspect;
+ const cx = rect.x + rect.width / 2;
+ const cy = rect.y + rect.height / 2;
+ return { x: cx - width / 2, y: cy - height / 2, width, height };
+}
+
+// 背景在某个画布比例下的 cover 缩放,以「受高度约束时的倍率」为 1 归一化。
+// 背景比画布更宽时按高度铺满(倍率不变),画布更宽时按宽度铺满(倍率随 aspect 增长)。
+export function coverScale(aspect, imageAspect) {
+ return Math.max(aspect / imageAspect, 1);
+}
+
+// 目标可见矩形:参考比例下正好等于作者视口,其余比例按背景 cover 缩放的反比收放。
+// 效果等价于「立绘层相对背景插画永远 cover」。
+export function frameForAspect(reference, aspect, imageAspect, referenceAspect = REFERENCE_ASPECT) {
+ const height =
+ (reference.height * coverScale(referenceAspect, imageAspect)) / coverScale(aspect, imageAspect);
+ const width = height * aspect;
+ const cx = reference.x + reference.width / 2;
+ const cy = reference.y + reference.height / 2;
+ return { x: cx - width / 2, y: cy - height / 2, width, height };
+}
+
+// 把目标可见矩形写成播放器 config.viewport 需要的字段:内边距归零,
+// 这样 resolveViewport() 得到的就是它本身。
+export function toViewportConfig(rect) {
+ return {
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height,
+ padLeft: 0,
+ padRight: 0,
+ padTop: 0,
+ padBottom: 0,
+ };
+}
diff --git a/dist/scripts/wallpaper-engine.js b/dist/scripts/wallpaper-engine.js
deleted file mode 100644
index 54a95cc..0000000
--- a/dist/scripts/wallpaper-engine.js
+++ /dev/null
@@ -1,974 +0,0 @@
-// 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/docs/adr/0003-spine-follows-background-cover.md b/docs/adr/0003-spine-follows-background-cover.md
new file mode 100644
index 0000000..83c4842
--- /dev/null
+++ b/docs/adr/0003-spine-follows-background-cover.md
@@ -0,0 +1,36 @@
+# 立绘层取景跟随背景图的 cover 缩放
+
+立绘层(spine 特效层)的可见世界矩形不再按"含住作者视口"(contain)映射,
+而是让它与背景图 `background-size: cover` 的缩放**同步**:背景放大多少倍,立绘层就放大多少倍。
+
+## 问题
+
+背景走 CSS `cover`(铺满、按需裁切),立绘层走播放器自带的 contain 映射。
+两者只在作者调参的那个比例下一致,比例一变缩放就分叉。以 1080×1920 竖屏为例,
+背景被放大约 1.78 倍,立绘层的世界高度却纹丝不动——特效与背景插画脱节。
+
+需要说明的是:contain 本身**不会**裁掉作者视口。可见矩形是宽高比等于画布、
+且恰好含住作者视口的最小矩形,所以作者视口在任何比例下都完整可见。
+"负内边距在其它比例下会裁掉主体"这个最初的判断经实测证伪。
+
+## 决定
+
+- 参考比例取 `16/9`。该比例下取景与作者设定**逐像素一致**,保证已发布的观感不被改动。
+- 其它比例:`V.h = V0.h × coverScale(参考比例) / coverScale(当前比例)`,中心不变,
+ 其中 `V0` 是作者视口换算到参考比例后的可见矩形。等价于"立绘层相对背景插画永远 cover"。
+- 应用方式是原地改写 `config.viewport` 字段后调 `player.setViewport(动画名)`,
+ 绝不整体替换该对象:`setViewport()` 会无条件读 `config.viewport.animations[name]`,
+ 换掉整个对象会在 `spine-player.js` 第 14973 行崩溃。
+- 维护时机挂在 `config.frame` 回调(每帧相机计算之前),以画布比例作键。
+ `config.success` 不可用:它在 `setViewport` 之前触发,那时 `currentViewport` 还不存在。
+- 预设可声明 `framing: "author"` 退回 contain 行为。
+
+## 代价
+
+21:9 与 9:16 下立绘层的可见范围会明显收窄(竖屏约收窄到 1/4 宽度),
+因为要让特效跟着被放大 1.78 倍的背景走。这是刻意的:不这样做,特效就会与背景插画对不上。
+16:9 不受影响。若某档壁纸不适合这个策略,用 `framing: "author"` 单独退回。
+
+## 背景图比例从哪来
+
+运行时读图片的 `naturalWidth / naturalHeight`,不在预设里重复写一个容易写错的数字。
diff --git a/tools/ascii.mjs b/tools/ascii.mjs
new file mode 100644
index 0000000..20c02d0
--- /dev/null
+++ b/tools/ascii.mjs
@@ -0,0 +1,109 @@
+// 把差分掩码(立绘层 = 正常帧 − 藏 canvas 参考帧)压成 ASCII,
+// 这样在没有图像输入能力的情况下也能"看"清构图。
+// 用法:
+// node tools/ascii.mjs [cols] 差分两幅,打印强度图 + 实体占比图
+// node tools/ascii.mjs [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 [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);
+ }));
+}
diff --git a/tools/frame.mjs b/tools/frame.mjs
new file mode 100644
index 0000000..6d79256
--- /dev/null
+++ b/tools/frame.mjs
@@ -0,0 +1,54 @@
+// 量出一个预设在某分辨率下的取景几何,用来决定"该给什么视口"。
+// 输出:作者声明的取景矩形、播放器扫描出的骨架边界、当前相机实际可见的世界矩形。
+// 用法:node tools/frame.mjs
+
+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 ");
+ 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));
diff --git a/tools/subject.mjs b/tools/subject.mjs
new file mode 100644
index 0000000..0e4c5e8
--- /dev/null
+++ b/tools/subject.mjs
@@ -0,0 +1,85 @@
+// 量出「立绘在屏幕上的实际足迹」:正常帧与藏 canvas 参考帧的差值掩码。
+// 不依赖对视口/相机数学的任何建模——直接测渲染结果。
+// 输出:足迹包围盒、四条边是否被画布切掉、覆盖面积占比、足迹底边距画布底部的距离。
+// 用法:node tools/subject.mjs [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 [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 ? "被画布边缘切到" : "四边都未触边"}`);
diff --git a/tools/wallpaper-engine-simulator.js b/tools/wallpaper-engine-simulator.js
new file mode 100644
index 0000000..409c810
--- /dev/null
+++ b/tools/wallpaper-engine-simulator.js
@@ -0,0 +1,1589 @@
+// Wallpaper Engine 网页壁纸 API 模拟器(开发用具,**不随壁纸发布**)
+//
+// 为什么它不在 dist/ 里:
+// 1. dist/ 只应包含壁纸真正加载的东西。它没有任何入口引用,属于死重。
+// 2. 更要紧的是它的启用判据不安全。isWallpaperEngineEnvironment() 要求
+// window.wallpaperRegisterListener 是函数,但**真实 Wallpaper Engine 里并没有这个 API**
+// (已实测:WE 2.8.0.485 / CEF 146 只注入 window.wallpaperPropertyListener)。
+// 也就是说在真实 WE 中该判据会返回 false,模拟器会自我激活并覆盖真实 API。
+// 所以绝不能按下面的旧说明把它挂进 dist/index.html。
+// 3. 需要模拟环境时,用 tools/serve.mjs 的测试驱动(它注入的是可精确控制的桩)。
+//
+// 保留它是为了在浏览器里手工调试 WE 属性与音频可视化,加载与否由使用者显式决定。
+//
+// 原说明(已失效,勿照做):在 index.html 中以
+//
+// 优先加载本脚本即可。若检测到真实的 Wallpaper Engine 环境则自动跳过。
+// 仅在非 Wallpaper Engine 环境下启用,尽可能还原原生处理方式
+
+/* ============================================================
+ * 环境检测 —— 多维度判断,避免误判
+ * ============================================================ */
+function isWallpaperEngineEnvironment() {
+ if (typeof window.wallpaperRegisterListener !== 'function') return false;
+ if (typeof window.wallpaperPropertyListener !== 'object' ||
+ window.wallpaperPropertyListener === null) return false;
+
+ const nativeAPIs = [
+ 'wallpaperRequestFile',
+ 'wallpaperGetUserDataPath',
+ 'wallpaperRegisterAudioListener'
+ ];
+ const hasNativeAPI = nativeAPIs.some(api => typeof window[api] === 'function');
+ if (!hasNativeAPI) return false;
+
+ const ua = navigator.userAgent.toLowerCase();
+ const isCEF = ua.includes('cef') || ua.includes('wallpaper');
+ if (!isCEF) return false;
+
+ return true;
+}
+
+/* ============================================================
+ * 工具函数
+ * ============================================================ */
+const WEUtils = {
+ 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('');
+ },
+
+ 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}`;
+ },
+
+ filePathToUrl(path) {
+ if (!path) return '';
+ if (path.startsWith('file:///')) return path;
+ if (path.startsWith('http://') || path.startsWith('https://')) return path;
+ if (path.startsWith('blob:') || path.startsWith('data:')) return path;
+ let normalized = path.replace(/\\/g, '/');
+ if (!normalized.startsWith('/')) normalized = '/' + normalized;
+ return 'file:///' + normalized.replace(/^\//, '');
+ },
+
+ debounce(fn, wait = 150) {
+ let timer = null;
+ return function (...args) {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(() => fn.apply(this, args), wait);
+ };
+ },
+
+ createFpsLimiter(fps) {
+ let lastTime = 0;
+ const interval = 1000 / Math.max(1, fps);
+ return function (callback) {
+ return function loop(timestamp) {
+ if (timestamp - lastTime >= interval) {
+ lastTime = timestamp;
+ callback(timestamp);
+ }
+ requestAnimationFrame(loop);
+ };
+ };
+ },
+
+ /** 生成稳定唯一 ID(用于 group 元素) */
+ uid() {
+ return 'we_' + Math.random().toString(36).slice(2, 9);
+ }
+};
+
+/* ============================================================
+ * 条件表达式求值器
+ * ============================================================ */
+class ConditionEvaluator {
+ constructor(getPropertyValue) {
+ this.getPropertyValue = getPropertyValue;
+ this._cache = new Map();
+ }
+
+ evaluate(condition) {
+ if (!condition || typeof condition !== 'string') return true;
+ try {
+ const expr = condition.replace(
+ /([A-Za-z_$][\w$]*)\.value/g,
+ (_, key) => {
+ const v = this.getPropertyValue(key);
+ if (v === undefined || v === null) return 'undefined';
+ if (typeof v === 'string') return JSON.stringify(v);
+ if (typeof v === 'boolean' || typeof v === 'number') {
+ return String(v);
+ }
+ return JSON.stringify(v);
+ }
+ );
+ let fn = this._cache.get(expr);
+ if (!fn) {
+ fn = 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 = '';
+
+ // FPS
+ this._fps = 60;
+ this._originalRAF = null;
+ this._rafInstalled = false;
+
+ // 音频
+ this._audioContext = null;
+ this._audioAnalyser = null;
+ this._audioData = null;
+ this._audioLoopId = null;
+
+ // DOM
+ this.panelEl = null;
+ this.styleEl = null;
+ this.fieldEls = new Map();
+ this.groupEls = new Map();
+
+ // 目录文件追踪(用于 filesRemoved)
+ this._directoryFiles = new Map();
+
+ // 鼠标
+ this._mouse = {
+ isOverPanel: false,
+ hideTimer: null,
+ EDGE_ZONE: 60,
+ HIDE_DELAY: 400
+ };
+
+ // 持久化 key
+ this._storageKey = '__we_properties__';
+
+ this.conditionEvaluator = new ConditionEvaluator(
+ key => this.properties[key]?.value
+ );
+
+ this.initialize();
+ }
+
+ /* --------------------------------------------------------
+ * 初始化
+ * -------------------------------------------------------- */
+ async initialize() {
+ try {
+ await this.loadProjectData();
+ this.initializeProperties();
+ this.restoreFromStorage();
+ this.initializeAPI();
+ this.setupMouseListeners();
+ this.setupPauseListener();
+ this.checkProtocol();
+ this.checkCookieAvailability();
+ 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)
+ };
+ }
+ if (this.properties.schemecolor) {
+ const hex = WEUtils.colorToHex(this.properties.schemecolor.value);
+ document.documentElement.style.setProperty('--we-theme-color', hex);
+ }
+ }
+
+ 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 'file': return '';
+ case 'directory': return '';
+ case 'texture': return '';
+ case 'group': return null;
+ case 'usershortcut': return null;
+ case 'text': return '';
+ default: return null;
+ }
+ }
+
+ /* --------------------------------------------------------
+ * localStorage 持久化
+ * 模拟 WE 保存/恢复用户属性
+ * -------------------------------------------------------- */
+ restoreFromStorage() {
+ try {
+ const raw = localStorage.getItem(this._storageKey);
+ if (!raw) return;
+ const saved = JSON.parse(raw);
+ let restoredCount = 0;
+ for (const [key, value] of Object.entries(saved)) {
+ if (this.properties[key] &&
+ this.propertyCache[key]?.type !== 'group' &&
+ this.propertyCache[key]?.type !== 'usershortcut') {
+ this.properties[key].value = value;
+ restoredCount++;
+ }
+ }
+ if (restoredCount > 0) {
+ console.log(
+ '[WallpaperEngine] 已从 localStorage 恢复',
+ restoredCount,
+ '个属性值'
+ );
+ }
+ // 更新主题色
+ if (this.properties.schemecolor) {
+ const hex = WEUtils.colorToHex(this.properties.schemecolor.value);
+ document.documentElement.style.setProperty('--we-theme-color', hex);
+ }
+ } catch (e) {
+ console.warn('[WallpaperEngine] 恢复持久化属性失败:', e);
+ }
+ }
+
+ saveToStorage(name, value) {
+ try {
+ const raw = localStorage.getItem(this._storageKey);
+ const saved = raw ? JSON.parse(raw) : {};
+ saved[name] = value;
+ localStorage.setItem(this._storageKey, JSON.stringify(saved));
+ } catch (e) {
+ // localStorage 可能被禁用或已满,静默忽略
+ }
+ }
+
+ clearStorage() {
+ try {
+ localStorage.removeItem(this._storageKey);
+ console.log('[WallpaperEngine] 已清除持久化属性');
+ } catch (e) { /* ignore */ }
+ }
+
+ /* --------------------------------------------------------
+ * 协议 / Cookie 检测
+ * -------------------------------------------------------- */
+ checkProtocol() {
+ const proto = location.protocol;
+ if (proto === 'file:') {
+ console.log(
+ '[WallpaperEngine] 当前为 file:// 协议:' +
+ 'XMLHttpRequest 可以访问同目录文件,但 fetch 可能被阻止。'
+ );
+ } else {
+ console.log(
+ '[WallpaperEngine] 当前为 ' + proto + ' 协议:' +
+ 'fetch / XHR 均可正常工作。' +
+ '若需读取本地文件,请通过 http 服务器运行。'
+ );
+ }
+ }
+
+ checkCookieAvailability() {
+ // Wallpaper Engine 的 CEF 容器中 document.cookie 通常不可用
+ // 浏览器中可用,此处仅作说明
+ try {
+ const testKey = '__we_cookie_test__';
+ document.cookie = testKey + '=1; path=/';
+ const available = document.cookie.includes(testKey + '=1');
+ // 清理测试 cookie
+ document.cookie = testKey + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
+ if (available) {
+ console.log(
+ '[WallpaperEngine] 浏览器中 document.cookie 可用;' +
+ '注意 WE 原生环境中不可用,请避免依赖 cookie。'
+ );
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ /* --------------------------------------------------------
+ * 鼠标监听
+ * -------------------------------------------------------- */
+ 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);
+ });
+
+ document.addEventListener('keydown', e => {
+ if (e.key === 'Escape' && this.isPanelVisible) {
+ this.hideSettingsPanel();
+ }
+ });
+ }
+
+ setupPauseListener() {
+ document.addEventListener('visibilitychange', () => {
+ const isPaused = document.hidden;
+ this._emit('setPaused', isPaused);
+ });
+ }
+
+ _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;
+ 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');
+ }
+
+ /* --------------------------------------------------------
+ * 面板构造(含 group 折叠支持)
+ * -------------------------------------------------------- */
+ 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();
+ this.groupEls.clear();
+
+ // 当前激活的 group 容器
+ let currentGroup = null;
+
+ for (const { key, def } of sorted) {
+ // group 类型:创建折叠容器
+ if (def.type === 'group') {
+ const group = document.createElement('details');
+ group.className = 'we-group';
+ group.open = true;
+ const gid = WEUtils.uid();
+ group.dataset.groupKey = key;
+
+ const summary = document.createElement('summary');
+ summary.className = 'we-group__summary';
+ // 支持 HTML
+ summary.innerHTML = def.text || key;
+ group.appendChild(summary);
+
+ content.appendChild(group);
+ this.groupEls.set(key, group);
+ currentGroup = group;
+ continue;
+ }
+
+ const row = this.createFieldRow(key, def);
+ if (row) {
+ (currentGroup || content).appendChild(row);
+ const ctrl = row.querySelector('[data-we-key]');
+ if (ctrl) this.fieldEls.set(key, ctrl);
+ }
+ }
+
+ // 底部工具条
+ content.appendChild(this.buildToolsBar());
+
+ panel.appendChild(header);
+ panel.appendChild(content);
+ document.body.appendChild(panel);
+ this.panelEl = panel;
+
+ this.refreshConditionalVisibility();
+ }
+
+ /**
+ * 底部工具条:清除持久化 / 音频可视化开关
+ */
+ buildToolsBar() {
+ const bar = document.createElement('div');
+ bar.className = 'we-tools';
+
+ // 清除持久化
+ const clearBtn = document.createElement('button');
+ clearBtn.className = 'we-tool-btn';
+ clearBtn.textContent = '清除持久化数据';
+ clearBtn.title = '清除 localStorage 中保存的属性值并刷新';
+ clearBtn.addEventListener('click', () => {
+ this.clearStorage();
+ location.reload();
+ });
+
+ // 音频可视化
+ const audioBtn = document.createElement('button');
+ audioBtn.className = 'we-tool-btn';
+ audioBtn.textContent = this._audioContext ? '关闭音频可视化' : '启用音频可视化';
+ audioBtn.title = '需要用户授权选择音频源';
+ audioBtn.addEventListener('click', async () => {
+ if (this._audioContext) {
+ this.disableAudioVisualization();
+ audioBtn.textContent = '启用音频可视化';
+ } else {
+ await this.enableAudioVisualization();
+ audioBtn.textContent = this._audioContext
+ ? '关闭音频可视化'
+ : '启用音频可视化';
+ }
+ });
+
+ bar.appendChild(clearBtn);
+ bar.appendChild(audioBtn);
+ return bar;
+ }
+
+ /* --------------------------------------------------------
+ * 单属性控件
+ * -------------------------------------------------------- */
+ 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';
+ 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);
+ case 'file': return this.buildFileRow(row, key, def, label);
+ case 'directory': return this.buildDirectoryRow(row, key, def, label);
+ case 'texture': return this.buildTextureRow(row, key, def, label);
+ case 'usershortcut': return this.buildUserShortcutRow(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';
+ 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);
+
+ 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;
+ }
+
+ buildFileRow(row, key, def, label) {
+ label.htmlFor = key;
+ row.appendChild(label);
+
+ const wrap = document.createElement('div');
+ wrap.className = 'we-file-wrap';
+
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.className = 'we-file-input';
+ input.id = key;
+ input.dataset.weKey = key;
+ input.accept = def.fileType || '';
+
+ const display = document.createElement('div');
+ display.className = 'we-file-display';
+ display.textContent = this.properties[key]?.value || '未选择文件';
+
+ input.addEventListener('change', () => {
+ const file = input.files[0];
+ if (!file) return;
+ const url = URL.createObjectURL(file);
+ display.textContent = file.name;
+ this.updateProperty(key, url);
+ });
+
+ wrap.appendChild(input);
+ wrap.appendChild(display);
+ row.appendChild(wrap);
+ return row;
+ }
+
+ buildDirectoryRow(row, key, def, label) {
+ label.htmlFor = key;
+ row.appendChild(label);
+
+ const wrap = document.createElement('div');
+ wrap.className = 'we-file-wrap';
+
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.className = 'we-file-input';
+ input.id = key;
+ input.dataset.weKey = key;
+ input.webkitdirectory = true;
+ input.multiple = true;
+
+ const display = document.createElement('div');
+ display.className = 'we-file-display';
+ display.textContent = this.properties[key]?.value || '未选择目录';
+
+ input.addEventListener('change', () => {
+ const files = Array.from(input.files);
+ if (files.length === 0) return;
+ const dirPath = files[0].webkitRelativePath.split('/')[0];
+ display.textContent = `${dirPath} (${files.length} 个文件)`;
+ this.updateProperty(key, dirPath);
+
+ // 触发 AddedOrChanged / Removed 事件
+ const newFileList = files.map(f => f.name);
+ const oldFileList = this._directoryFiles.get(key) || [];
+
+ const added = newFileList.filter(f => !oldFileList.includes(f));
+ const removed = oldFileList.filter(f => !newFileList.includes(f));
+
+ if (added.length > 0) {
+ this._emitUserDirectoryEvent(
+ 'userDirectoryFilesAddedOrChanged', key, added
+ );
+ }
+ if (removed.length > 0) {
+ this._emitUserDirectoryEvent(
+ 'userDirectoryFilesRemoved', key, removed
+ );
+ }
+ this._directoryFiles.set(key, newFileList);
+ });
+
+ wrap.appendChild(input);
+ wrap.appendChild(display);
+ row.appendChild(wrap);
+ return row;
+ }
+
+ /**
+ * texture 类型:图像/视频纹理
+ * 浏览器降级:使用 + ObjectURL
+ * WE 原生:返回 file:/// 路径
+ */
+ buildTextureRow(row, key, def, label) {
+ label.htmlFor = key;
+ row.appendChild(label);
+
+ const wrap = document.createElement('div');
+ wrap.className = 'we-file-wrap';
+
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.className = 'we-file-input';
+ input.id = key;
+ input.dataset.weKey = key;
+ input.accept = 'image/*,video/*';
+
+ const display = document.createElement('div');
+ display.className = 'we-file-display';
+ const currentValue = this.properties[key]?.value || '';
+ display.textContent = currentValue
+ ? decodeURIComponent(currentValue.split('/').pop() || '')
+ : '未选择纹理';
+
+ // 小预览
+ const preview = document.createElement('div');
+ preview.className = 'we-texture-preview';
+
+ input.addEventListener('change', () => {
+ const file = input.files[0];
+ if (!file) return;
+ const url = URL.createObjectURL(file);
+ display.textContent = file.name;
+ preview.innerHTML = '';
+ if (file.type.startsWith('video/')) {
+ const v = document.createElement('video');
+ v.src = url;
+ v.muted = true;
+ v.autoplay = true;
+ v.loop = true;
+ preview.appendChild(v);
+ } else {
+ const img = document.createElement('img');
+ img.src = url;
+ img.alt = file.name;
+ preview.appendChild(img);
+ }
+ this.updateProperty(key, url);
+ });
+
+ wrap.appendChild(input);
+ wrap.appendChild(display);
+ wrap.appendChild(preview);
+ row.appendChild(wrap);
+ return row;
+ }
+
+ /**
+ * usershortcut 类型:用户定义的快捷方式
+ * Web 壁纸无法触发,仅作只读展示
+ */
+ buildUserShortcutRow(row, key, def, label) {
+ label.htmlFor = key;
+ row.appendChild(label);
+
+ const info = document.createElement('div');
+ info.className = 'we-field__text';
+ info.textContent = '用户快捷方式(仅 SceneScript 可用,Web 壁纸中不可触发)';
+ info.style.fontStyle = 'italic';
+ info.style.color = '#7a7a88';
+ row.appendChild(info);
+ 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)) {
+ // group 元素的条件
+ const groupEl = this.groupEls.get(key);
+ if (groupEl && def.condition) {
+ const visible = this.conditionEvaluator.evaluate(def.condition);
+ groupEl.classList.toggle('we-field--hidden', !visible);
+ }
+ // 普通字段
+ 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.saveToStorage(name, value);
+
+ // 通知监听器
+ 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
+ );
+ }
+ }
+ }
+
+ /* --------------------------------------------------------
+ * FPS 限制:包装全局 requestAnimationFrame
+ * -------------------------------------------------------- */
+ installGlobalFpsLimiter(fps) {
+ if (this._rafInstalled) return;
+ this._originalRAF = window.requestAnimationFrame;
+ const interval = 1000 / Math.max(1, fps);
+ let lastTime = 0;
+ const originalRAF = this._originalRAF;
+
+ window.requestAnimationFrame = function (callback) {
+ return originalRAF.call(window, function (timestamp) {
+ if (timestamp - lastTime >= interval) {
+ lastTime = timestamp;
+ try {
+ callback(timestamp);
+ } catch (err) {
+ console.error(
+ '[WallpaperEngine] RAF 回调抛错:', err
+ );
+ }
+ }
+ // 注意:这里不再循环调用 RAF
+ // 由调用者自己决定是否继续请求下一帧
+ });
+ };
+
+ this._rafInstalled = true;
+ console.log('[WallpaperEngine] 全局 RAF 已限制为', fps, 'fps');
+ }
+
+ uninstallGlobalFpsLimiter() {
+ if (!this._rafInstalled || !this._originalRAF) return;
+ window.requestAnimationFrame = this._originalRAF;
+ this._rafInstalled = false;
+ console.log('[WallpaperEngine] 全局 RAF 已恢复');
+ }
+
+ setFpsLimit(fps) {
+ this._fps = fps;
+ if (fps > 0 && fps < 1000) {
+ this.installGlobalFpsLimiter(fps);
+ }
+ }
+
+ /* --------------------------------------------------------
+ * 样式
+ * -------------------------------------------------------- */
+ 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;
+}
+
+/* group 折叠容器 */
+.we-group {
+ margin-bottom: 20px;
+ border: 1px solid rgba(255,255,255,0.08);
+ border-radius: 6px;
+ padding: 8px 12px 0;
+ background: rgba(255,255,255,0.02);
+}
+.we-group__summary {
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 600;
+ color: #e6e6ee;
+ padding: 6px 0 8px;
+ user-select: none;
+ list-style: none;
+ position: relative;
+ padding-left: 18px;
+}
+.we-group__summary::-webkit-details-marker { display: none; }
+.we-group__summary::before {
+ content: "▸";
+ position: absolute;
+ left: 0;
+ top: 6px;
+ transition: transform 0.2s;
+ color: var(--we-theme-color);
+}
+.we-group[open] > .we-group__summary::before {
+ transform: rotate(90deg);
+}
+.we-group > .we-field:last-child {
+ margin-bottom: 12px;
+}
+
+/* 开关 */
+.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-file-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.we-file-input {
+ display: none;
+}
+.we-file-display {
+ padding: 9px 11px;
+ background: rgba(255,255,255,0.06);
+ border: 1px dashed rgba(255,255,255,0.2);
+ border-radius: 6px;
+ font-size: 13px;
+ color: #9a9aa8;
+ cursor: pointer;
+ text-align: center;
+ transition: border-color 0.15s, color 0.15s;
+ word-break: break-all;
+}
+.we-file-display:hover {
+ border-color: var(--we-theme-color);
+ color: #f0f0f5;
+}
+.we-texture-preview {
+ border-radius: 6px;
+ overflow: hidden;
+ max-height: 120px;
+ display: flex;
+ justify-content: center;
+ background: rgba(0,0,0,0.2);
+}
+.we-texture-preview:empty { display: none; }
+.we-texture-preview img,
+.we-texture-preview video {
+ max-width: 100%;
+ max-height: 120px;
+ display: block;
+}
+
+/* 底部工具条 */
+.we-tools {
+ margin-top: 24px;
+ padding-top: 16px;
+ border-top: 1px solid rgba(255,255,255,0.08);
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.we-tool-btn {
+ flex: 1;
+ min-width: 120px;
+ padding: 8px 12px;
+ background: rgba(255,255,255,0.06);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 6px;
+ color: #e6e6ee;
+ font-size: 12px;
+ font-family: inherit;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+}
+.we-tool-btn:hover {
+ background: rgba(255,255,255,0.1);
+ border-color: var(--we-theme-color);
+ color: #fff;
+}
+
+/* 滚动条 */
+.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);
+}
+ `;
+ }
+
+ /* --------------------------------------------------------
+ * 全局 API 注入
+ * -------------------------------------------------------- */
+ initializeAPI() {
+ if (!window.wallpaperPropertyListener) {
+ window.wallpaperPropertyListener = {};
+ }
+
+ 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);
+ }
+ };
+ }
+
+ if (!window.wallpaperGetUserDataPath) {
+ window.wallpaperGetUserDataPath = () => this.userDataPath;
+ }
+
+ if (!window.wallpaperRequestFile) {
+ window.wallpaperRequestFile = (filename) => {
+ console.warn(
+ '[WallpaperEngine] wallpaperRequestFile 在浏览器中不可用:',
+ filename
+ );
+ return null;
+ };
+ }
+
+ if (!window.wallpaperRegisterAudioListener) {
+ window.wallpaperRegisterAudioListener = (callback) => {
+ if (typeof callback !== 'function') return;
+ this.audioListeners.push({ applyAudioProperties: callback });
+ };
+ }
+
+ 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) {
+ let lists;
+ switch (method) {
+ case 'applyGeneralProperties':
+ lists = this.generalListeners;
+ break;
+ case 'applyUserProperties':
+ lists = this.propertyListeners;
+ break;
+ case 'setPaused':
+ lists = [...this.generalListeners, ...this.propertyListeners];
+ break;
+ default:
+ lists = this.propertyListeners;
+ }
+ for (const listener of lists) {
+ try {
+ listener[method]?.(payload);
+ } catch (err) {
+ console.error(
+ `[WallpaperEngine] ${method} 抛错:`, err
+ );
+ }
+ }
+ }
+
+ _emitUserDirectoryEvent(eventName, propertyName, files) {
+ const lists = [...this.generalListeners, ...this.propertyListeners];
+ for (const listener of lists) {
+ try {
+ listener[eventName]?.(propertyName, files);
+ } catch (err) {
+ console.error(
+ `[WallpaperEngine] ${eventName} 抛错:`, err
+ );
+ }
+ }
+ }
+
+ /* --------------------------------------------------------
+ * 音频可视化
+ * -------------------------------------------------------- */
+ async enableAudioVisualization() {
+ if (this._audioContext) return;
+ try {
+ const stream = await navigator.mediaDevices.getDisplayMedia({
+ video: true,
+ audio: true
+ });
+ this._audioContext = new AudioContext();
+ const source = this._audioContext.createMediaStreamSource(stream);
+ this._audioAnalyser = this._audioContext.createAnalyser();
+ this._audioAnalyser.fftSize = 256;
+ source.connect(this._audioAnalyser);
+ this._audioData = new Uint8Array(
+ this._audioAnalyser.frequencyBinCount
+ );
+
+ const updateInterval = 1000 / 30;
+ let lastUpdate = 0;
+ const update = (timestamp) => {
+ if (timestamp - lastUpdate >= updateInterval) {
+ lastUpdate = timestamp;
+ this._audioAnalyser.getByteFrequencyData(this._audioData);
+ const normalized = Array.from(this._audioData).map(
+ v => Math.min(1, v / 255)
+ );
+ const left = normalized.slice(0, 64);
+ const right = normalized.slice(64, 128);
+ const audioArray = [...left, ...right];
+ for (const listener of this.audioListeners) {
+ try {
+ listener.applyAudioProperties?.(audioArray);
+ } catch (err) {
+ console.error(
+ '[WallpaperEngine] 音频回调抛错:', err
+ );
+ }
+ }
+ }
+ this._audioLoopId = requestAnimationFrame(update);
+ };
+ this._audioLoopId = requestAnimationFrame(update);
+ console.log('[WallpaperEngine] 音频可视化已启用');
+ } catch (err) {
+ console.warn(
+ '[WallpaperEngine] 音频可视化启用失败(需要用户授权):',
+ err
+ );
+ }
+ }
+
+ disableAudioVisualization() {
+ if (this._audioLoopId) {
+ cancelAnimationFrame(this._audioLoopId);
+ this._audioLoopId = null;
+ }
+ if (this._audioContext) {
+ this._audioContext.close();
+ this._audioContext = null;
+ this._audioAnalyser = null;
+ this._audioData = null;
+ }
+ console.log('[WallpaperEngine] 音频可视化已关闭');
+ }
+
+ /* --------------------------------------------------------
+ * 初始属性推送
+ * WE 行为:加载时先 applyGeneralProperties,再逐条 applyUserProperties
+ * -------------------------------------------------------- */
+ simulateWallpaperEngine() {
+ const run = () => {
+ setTimeout(() => {
+ // 1. 通用属性(FPS)
+ this.setFpsLimit(60);
+ this._emit('applyGeneralProperties', { fps: 60 });
+
+ // 2. 逐条推送用户属性
+ for (const [key, prop] of Object.entries(this.properties)) {
+ const def = this.propertyCache[key];
+ // group / usershortcut 不推送
+ if (def?.type === 'group' || def?.type === 'usershortcut') {
+ continue;
+ }
+ this._emit('applyUserProperties', {
+ [key]: { value: prop.value }
+ });
+ }
+ console.log('[WallpaperEngine] 已推送初始属性到监听器');
+ }, 100);
+ };
+
+ if (document.readyState === 'complete') run();
+ else window.addEventListener('load', run, { once: true });
+ }
+}
+
+/* ============================================================
+ * 启动
+ * ============================================================ */
+function bootstrap() {
+ if (isWallpaperEngineEnvironment()) {
+ console.log(
+ '[WallpaperEngine] 检测到原生 Wallpaper Engine 环境,跳过模拟器'
+ );
+ return;
+ }
+ console.log('[WallpaperEngine] 未检测到原生环境,启动模拟器');
+
+ const simulator = new WallpaperEngineSimulator();
+
+ const check = setInterval(() => {
+ if (simulator.isInitialized) {
+ clearInterval(check);
+ simulator.simulateWallpaperEngine();
+ console.log('[WallpaperEngine] 模拟环境已启动');
+ }
+ }, 50);
+ setTimeout(() => clearInterval(check), 5000);
+
+ // 调试入口
+ window.__WE_SIMULATOR__ = simulator;
+ window.__WE_ENABLE_AUDIO__ = () => simulator.enableAudioVisualization();
+ window.__WE_DISABLE_AUDIO__ = () => simulator.disableAudioVisualization();
+ window.__WE_CLEAR_STORAGE__ = () => simulator.clearStorage();
+ window.__WE_SET_FPS__ = (fps) => simulator.setFpsLimit(fps);
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
+} else {
+ bootstrap();
+}
+
+export default {
+ isWallpaperEngineEnvironment,
+ WallpaperEngineSimulator,
+ WEUtils
+};
\ No newline at end of file