dsh-wallpaper_share 26.8.22-T

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,3466 @@
1
+ import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
2
+ import { execFileSync, spawn } from "node:child_process";
3
+ import { createServer } from "node:http";
4
+ import { dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { EventEmitter } from "node:events";
7
+ import { createHash } from "node:crypto";
8
+ import { deflateSync } from "node:zlib";
9
+ //#region src/scene/SceneCapabilities.ts
10
+ /**
11
+ * Scene renderer 能力探测。
12
+ * 只负责回答"是否存在可用的 renderer"与"assets 是否齐备",
13
+ * 不做任何渲染。被 SceneAdapter 调用并缓存结果。
14
+ */
15
+ /** 内置参考 renderer 的静态标识(真 renderer 通过 [VERSION] 行自报,不走到这里) */
16
+ const REFERENCE_VERSION = "reference-0.1.0";
17
+ /** 内置参考 renderer 支持的格式(RGBA 动画测试画面) */
18
+ const REFERENCE_FORMATS = ["rgba"];
19
+ /** 探测 renderer 二进制是否存在;存在则返回解析后的 {path, args, version} */
20
+ function probeRenderer(config, weDir) {
21
+ if (config.sceneRendererPath.trim() !== "") {
22
+ const path = config.sceneRendererPath.trim();
23
+ if (existsSync(path)) return {
24
+ path,
25
+ args: [],
26
+ version: probeVersion(path)
27
+ };
28
+ return null;
29
+ }
30
+ const ref = resolveReferenceRenderer();
31
+ if (ref !== null && existsSync(ref)) return {
32
+ path: process.execPath,
33
+ args: [ref],
34
+ version: REFERENCE_VERSION
35
+ };
36
+ return null;
37
+ }
38
+ /** 定位随包发布的内置参考 renderer(<包根>/tools/scene-renderer/scene-renderer.mjs,与 lib/ 同级) */
39
+ function resolveReferenceRenderer() {
40
+ try {
41
+ const here = fileURLToPath(import.meta.url);
42
+ return resolve(dirname(here), "..", "tools", "scene-renderer", "scene-renderer.mjs");
43
+ } catch {
44
+ return resolve(process.cwd(), "tools", "scene-renderer", "scene-renderer.mjs");
45
+ }
46
+ }
47
+ /** 尝试读取 renderer 的 --version 输出;失败返回空串(真 renderer 可稍后经 [VERSION] 自报) */
48
+ function probeVersion(path) {
49
+ try {
50
+ const first = execFileSync(path, ["--version"], {
51
+ encoding: "utf8",
52
+ timeout: 3e3,
53
+ windowsHide: true
54
+ }).split(/\r?\n/).find((l) => l.trim() !== "");
55
+ return first !== void 0 ? first.trim().slice(0, 64) : "";
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+ /** 探测 WE engine assets 目录:显式 > 自动 <weDir>/assets */
61
+ function resolveAssetsDir(config, weDir) {
62
+ if (config.wallpaperEngineAssetsDir.trim() !== "") return config.wallpaperEngineAssetsDir.trim();
63
+ return weDir.replace(/[\\/]+$/, "") + "/assets";
64
+ }
65
+ /** 汇总 renderer 能力与 assets 齐备情况(结果被 SceneAdapter 缓存) */
66
+ function detectSceneRenderer(config, weDir) {
67
+ const assetsDir = resolveAssetsDir(config, weDir);
68
+ const assetsFound = existsSync(assetsDir + "/shaders") || existsSync(assetsDir);
69
+ const probe = probeRenderer(config, weDir);
70
+ if (probe === null) return {
71
+ available: false,
72
+ version: "",
73
+ rendererPath: config.sceneRendererPath.trim(),
74
+ bin: "",
75
+ args: [],
76
+ assetsDir,
77
+ assetsFound,
78
+ formats: [],
79
+ reason: "Scene renderer not found:请设置 CONFIG.sceneRendererPath,或安装内置参考 renderer"
80
+ };
81
+ const isReference = probe.version === REFERENCE_VERSION;
82
+ return {
83
+ available: true,
84
+ version: probe.version !== "" ? probe.version : isReference ? REFERENCE_VERSION : "",
85
+ rendererPath: probe.path + (probe.args.length > 0 ? " " + probe.args.join(" ") : ""),
86
+ bin: probe.path,
87
+ args: probe.args,
88
+ assetsDir,
89
+ assetsFound,
90
+ formats: isReference ? REFERENCE_FORMATS : [
91
+ "jpeg",
92
+ "webp",
93
+ "rgba",
94
+ "bgra"
95
+ ]
96
+ };
97
+ }
98
+ /** 计算 scene 指纹:绝对路径 + size + mtime,避免旧 scene 缓存串台 */
99
+ function sceneFingerprint(pkgPath) {
100
+ try {
101
+ const st = statSync(pkgPath);
102
+ return pkgPath + "|" + st.size + "|" + Math.floor(st.mtimeMs);
103
+ } catch {
104
+ return pkgPath + "|unknown";
105
+ }
106
+ }
107
+ //#endregion
108
+ //#region src/scene/SceneProtocol.ts
109
+ /** 反向映射(0..3 → 格式名;未知返回 undefined) */
110
+ const FORMAT_CODE_NAME = {
111
+ 0: "jpeg",
112
+ 1: "webp",
113
+ 2: "rgba",
114
+ 3: "bgra"
115
+ };
116
+ //#endregion
117
+ //#region src/scene/SceneRendererProcess.ts
118
+ /**
119
+ * 单个 scene renderer 子进程的管理:spawn、stdin 命令写入、stdout 帧解析、
120
+ * stderr 日志/[STATUS] 解析、退出/崩溃事件、健康心跳、带超时的优雅停止。
121
+ *
122
+ * stdout 帧格式(renderer → Node):
123
+ * [4B LE uint32 payloadLen][payload]
124
+ * payload = [1B format][4B LE width][4B LE height][编码/像素字节]
125
+ *
126
+ * stdin 命令(Node → renderer):换行分隔 JSON,见 SceneProtocol。
127
+ * stderr:人类日志 + 可选 `[STATUS]{"fps":..,"frame":..}` 心跳行 + `[VERSION]x` 自报版本。
128
+ */
129
+ /** 优雅停止:先发 {"cmd":"stop"},超时后 SIGKILL */
130
+ const STOP_GRACE_MS = 1500;
131
+ var SceneRendererProcess = class extends EventEmitter {
132
+ child = null;
133
+ stdoutBuf = Buffer.alloc(0);
134
+ stderrBuf = "";
135
+ stopped = false;
136
+ stopTimer = null;
137
+ path;
138
+ args;
139
+ logPrefix;
140
+ pid = null;
141
+ lastFrameAt = 0;
142
+ lastFrame = null;
143
+ version = "";
144
+ constructor(opts) {
145
+ super();
146
+ this.path = opts.path;
147
+ this.args = opts.args;
148
+ this.logPrefix = opts.logPrefix ?? "[SceneRenderer]";
149
+ }
150
+ get running() {
151
+ return this.child !== null && this.stopped === false && this.child.exitCode === null;
152
+ }
153
+ start(request) {
154
+ if (this.running) return;
155
+ this.stopped = false;
156
+ this.child = spawn(this.path, this.args, {
157
+ stdio: [
158
+ "pipe",
159
+ "pipe",
160
+ "pipe"
161
+ ],
162
+ windowsHide: true
163
+ });
164
+ this.pid = this.child.pid ?? null;
165
+ this.log(this.logPrefix + " Starting renderer " + this.path + " " + this.args.join(" "));
166
+ this.child.stdout.on("data", (chunk) => this.onStdout(chunk));
167
+ this.child.stderr.on("data", (chunk) => this.onStderr(chunk.toString("utf8")));
168
+ this.child.on("error", (err) => {
169
+ this.log(this.logPrefix + " Renderer spawn error: " + String(err.message ?? err));
170
+ this.emit("exit", null, null);
171
+ });
172
+ this.child.on("exit", (code, signal) => {
173
+ this.log(this.logPrefix + " Renderer exited unexpectedly (code=" + String(code) + ", signal=" + String(signal) + ")");
174
+ this.cleanup();
175
+ this.emit("exit", code, signal);
176
+ });
177
+ this.send({
178
+ cmd: "load",
179
+ ...request
180
+ });
181
+ }
182
+ send(command) {
183
+ if (this.child === null || this.stopped || this.child.stdin.destroyed) return;
184
+ try {
185
+ this.child.stdin.write(JSON.stringify(command) + "\n");
186
+ } catch (err) {
187
+ this.log(this.logPrefix + " stdin write failed: " + String(err.message ?? err));
188
+ }
189
+ }
190
+ /** 优雅停止:先 stop 命令,超时强杀 */
191
+ stop() {
192
+ if (this.child === null) return;
193
+ if (this.stopped) return;
194
+ try {
195
+ this.send({ cmd: "stop" });
196
+ } catch {}
197
+ this.stopped = true;
198
+ this.stopTimer = setTimeout(() => {
199
+ if (this.child !== null && this.child.exitCode === null) {
200
+ this.log(this.logPrefix + " Force-killing renderer after grace timeout");
201
+ try {
202
+ this.child.kill("SIGKILL");
203
+ } catch {}
204
+ }
205
+ }, STOP_GRACE_MS);
206
+ }
207
+ /** 立即强制终止(用于 dispose / 切换壁纸的硬清理) */
208
+ kill() {
209
+ this.stopped = true;
210
+ if (this.stopTimer !== null) {
211
+ clearTimeout(this.stopTimer);
212
+ this.stopTimer = null;
213
+ }
214
+ if (this.child !== null) {
215
+ try {
216
+ this.child.kill("SIGKILL");
217
+ } catch {}
218
+ this.cleanup();
219
+ }
220
+ }
221
+ cleanup() {
222
+ if (this.stopTimer !== null) {
223
+ clearTimeout(this.stopTimer);
224
+ this.stopTimer = null;
225
+ }
226
+ this.child = null;
227
+ this.pid = null;
228
+ this.stdoutBuf = Buffer.alloc(0);
229
+ }
230
+ log(line) {
231
+ this.emit("log", line);
232
+ }
233
+ onStdout(chunk) {
234
+ this.stdoutBuf = Buffer.concat([this.stdoutBuf, chunk]);
235
+ for (;;) {
236
+ if (this.stdoutBuf.length < 4) return;
237
+ const len = this.stdoutBuf.readUInt32LE(0);
238
+ if (len <= 0 || len > 16777216) {
239
+ this.stdoutBuf = this.stdoutBuf.subarray(1);
240
+ continue;
241
+ }
242
+ if (this.stdoutBuf.length < 4 + len) return;
243
+ const payload = this.stdoutBuf.subarray(4, 4 + len);
244
+ this.stdoutBuf = this.stdoutBuf.subarray(4 + len);
245
+ const frame = this.decodePayload(payload);
246
+ if (frame !== null) {
247
+ this.lastFrame = frame;
248
+ this.lastFrameAt = Date.now();
249
+ this.emit("frame", frame);
250
+ }
251
+ }
252
+ }
253
+ decodePayload(payload) {
254
+ if (payload.length < 9) return null;
255
+ const formatCode = payload[0];
256
+ const format = FORMAT_CODE_NAME[formatCode];
257
+ if (format === void 0) return null;
258
+ const width = payload.readUInt32LE(1);
259
+ const height = payload.readUInt32LE(5);
260
+ if (width < 1 || height < 1 || width > 16384 || height > 16384) return null;
261
+ return {
262
+ format,
263
+ width,
264
+ height,
265
+ data: Uint8Array.from(payload.subarray(9)),
266
+ ts: Date.now()
267
+ };
268
+ }
269
+ onStderr(text) {
270
+ this.stderrBuf += text;
271
+ let nl = this.stderrBuf.indexOf("\n");
272
+ while (nl >= 0) {
273
+ const line = this.stderrBuf.slice(0, nl).replace(/\r$/, "");
274
+ this.stderrBuf = this.stderrBuf.slice(nl + 1);
275
+ this.handleStderrLine(line);
276
+ nl = this.stderrBuf.indexOf("\n");
277
+ }
278
+ }
279
+ handleStderrLine(line) {
280
+ if (line.trim() === "") return;
281
+ const statusIdx = line.indexOf("[STATUS]");
282
+ if (statusIdx >= 0) try {
283
+ const json = JSON.parse(line.slice(statusIdx + 8).trim());
284
+ this.emit("status", json);
285
+ return;
286
+ } catch {}
287
+ const verIdx = line.indexOf("[VERSION]");
288
+ if (verIdx >= 0) {
289
+ const v = line.slice(verIdx + 9).trim();
290
+ if (v !== "") this.version = v;
291
+ this.emit("version", this.version);
292
+ return;
293
+ }
294
+ this.emit("log", line.startsWith(this.logPrefix) ? line : this.logPrefix + " " + line);
295
+ }
296
+ };
297
+ //#endregion
298
+ //#region src/scene/SceneWebSocket.ts
299
+ /**
300
+ * 极简 RFC 6455 WebSocket 服务端(仅服务端→浏览器推送二进制帧),
301
+ * 用于把 SceneAdapter 的最新帧广播给 SceneCanvas。
302
+ *
303
+ * 不依赖任何第三方 ws 库:DSH 的 webServer 提供 `registerUpgrade` 路由,
304
+ * 回调收到已协商前的 (req, socket, head),这里完成握手与后续帧收发。
305
+ * 客户端只消费二进制帧(opcode 0x2),服务端只需解析 close/ping/pong。
306
+ *
307
+ * WS 消息负载(Node → 浏览器):`[1B format][4B LE width][4B LE height][payload]`
308
+ */
309
+ /** RFC 6455 握手魔数 GUID */
310
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
311
+ var SceneFrameHub = class {
312
+ clients = /* @__PURE__ */ new Set();
313
+ logFn;
314
+ onClientsChanged;
315
+ constructor(logFn, onClientsChanged) {
316
+ this.logFn = logFn ?? (() => {});
317
+ this.onClientsChanged = onClientsChanged ?? (() => {});
318
+ }
319
+ get clientCount() {
320
+ return this.clients.size;
321
+ }
322
+ /** 作为 webServer.registerUpgrade 的 handler 使用 */
323
+ handleUpgrade = (req, socket, head) => {
324
+ const monitor = this.monitorFromQuery(req.url ?? "");
325
+ if (!this.accept(req, socket)) return;
326
+ const client = {
327
+ socket,
328
+ monitor,
329
+ buffer: Buffer.alloc(0),
330
+ closed: false
331
+ };
332
+ this.clients.add(client);
333
+ this.onClientsChanged(this.clients.size);
334
+ this.logFn("[SceneRenderer] Scene stream client connected (monitor=" + (monitor === "" ? "auto" : monitor) + ", total=" + this.clients.size + ")");
335
+ socket.on("data", (chunk) => this.onData(client, chunk));
336
+ socket.on("error", () => this.drop(client));
337
+ socket.on("close", () => this.drop(client));
338
+ if (head.length > 0) this.onData(client, head);
339
+ };
340
+ /** 广播一帧给匹配 monitor 的客户端(monitor='' 的客户端视为 auto,接受所有) */
341
+ broadcast(monitor, frame) {
342
+ const msg = this.encodeFrameMessage(frame);
343
+ for (const c of this.clients) {
344
+ if (c.closed) continue;
345
+ if (c.monitor !== "" && c.monitor !== monitor) continue;
346
+ try {
347
+ c.socket.write(msg);
348
+ } catch {
349
+ this.drop(c);
350
+ }
351
+ }
352
+ }
353
+ closeAll() {
354
+ for (const c of [...this.clients]) {
355
+ try {
356
+ c.socket.destroy();
357
+ } catch {}
358
+ c.closed = true;
359
+ }
360
+ this.clients.clear();
361
+ }
362
+ monitorFromQuery(url) {
363
+ const m = /[?&]monitor=([^&]+)/.exec(url);
364
+ if (m === null || m[1] === void 0) return "";
365
+ try {
366
+ return decodeURIComponent(m[1]);
367
+ } catch {
368
+ return "";
369
+ }
370
+ }
371
+ accept(req, socket) {
372
+ const key = req.headers["sec-websocket-key"];
373
+ if (typeof key !== "string") {
374
+ socket.destroy();
375
+ return false;
376
+ }
377
+ const accept = createHash("sha1").update(key + WS_GUID).digest("base64");
378
+ try {
379
+ socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n");
380
+ return true;
381
+ } catch {
382
+ socket.destroy();
383
+ return false;
384
+ }
385
+ }
386
+ onData(client, chunk) {
387
+ if (client.closed) return;
388
+ client.buffer = Buffer.concat([client.buffer, chunk]);
389
+ for (;;) {
390
+ const parsed = this.parseClientFrame(client.buffer);
391
+ if (parsed === null) return;
392
+ if (parsed.needMore) return;
393
+ client.buffer = parsed.rest;
394
+ this.handleClientFrame(client, parsed.opcode, parsed.payload);
395
+ }
396
+ }
397
+ parseClientFrame(buf) {
398
+ if (buf.length < 2) return {
399
+ opcode: 0,
400
+ payload: Buffer.alloc(0),
401
+ rest: buf,
402
+ needMore: true
403
+ };
404
+ const b0 = buf[0];
405
+ const b1 = buf[1];
406
+ const opcode = b0 & 15;
407
+ const masked = (b1 & 128) !== 0;
408
+ let len = b1 & 127;
409
+ let offset = 2;
410
+ if (len === 126) {
411
+ if (buf.length < 4) return {
412
+ opcode,
413
+ payload: Buffer.alloc(0),
414
+ rest: buf,
415
+ needMore: true
416
+ };
417
+ len = buf.readUInt16BE(2);
418
+ offset = 4;
419
+ } else if (len === 127) {
420
+ if (buf.length < 10) return {
421
+ opcode,
422
+ payload: Buffer.alloc(0),
423
+ rest: buf,
424
+ needMore: true
425
+ };
426
+ const big = buf.readBigUInt64BE(2);
427
+ if (big > BigInt(16777216)) return null;
428
+ len = Number(big);
429
+ offset = 10;
430
+ }
431
+ let maskKey = null;
432
+ if (masked) {
433
+ if (buf.length < offset + 4) return {
434
+ opcode,
435
+ payload: Buffer.alloc(0),
436
+ rest: buf,
437
+ needMore: true
438
+ };
439
+ maskKey = buf.subarray(offset, offset + 4);
440
+ offset += 4;
441
+ }
442
+ if (buf.length < offset + len) return {
443
+ opcode,
444
+ payload: Buffer.alloc(0),
445
+ rest: buf,
446
+ needMore: true
447
+ };
448
+ let payload = buf.subarray(offset, offset + len);
449
+ if (maskKey !== null) {
450
+ const out = Buffer.alloc(len);
451
+ for (let i = 0; i < len; i++) out[i] = payload[i] ^ maskKey[i & 3];
452
+ payload = out;
453
+ }
454
+ return {
455
+ opcode,
456
+ payload,
457
+ rest: buf.subarray(offset + len),
458
+ needMore: false
459
+ };
460
+ }
461
+ handleClientFrame(client, opcode, payload) {
462
+ if (opcode === 8) {
463
+ try {
464
+ client.socket.write(this.encodeServerFrame(Buffer.alloc(0), 8));
465
+ } catch {}
466
+ this.drop(client);
467
+ return;
468
+ }
469
+ if (opcode === 9) {
470
+ try {
471
+ client.socket.write(this.encodeServerFrame(payload, 10));
472
+ } catch {
473
+ this.drop(client);
474
+ }
475
+ return;
476
+ }
477
+ }
478
+ drop(client) {
479
+ if (client.closed) return;
480
+ client.closed = true;
481
+ this.clients.delete(client);
482
+ try {
483
+ client.socket.destroy();
484
+ } catch {}
485
+ this.onClientsChanged(this.clients.size);
486
+ this.logFn("[SceneRenderer] Scene stream client disconnected (total=" + this.clients.size + ")");
487
+ }
488
+ /** 服务端→客户端二进制帧:opcode 0x2,FIN=1,无掩码 */
489
+ encodeServerFrame(payload, opcode) {
490
+ const len = payload.length;
491
+ let header;
492
+ if (len < 126) {
493
+ header = Buffer.alloc(2);
494
+ header[1] = len;
495
+ } else if (len < 65536) {
496
+ header = Buffer.alloc(4);
497
+ header[1] = 126;
498
+ header.writeUInt16BE(len, 2);
499
+ } else {
500
+ header = Buffer.alloc(10);
501
+ header[1] = 127;
502
+ header.writeBigUInt64BE(BigInt(len), 2);
503
+ }
504
+ header[0] = 128 | opcode;
505
+ return Buffer.concat([header, payload]);
506
+ }
507
+ /** 把 SceneFrame 编码为 WS 二进制消息负载 */
508
+ encodeFrameMessage(frame) {
509
+ const header = Buffer.alloc(9);
510
+ header[0] = frame.format === "jpeg" ? 0 : frame.format === "webp" ? 1 : frame.format === "rgba" ? 2 : 3;
511
+ header.writeUInt32LE(frame.width, 1);
512
+ header.writeUInt32LE(frame.height, 5);
513
+ return this.encodeServerFrame(Buffer.concat([header, Buffer.from(frame.data)]), 2);
514
+ }
515
+ };
516
+ //#endregion
517
+ //#region src/scene/SceneFallback.ts
518
+ function resolveSceneFallback(opts) {
519
+ const { kind, rendererRunning, rendererAvailable, hasTexture, hasPreview, renderMode } = opts;
520
+ if (kind !== "scene") return {
521
+ level: "generic",
522
+ reason: "not a scene wallpaper (" + kind + ")"
523
+ };
524
+ if (renderMode !== "source") return hasPreview ? {
525
+ level: "preview",
526
+ reason: "render mode is preview (static preview image)"
527
+ } : {
528
+ level: "generic",
529
+ reason: "no preview and render mode is preview"
530
+ };
531
+ if (rendererRunning) return {
532
+ level: "renderer",
533
+ reason: "live scene renderer streaming frames"
534
+ };
535
+ if (hasTexture) return {
536
+ level: "texture",
537
+ reason: rendererAvailable ? "renderer not running (crashed/unavailable) → extracted scene texture" : "renderer not found → extracted scene texture"
538
+ };
539
+ if (hasPreview) return {
540
+ level: "preview",
541
+ reason: "no renderer and no extractable texture → preview image"
542
+ };
543
+ return {
544
+ level: "generic",
545
+ reason: "no renderer, no texture, no preview → generic background"
546
+ };
547
+ }
548
+ /** 把状态收敛成一句话日志(供 diag 与 stderr) */
549
+ function describeSceneStatus(status, fallback) {
550
+ if (status === null) return "renderer unavailable → " + fallback.level;
551
+ return status.state + (status.fps !== void 0 ? " @" + status.fps.toFixed(1) + "fps" : "") + " → " + fallback.level;
552
+ }
553
+ //#endregion
554
+ //#region src/scene/SceneAdapter.ts
555
+ /**
556
+ * SceneAdapter —— scene 壁纸动态渲染的统一编排层。
557
+ *
558
+ * 职责(与任务十一致):
559
+ * renderer detection / startup / shutdown、scene.pkg 路径、engine assets 路径、
560
+ * 输出分辨率、FPS、帧传输(→ SceneFrameHub)、renderer 健康检查、自动重启一次、
561
+ * fallback 信号、缓存(指纹)、诊断。
562
+ *
563
+ * 活动模型(按需渲染,避免空转):
564
+ * - 只有「目标显示器是 scene」且「至少有一个浏览器 WS 客户端」时才运行 renderer;
565
+ * - 浏览器切到性能模式 / 关闭同步 / 关闭页面 → WS 断开 → renderer 停止;
566
+ * - 切换壁纸 / 显示器 → 旧 renderer 停止 → 新 renderer 启动;
567
+ * - 崩溃 → 自动重启一次 → 仍失败 → 由浏览器走 texture/preview fallback。
568
+ */
569
+ /** renderer 崩溃后最多自动重启次数 */
570
+ const MAX_RESTARTS = 1;
571
+ /** 无帧心跳超过该毫秒视为 stalled(触发一次重启) */
572
+ const STALL_MS = 4e3;
573
+ /** 崩溃后重启前的退避 */
574
+ const RESTART_DELAY_MS = 500;
575
+ /** 健康轮询间隔 */
576
+ const HEALTH_INTERVAL_MS = 1e3;
577
+ var SceneAdapter = class {
578
+ hub;
579
+ config;
580
+ weDir;
581
+ logFn;
582
+ capabilities = null;
583
+ process = null;
584
+ target = null;
585
+ fingerprint = "";
586
+ status = {
587
+ state: "idle",
588
+ restarts: 0
589
+ };
590
+ restarts = 0;
591
+ disposed = false;
592
+ healthTimer = null;
593
+ constructor(opts) {
594
+ this.config = opts.config;
595
+ this.weDir = opts.weDir;
596
+ this.logFn = opts.log;
597
+ this.capabilities = detectSceneRenderer(opts.config, opts.weDir);
598
+ this.log("[SceneRenderer] " + (this.capabilities.available ? "Renderer found: " + this.capabilities.rendererPath + " (assets " + (this.capabilities.assetsFound ? "ok" : "missing") + ")" : "Renderer not found: " + (this.capabilities.reason ?? "")));
599
+ this.hub = new SceneFrameHub((line) => this.log(line), () => this.syncActivity());
600
+ this.healthTimer = setInterval(() => this.checkHealth(), HEALTH_INTERVAL_MS);
601
+ if (typeof this.healthTimer.unref === "function") this.healthTimer.unref();
602
+ }
603
+ /** 目标显示器/壁纸变化时调用;kind 非 scene 或文件变化会重启 renderer */
604
+ setTarget(target) {
605
+ if (target === null || target.kind !== "scene") {
606
+ if (this.target !== null) {
607
+ this.stopProcess();
608
+ this.target = null;
609
+ this.fingerprint = "";
610
+ this.status = {
611
+ state: "idle",
612
+ restarts: this.restarts
613
+ };
614
+ }
615
+ return;
616
+ }
617
+ const fp = sceneFingerprint(target.file);
618
+ if (this.target !== null && this.target.key === target.key && this.fingerprint === fp) return;
619
+ this.log("[SceneRenderer] Scene changed → restarting renderer (" + target.file + ")");
620
+ this.stopProcess();
621
+ this.target = target;
622
+ this.fingerprint = fp;
623
+ this.restarts = 0;
624
+ this.syncActivity();
625
+ }
626
+ /** 按需启动/停止:目标为 scene 且有客户端才运行 */
627
+ syncActivity() {
628
+ if (this.disposed) return;
629
+ if (this.target === null) return;
630
+ if (this.hub.clientCount > 0) {
631
+ if (this.process === null || !this.process.running) this.start(this.target);
632
+ } else this.stopProcess();
633
+ }
634
+ /** 停止进程但不改变 target(客户端断开时调用,保持可重新启动) */
635
+ stopProcess() {
636
+ if (this.process !== null) {
637
+ this.process.kill();
638
+ this.process = null;
639
+ }
640
+ if (this.status.state !== "idle" && this.status.state !== "stopped") this.status = {
641
+ state: "idle",
642
+ restarts: this.restarts
643
+ };
644
+ }
645
+ /** 显式完全停止(dispose) */
646
+ stop() {
647
+ this.disposed = true;
648
+ this.stopProcess();
649
+ this.target = null;
650
+ this.fingerprint = "";
651
+ this.status = {
652
+ state: "stopped",
653
+ restarts: this.restarts
654
+ };
655
+ }
656
+ /** 切换渲染分辨率(供未来多显示器 / 分辨率调整) */
657
+ resize(width, height) {
658
+ this.config.width = width;
659
+ this.config.height = height;
660
+ if (this.process !== null && this.process.running) this.process.send({
661
+ cmd: "resize",
662
+ width,
663
+ height
664
+ });
665
+ }
666
+ pause() {
667
+ if (this.process !== null && this.process.running) {
668
+ this.process.send({ cmd: "pause" });
669
+ this.status = {
670
+ ...this.status,
671
+ state: "paused"
672
+ };
673
+ }
674
+ }
675
+ resume() {
676
+ if (this.process !== null && this.process.running) {
677
+ this.process.send({ cmd: "resume" });
678
+ this.status = {
679
+ ...this.status,
680
+ state: "running"
681
+ };
682
+ }
683
+ }
684
+ /** 帧 → 广播给浏览器(经 SceneFrameHub) */
685
+ onFrame = (frame) => {
686
+ if (this.target !== null) this.hub.broadcast(this.target.key, frame);
687
+ if (this.status.state !== "running" && this.status.state !== "paused") this.status = {
688
+ state: "running",
689
+ pid: this.process?.pid ?? void 0,
690
+ restarts: this.restarts,
691
+ resolution: this.status.resolution
692
+ };
693
+ };
694
+ start(target) {
695
+ if (this.capabilities === null || !this.capabilities.available) {
696
+ this.status = {
697
+ state: "crashed",
698
+ restarts: this.restarts,
699
+ lastError: this.capabilities?.reason ?? "Renderer not found"
700
+ };
701
+ this.log("[SceneRenderer] Renderer not available, falling back to extracted scene texture");
702
+ return;
703
+ }
704
+ if (!this.capabilities.assetsFound) {
705
+ this.status = {
706
+ state: "crashed",
707
+ restarts: this.restarts,
708
+ lastError: "Wallpaper Engine assets dir missing: " + this.capabilities.assetsDir
709
+ };
710
+ this.log("[SceneRenderer] Assets dir missing (" + this.capabilities.assetsDir + "), falling back to texture");
711
+ return;
712
+ }
713
+ this.status = {
714
+ state: "starting",
715
+ restarts: this.restarts,
716
+ resolution: {
717
+ width: this.config.width,
718
+ height: this.config.height
719
+ }
720
+ };
721
+ this.log("[SceneRenderer] Starting renderer");
722
+ const proc = new SceneRendererProcess({
723
+ path: this.capabilities.bin,
724
+ args: this.capabilities.args
725
+ });
726
+ proc.on("frame", this.onFrame);
727
+ proc.on("status", (s) => this.onStatus(s));
728
+ proc.on("version", (v) => {
729
+ this.log("[SceneRenderer] Renderer version: " + v);
730
+ });
731
+ proc.on("log", (line) => this.log(line));
732
+ proc.on("exit", (code, signal) => this.onExit(code, signal));
733
+ this.process = proc;
734
+ proc.start({
735
+ scene: target.file,
736
+ assets: resolveAssetsDir(this.config, this.weDir),
737
+ width: this.config.width,
738
+ height: this.config.height,
739
+ fps: this.config.fps,
740
+ quality: this.config.quality
741
+ });
742
+ }
743
+ onStatus(s) {
744
+ const fps = typeof s.fps === "number" ? s.fps : this.status.fps;
745
+ const frameIndex = typeof s.frame === "number" ? s.frame : this.status.frameIndex;
746
+ this.status = {
747
+ state: "running",
748
+ pid: this.process?.pid ?? void 0,
749
+ fps,
750
+ frameIndex,
751
+ resolution: this.status.resolution,
752
+ restarts: this.restarts
753
+ };
754
+ }
755
+ onExit(code, signal) {
756
+ if (this.process === null) return;
757
+ this.process = null;
758
+ if (this.disposed || this.hub.clientCount === 0 || this.target === null) {
759
+ this.status = {
760
+ state: "idle",
761
+ restarts: this.restarts
762
+ };
763
+ return;
764
+ }
765
+ this.log("[SceneRenderer] Renderer exited unexpectedly (code=" + String(code) + ", signal=" + String(signal) + ")");
766
+ if (this.restarts < MAX_RESTARTS) {
767
+ this.restarts += 1;
768
+ this.log("[SceneRenderer] Auto-restarting renderer (attempt " + this.restarts + "/1)");
769
+ this.status = {
770
+ state: "starting",
771
+ restarts: this.restarts
772
+ };
773
+ setTimeout(() => {
774
+ if (!this.disposed && this.hub.clientCount > 0 && this.target !== null) this.start(this.target);
775
+ }, RESTART_DELAY_MS);
776
+ } else {
777
+ this.status = {
778
+ state: "crashed",
779
+ restarts: this.restarts,
780
+ lastError: "Renderer crashed after " + this.restarts + " restart(s)"
781
+ };
782
+ this.log("[SceneRenderer] Fallback to extracted scene texture");
783
+ }
784
+ }
785
+ checkHealth() {
786
+ const proc = this.process;
787
+ if (proc === null || !proc.running || this.disposed) return;
788
+ if (proc.lastFrameAt > 0 && Date.now() - proc.lastFrameAt > STALL_MS) {
789
+ this.log("[SceneRenderer] No frame for 4000ms — restarting renderer");
790
+ proc.kill();
791
+ }
792
+ }
793
+ getCapabilities() {
794
+ return this.capabilities;
795
+ }
796
+ /** 当前 renderer 正在渲染的目标(浏览器经 WS 锁定后可能与 auto 显示器不同) */
797
+ getTarget() {
798
+ return this.target;
799
+ }
800
+ getStatus() {
801
+ const s = this.status;
802
+ if (s.pid === void 0 && this.process?.pid != null) s.pid = this.process.pid;
803
+ return s;
804
+ }
805
+ /** 是否正在出帧(浏览器据此决定是否走 live canvas) */
806
+ isRunning() {
807
+ return this.process !== null && this.process.running && this.hub.clientCount > 0;
808
+ }
809
+ getFallback(opts) {
810
+ return resolveSceneFallback({
811
+ kind: opts.kind,
812
+ rendererRunning: this.isRunning(),
813
+ rendererAvailable: this.capabilities?.available === true,
814
+ hasTexture: opts.hasTexture,
815
+ hasPreview: opts.hasPreview,
816
+ renderMode: opts.renderMode
817
+ });
818
+ }
819
+ describe() {
820
+ return describeSceneStatus(this.getStatus(), this.getFallback({
821
+ kind: this.target?.kind ?? "scene",
822
+ hasTexture: false,
823
+ hasPreview: false,
824
+ renderMode: "source"
825
+ }));
826
+ }
827
+ dispose() {
828
+ this.disposed = true;
829
+ if (this.healthTimer !== null) clearInterval(this.healthTimer);
830
+ this.stopProcess();
831
+ this.hub.closeAll();
832
+ }
833
+ log(line) {
834
+ this.logFn(line);
835
+ }
836
+ };
837
+ //#endregion
838
+ //#region src/scene/ScenePkg.ts
839
+ function parseScenePkg(buf) {
840
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
841
+ let pos = 0;
842
+ const readI32 = () => {
843
+ const v = view.getInt32(pos, true);
844
+ pos += 4;
845
+ return v;
846
+ };
847
+ const magicLen = readI32();
848
+ const magic = utf8Slice(buf, pos, pos + Math.max(0, Math.min(magicLen, 64)));
849
+ pos += magicLen;
850
+ const version = readI32();
851
+ const entries = [];
852
+ let guard = 0;
853
+ while (pos + 8 <= buf.length && guard++ < 1e5) {
854
+ const nameLen = readI32();
855
+ if (nameLen <= 0 || nameLen > 2048 || pos + nameLen + 8 > buf.length) break;
856
+ const name = utf8Slice(buf, pos, pos + nameLen);
857
+ pos += nameLen;
858
+ const offset = readI32();
859
+ const size = readI32();
860
+ if (offset < 0 || size < 0 || offset + size > buf.length) break;
861
+ entries.push({
862
+ name,
863
+ offset,
864
+ size
865
+ });
866
+ }
867
+ const dataStart = pos;
868
+ const read = (name) => {
869
+ const e = entries.find((x) => x.name === name);
870
+ if (e === void 0) return null;
871
+ return buf.subarray(dataStart + e.offset, dataStart + e.offset + e.size);
872
+ };
873
+ return {
874
+ magic,
875
+ version,
876
+ entries,
877
+ dataStart,
878
+ read,
879
+ has: (name) => entries.some((x) => x.name === name)
880
+ };
881
+ }
882
+ /** 读取并解析 scene.json(WE 怪癖:缺头括号 + 尾部垃圾) */
883
+ function readSceneJson(pkg) {
884
+ const buf = pkg.read("scene.json");
885
+ if (buf === null) return null;
886
+ try {
887
+ return parseJsonLike(buf);
888
+ } catch {
889
+ return null;
890
+ }
891
+ }
892
+ /** 解析 WE 的"类 JSON"文本:补 { 并截断到最后一个未引用 } */
893
+ function parseJsonLike(buf) {
894
+ const raw = utf8Slice(buf, 0, buf.length);
895
+ const last = lastUnquotedBrace(raw);
896
+ if (last < 0) throw new Error("no closing brace");
897
+ let text = raw.slice(0, last + 1).trim();
898
+ if (!text.startsWith("{")) text = "{" + text;
899
+ return JSON.parse(text);
900
+ }
901
+ function lastUnquotedBrace(text) {
902
+ let inStr = false;
903
+ let esc = false;
904
+ let last = -1;
905
+ for (let i = 0; i < text.length; i++) {
906
+ const c = text[i];
907
+ if (inStr) {
908
+ if (esc) esc = false;
909
+ else if (c === "\\") esc = true;
910
+ else if (c === "\"") inStr = false;
911
+ } else if (c === "\"") inStr = true;
912
+ else if (c === "}") last = i;
913
+ }
914
+ return last;
915
+ }
916
+ function utf8Slice(buf, start, end) {
917
+ let out = "";
918
+ const bytes = buf.subarray(start, end);
919
+ for (let i = 0; i < bytes.length; i++) {
920
+ const b = bytes[i];
921
+ if (b < 128) out += String.fromCharCode(b);
922
+ else if (b >= 192 && b < 224 && i + 1 < bytes.length) {
923
+ out += String.fromCharCode((b & 31) << 6 | bytes[i + 1] & 63);
924
+ i++;
925
+ } else if (b >= 224 && b < 240 && i + 2 < bytes.length) {
926
+ out += String.fromCharCode((b & 15) << 12 | (bytes[i + 1] & 63) << 6 | bytes[i + 2] & 63);
927
+ i += 2;
928
+ } else out += String.fromCharCode(b);
929
+ }
930
+ return out;
931
+ }
932
+ /** 解析 "x y z" 形式的字符串向量(含小数/负数/多余空格),失败返回默认 */
933
+ function parseVec3(text, def = [
934
+ 0,
935
+ 0,
936
+ 0
937
+ ]) {
938
+ if (typeof text !== "string") return def;
939
+ const parts = text.trim().split(/\s+/).map(Number);
940
+ if (parts.length < 2 || parts.some((n) => !Number.isFinite(n))) return def;
941
+ return [
942
+ parts[0] ?? def[0],
943
+ parts[1] ?? def[1],
944
+ parts[2] ?? def[2]
945
+ ];
946
+ }
947
+ function parseVec2(text, def = [0, 0]) {
948
+ if (typeof text !== "string") return def;
949
+ const parts = text.trim().split(/\s+/).map(Number);
950
+ if (parts.length < 2 || parts.some((n) => !Number.isFinite(n))) return def;
951
+ return [parts[0] ?? def[0], parts[1] ?? def[1]];
952
+ }
953
+ /** 解析 visible:true / {user|script, value} / undefined → 默认 true */
954
+ function resolveVisible(v, def = true) {
955
+ if (v === void 0 || v === null) return def;
956
+ if (typeof v === "boolean") return v;
957
+ if (typeof v === "object" && v.value !== void 0) {
958
+ const val = v.value;
959
+ return typeof val === "boolean" ? val : def;
960
+ }
961
+ return def;
962
+ }
963
+ //#endregion
964
+ //#region src/scene/ScenePuppet.ts
965
+ const f32At = (bytes, q) => {
966
+ const v = bytes[q] | bytes[q + 1] << 8 | bytes[q + 2] << 16 | bytes[q + 3] << 24 | 0;
967
+ return new Float32Array(new Int32Array([v]).buffer)[0];
968
+ };
969
+ const u32At = (bytes, q) => {
970
+ return (bytes[q] | bytes[q + 1] << 8 | bytes[q + 2] << 16 | bytes[q + 3] << 24) >>> 0;
971
+ };
972
+ const i32At = (bytes, q) => {
973
+ return bytes[q] | bytes[q + 1] << 8 | bytes[q + 2] << 16 | bytes[q + 3] << 24 | 0;
974
+ };
975
+ const u16At = (bytes, q) => {
976
+ return bytes[q] | bytes[q + 1] << 8;
977
+ };
978
+ /** 解析 mdl;失败返回 null */
979
+ function parsePuppetMdl(bytes) {
980
+ try {
981
+ const len = bytes.length;
982
+ const find = (tag, from) => {
983
+ const t = new Uint8Array(tag.length);
984
+ for (let i = 0; i < tag.length; i++) t[i] = tag.charCodeAt(i);
985
+ let i = from;
986
+ while (i < len - tag.length) {
987
+ let ok = true;
988
+ for (let k = 0; k < tag.length; k++) if (bytes[i + k] !== t[k]) {
989
+ ok = false;
990
+ break;
991
+ }
992
+ if (ok) return i;
993
+ i++;
994
+ }
995
+ return -1;
996
+ };
997
+ if (bytes.length < 16) return null;
998
+ let material = "";
999
+ let p = 0;
1000
+ const magic1 = (() => {
1001
+ let s = "";
1002
+ while (p < len && bytes[p] !== 0 && s.length < 32) {
1003
+ s += String.fromCharCode(bytes[p]);
1004
+ p++;
1005
+ }
1006
+ p++;
1007
+ return s;
1008
+ })();
1009
+ if (!magic1.startsWith("0023") && !magic1.startsWith("0021") && !magic1.startsWith("0020")) return null;
1010
+ p += 12;
1011
+ if (p < len) {
1012
+ let s = "";
1013
+ let q = p;
1014
+ while (q < len && bytes[q] !== 0 && s.length < 4096) {
1015
+ s += String.fromCharCode(bytes[q]);
1016
+ q++;
1017
+ }
1018
+ if (s.includes("\"name\"") || s.includes("{")) material = s;
1019
+ p = q + 1;
1020
+ }
1021
+ const mdls4 = find("MDLS0004", 0);
1022
+ const mdls3 = find("MDLS0003", 0);
1023
+ const mdls = mdls4 >= 0 ? mdls4 : mdls3;
1024
+ const mdlsIs3 = mdls >= 0 && mdls4 < 0;
1025
+ const mdat = find("MDAT0001", 0);
1026
+ const mdla = find("MDLA0006", 0);
1027
+ const mdle = find("MDLE0002", 0);
1028
+ let mesh = null;
1029
+ {
1030
+ const stride = 80;
1031
+ const mdlsOffset = mdls >= 0 ? mdls : len;
1032
+ for (let offset = 9; offset + 12 < mdlsOffset; offset++) {
1033
+ const candidateVertexBytes = u32At(bytes, offset + 4);
1034
+ const verticesOffset = offset + 8;
1035
+ const indexLengthOffset = verticesOffset + candidateVertexBytes;
1036
+ if (candidateVertexBytes === 0 || candidateVertexBytes % stride !== 0 || indexLengthOffset + 4 > mdlsOffset) continue;
1037
+ const candidateIndexBytes = u32At(bytes, indexLengthOffset);
1038
+ const indicesOffset = indexLengthOffset + 4;
1039
+ if (candidateIndexBytes === 0 || candidateIndexBytes % 6 !== 0 || indicesOffset + candidateIndexBytes > mdlsOffset) continue;
1040
+ const vc = candidateVertexBytes / stride;
1041
+ const idxCount = candidateIndexBytes / 2;
1042
+ let valid = true;
1043
+ let minX = Infinity;
1044
+ for (let i = 0; i < vc; i++) {
1045
+ const vp = verticesOffset + i * stride;
1046
+ const x = f32At(bytes, vp);
1047
+ if (!Number.isFinite(x)) {
1048
+ valid = false;
1049
+ break;
1050
+ }
1051
+ if (x < minX) minX = x;
1052
+ }
1053
+ if (!valid || !Number.isFinite(minX)) continue;
1054
+ const maxIdx = vc - 1;
1055
+ for (let i = 0; i < idxCount && valid; i++) if (u16At(bytes, indicesOffset + i * 2) > maxIdx) valid = false;
1056
+ if (!valid) continue;
1057
+ const vertices = [];
1058
+ for (let i = 0; i < vc; i++) {
1059
+ const vp = verticesOffset + i * stride;
1060
+ const weights = [];
1061
+ for (let w = 0; w < 4; w++) weights.push(f32At(bytes, vp + 56 + w * 4));
1062
+ vertices.push({
1063
+ pos: [
1064
+ f32At(bytes, vp),
1065
+ f32At(bytes, vp + 4),
1066
+ f32At(bytes, vp + 8)
1067
+ ],
1068
+ weights,
1069
+ uv: [f32At(bytes, vp + 72), f32At(bytes, vp + 76)]
1070
+ });
1071
+ }
1072
+ const indices = [];
1073
+ for (let i = 0; i < idxCount; i++) indices.push(u16At(bytes, indicesOffset + i * 2));
1074
+ let sy = 0;
1075
+ let sv = 0;
1076
+ let syv = 0;
1077
+ let sy2 = 0;
1078
+ let sv2 = 0;
1079
+ const vn = vertices.length;
1080
+ for (const v of vertices) {
1081
+ const y = v.pos[1];
1082
+ const vv = v.uv[1];
1083
+ sy += y;
1084
+ sv += vv;
1085
+ syv += y * vv;
1086
+ sy2 += y * y;
1087
+ sv2 += vv * vv;
1088
+ }
1089
+ const denom = Math.sqrt(Math.max(1e-9, (vn * sy2 - sy * sy) * (vn * sv2 - sv * sv)));
1090
+ mesh = {
1091
+ vertices,
1092
+ indices,
1093
+ flipV: (vn * syv - sy * sv) / denom > 0
1094
+ };
1095
+ break;
1096
+ }
1097
+ }
1098
+ let boneCount = 0;
1099
+ const mdlsBones = [];
1100
+ if (mdls >= 0 && mdls + 18 + 76 <= len) {
1101
+ boneCount = u32At(bytes, mdls + 13);
1102
+ if (boneCount > 512) boneCount = 0;
1103
+ if (mdlsIs3) {
1104
+ let q = mdls + 17;
1105
+ for (let i = 0; i < boneCount && q + 77 <= len; i++) {
1106
+ const parent = i32At(bytes, q + 5);
1107
+ const mp = q + 13;
1108
+ const bind = [];
1109
+ for (let k = 0; k < 16; k++) bind.push(f32At(bytes, mp + k * 4));
1110
+ mdlsBones.push({
1111
+ parent,
1112
+ bind
1113
+ });
1114
+ let j = mp + 64;
1115
+ while (j < len && bytes[j] !== 0 && j < q + 4096) j++;
1116
+ q = j + 1;
1117
+ }
1118
+ } else {
1119
+ let q = mdls + 18;
1120
+ for (let i = 0; i < boneCount && q + 76 <= len; i++) {
1121
+ const parent = i32At(bytes, q + 4);
1122
+ const mp = q + 12;
1123
+ const bind = [];
1124
+ for (let k = 0; k < 16; k++) bind.push(f32At(bytes, mp + k * 4));
1125
+ mdlsBones.push({
1126
+ parent,
1127
+ bind
1128
+ });
1129
+ q += 76;
1130
+ }
1131
+ }
1132
+ }
1133
+ let poseMatrices = null;
1134
+ if (mdle >= 0 && mdle + 17 + 64 <= len) {
1135
+ const count = u32At(bytes, mdle + 13) / 64;
1136
+ if (count >= 1 && count <= 512 && mdle + 17 + count * 64 <= len) {
1137
+ const mats = [];
1138
+ for (let i = 0; i < count * 16; i++) mats.push(f32At(bytes, mdle + 17 + i * 4));
1139
+ poseMatrices = mats;
1140
+ }
1141
+ }
1142
+ const bonePositions = {};
1143
+ const mdatNames = [];
1144
+ if (mdat >= 0) {
1145
+ const mdatEnd = (() => {
1146
+ let e = len;
1147
+ if (mdla >= 0 && mdla > mdat) e = Math.min(e, mdla);
1148
+ if (mdle >= 0 && mdle > mdat) e = Math.min(e, mdle);
1149
+ return e;
1150
+ })();
1151
+ const mdatCount = u16At(bytes, mdat + 13);
1152
+ if (mdatCount > 0 && mdatCount <= 256) {
1153
+ let q = mdat + 17;
1154
+ for (let i = 0; i < mdatCount && q + 65 <= mdatEnd; i++) {
1155
+ let skips = 0;
1156
+ while (skips < 4 && q < mdatEnd && bytes[q] === 0 && q + 66 <= mdatEnd) {
1157
+ q++;
1158
+ skips++;
1159
+ }
1160
+ let nm = "";
1161
+ let s = q;
1162
+ while (s < mdatEnd && bytes[s] !== 0 && bytes[s] >= 32 && bytes[s] < 127 && nm.length < 128) {
1163
+ nm += String.fromCharCode(bytes[s]);
1164
+ s++;
1165
+ }
1166
+ if (nm.length < 1 || s >= mdatEnd || bytes[s] !== 0) break;
1167
+ const mp = s + 1;
1168
+ if (mp + 64 > mdatEnd) break;
1169
+ mdatNames.push(nm);
1170
+ bonePositions[nm] = [
1171
+ f32At(bytes, mp + 48),
1172
+ f32At(bytes, mp + 52),
1173
+ f32At(bytes, mp + 56)
1174
+ ];
1175
+ q = mp + 64;
1176
+ }
1177
+ } else {
1178
+ let q = mdat + 17;
1179
+ let guard = 0;
1180
+ while (q + 66 <= mdatEnd && guard++ < 256) {
1181
+ let nm = "";
1182
+ let s = q;
1183
+ while (s < mdatEnd && bytes[s] !== 0 && bytes[s] >= 32 && bytes[s] < 127 && nm.length < 128) {
1184
+ nm += String.fromCharCode(bytes[s]);
1185
+ s++;
1186
+ }
1187
+ if (nm.length < 1 || s >= mdatEnd || bytes[s] !== 0) break;
1188
+ const mp = s + 1;
1189
+ if (mp + 64 > mdatEnd) break;
1190
+ mdatNames.push(nm);
1191
+ bonePositions[nm] = [
1192
+ f32At(bytes, mp + 48),
1193
+ f32At(bytes, mp + 52),
1194
+ f32At(bytes, mp + 56)
1195
+ ];
1196
+ q = mp + 64;
1197
+ }
1198
+ }
1199
+ }
1200
+ const bones = [];
1201
+ const total = Math.max(boneCount, mdatNames.length, poseMatrices !== null ? poseMatrices.length / 16 : 0);
1202
+ for (let i = 0; i < total; i++) {
1203
+ const mdlsB = mdlsBones[i];
1204
+ const pose = poseMatrices !== null && i * 16 + 15 < poseMatrices.length ? poseMatrices.slice(i * 16, i * 16 + 16) : null;
1205
+ bones.push({
1206
+ name: mdatNames[i] ?? "",
1207
+ parent: mdlsB !== void 0 ? mdlsB.parent : -1,
1208
+ bind: mdlsB !== void 0 ? mdlsB.bind : null,
1209
+ pose
1210
+ });
1211
+ }
1212
+ const animations = [];
1213
+ if (mdla >= 0 && mdla + 17 <= len) {
1214
+ const animCount = Math.max(0, Math.min(64, u32At(bytes, mdla + 13)));
1215
+ let q = mdla + 17;
1216
+ for (let a = 0; a < animCount && q + 8 <= len; a++) {
1217
+ const id = u32At(bytes, q);
1218
+ q += 4;
1219
+ q += 4;
1220
+ let nm = "";
1221
+ while (q < len && bytes[q] !== 0 && nm.length < 128) {
1222
+ nm += String.fromCharCode(bytes[q]);
1223
+ q++;
1224
+ }
1225
+ q++;
1226
+ let lp = "";
1227
+ while (q < len && bytes[q] !== 0 && lp.length < 128) {
1228
+ lp += String.fromCharCode(bytes[q]);
1229
+ q++;
1230
+ }
1231
+ q++;
1232
+ if (nm === "" || q + 20 > len) break;
1233
+ const duration = f32At(bytes, q);
1234
+ q += 4;
1235
+ const bc = u32At(bytes, q);
1236
+ q += 4;
1237
+ q += 4;
1238
+ q += 4;
1239
+ q += 4;
1240
+ const dataLen = u32At(bytes, q);
1241
+ q += 4;
1242
+ if (dataLen <= 0 || dataLen > len - q) break;
1243
+ q++;
1244
+ const kf = parseKeyframes(bytes, q, dataLen);
1245
+ q += kf.offset + dataLen;
1246
+ animations.push({
1247
+ id,
1248
+ name: nm,
1249
+ loop: lp === "loop",
1250
+ boneCount: bc,
1251
+ duration,
1252
+ keyframes: kf.keyframes
1253
+ });
1254
+ }
1255
+ }
1256
+ return {
1257
+ material,
1258
+ bones,
1259
+ mesh,
1260
+ animations,
1261
+ bonePositions
1262
+ };
1263
+ } catch {
1264
+ return null;
1265
+ }
1266
+ }
1267
+ /**
1268
+ * 解析关键帧数据区:每帧 36B = [t:3B LE][8×f32][1B]。
1269
+ * 偏移探测:尝试 0..8 字节偏移,选质量最高的解析;
1270
+ * 质量 = t 单调峰得分 − 帧值合理性惩罚(NaN/巨大值 = 错位解析)。
1271
+ * 返回探测到的数据起点偏移(供调用方前进游标)。
1272
+ */
1273
+ function parseKeyframes(bytes, dataStart, dataLen) {
1274
+ const len = bytes.length;
1275
+ const frameCount = Math.floor(dataLen / 36);
1276
+ if (frameCount <= 0) return {
1277
+ keyframes: [],
1278
+ offset: 0
1279
+ };
1280
+ let best = [];
1281
+ let bestOff = 0;
1282
+ let bestScore = -Infinity;
1283
+ for (let off = 0; off <= 8 && dataStart + off + 36 <= len; off++) {
1284
+ const kf = [];
1285
+ let bad = false;
1286
+ let penalty = 0;
1287
+ for (let f = 0; f < frameCount; f++) {
1288
+ const fp = dataStart + off + f * 36;
1289
+ if (fp + 36 > len) {
1290
+ bad = true;
1291
+ break;
1292
+ }
1293
+ const t = (bytes[fp] | bytes[fp + 1] << 8 | bytes[fp + 2] << 16) >>> 0;
1294
+ const values = [];
1295
+ for (let k = 0; k < 8; k++) {
1296
+ const v = f32At(bytes, fp + 3 + k * 4);
1297
+ values.push(v);
1298
+ if (!Number.isFinite(v) || Math.abs(v) > 1e7) penalty += 10;
1299
+ else if (Math.abs(v) > 1e5) penalty += 1;
1300
+ }
1301
+ kf.push({
1302
+ t,
1303
+ values
1304
+ });
1305
+ }
1306
+ if (bad) continue;
1307
+ let peak = 0;
1308
+ for (let i = 1; i < kf.length; i++) if (kf[i].t > kf[peak].t) peak = i;
1309
+ let score = 0;
1310
+ for (let i = 1; i <= peak; i++) if (kf[i].t >= kf[i - 1].t) score++;
1311
+ for (let i = peak + 1; i < kf.length; i++) if (kf[i].t <= kf[i - 1].t) score++;
1312
+ let tMin = Infinity;
1313
+ let tMax = -Infinity;
1314
+ for (const k of kf) {
1315
+ if (k.t < tMin) tMin = k.t;
1316
+ if (k.t > tMax) tMax = k.t;
1317
+ }
1318
+ if (tMin === tMax) score -= frameCount * .5;
1319
+ if (score - penalty > bestScore) {
1320
+ bestScore = score - penalty;
1321
+ best = kf;
1322
+ bestOff = off;
1323
+ }
1324
+ }
1325
+ return {
1326
+ keyframes: best,
1327
+ offset: bestOff
1328
+ };
1329
+ }
1330
+ //#endregion
1331
+ //#region src/scene/SceneModel.ts
1332
+ /**
1333
+ * SceneModel —— scene.json + pkg 条目 → 归一化图层模型(Phase 1 最小切片)。
1334
+ *
1335
+ * 纯数据转换(无 IO、无 node API),node 半构建后经 HTTP 交给浏览器渲染;
1336
+ * 类型定义同时供浏览器侧 SceneModelRenderer 使用。
1337
+ *
1338
+ * 覆盖范围(诚实标注):
1339
+ * - 图层树 + transform(origin/angles/scale/parallaxDepth)✅
1340
+ * - visible 解析(bool / {user|script,value})✅
1341
+ * - 模型→材质→纹理引用链解析(best-effort)✅
1342
+ * - 纹理(pkg 内嵌图片 + .tex 容器含 LZ4/DXT)✅(Phase 2a)
1343
+ * - 粒子系统描述解析(发射器/初始化器/算子/渲染器/材质)✅(Phase C)
1344
+ * - keyframe 动画 / shader / SceneScript 渲染:后续
1345
+ */
1346
+ /** 从 scene.pkg 构建归一化图层模型;失败返回 null(调用方走 fallback) */
1347
+ function buildSceneModel(pkgBuf, opts) {
1348
+ const particleRateScale = opts?.particleRateScale ?? 1;
1349
+ const particleSizeScale = opts?.particleSizeScale ?? 1;
1350
+ const effectStrengthScale = opts?.effectStrengthScale ?? 1;
1351
+ const puppetMeshRender = opts?.puppetMeshRender ?? false;
1352
+ let pkg;
1353
+ try {
1354
+ pkg = parseScenePkg(pkgBuf);
1355
+ } catch {
1356
+ return null;
1357
+ }
1358
+ const scene = readSceneJson(pkg);
1359
+ if (scene === null || typeof scene !== "object") return null;
1360
+ const objects = Array.isArray(scene.objects) ? scene.objects : [];
1361
+ const general = scene.general ?? {};
1362
+ const cameraRaw = scene.camera ?? {};
1363
+ const proj = general.orthogonalprojection;
1364
+ const width = toInt(proj?.width, 1920);
1365
+ const height = toInt(proj?.height, 1080);
1366
+ const clearRaw = typeof general.clearcolor === "string" ? general.clearcolor : null;
1367
+ const clearColor = clearRaw !== null ? parseColor3(clearRaw) : null;
1368
+ const camera = {
1369
+ center: parseVec3(cameraRaw.center, [
1370
+ 0,
1371
+ 0,
1372
+ -1
1373
+ ]),
1374
+ eye: parseVec3(cameraRaw.eye, [
1375
+ 0,
1376
+ 0,
1377
+ 0
1378
+ ]),
1379
+ up: parseVec3(cameraRaw.up, [
1380
+ 0,
1381
+ 1,
1382
+ 0
1383
+ ])
1384
+ };
1385
+ const layers = [];
1386
+ for (const o of objects) {
1387
+ const image = typeof o.image === "string" ? o.image : void 0;
1388
+ const kind = resolveKind(o, image);
1389
+ const refs = image !== void 0 ? resolveTextureRefs(pkg, image) : {
1390
+ materials: [],
1391
+ textures: [],
1392
+ decodable: null
1393
+ };
1394
+ const decodable = refs.decodable;
1395
+ const puppet = image !== void 0 ? resolvePuppet(pkg, image) : null;
1396
+ const particle = typeof o.particle === "string" ? resolveParticleSystem(pkg, o.particle, o) : null;
1397
+ layers.push({
1398
+ id: toInt(o.id, 0),
1399
+ name: typeof o.name === "string" ? o.name : "",
1400
+ kind,
1401
+ visible: resolveVisible(o.visible, true),
1402
+ parent: typeof o.parent === "number" ? o.parent : null,
1403
+ size: parseSize(o.size),
1404
+ alpha: numOr(o.alpha, 1),
1405
+ origin: parseVec3(o.origin, [
1406
+ 0,
1407
+ 0,
1408
+ 0
1409
+ ]),
1410
+ angles: parseVec3(o.angles, [
1411
+ 0,
1412
+ 0,
1413
+ 0
1414
+ ]),
1415
+ scale: parseVec3(o.scale, [
1416
+ 1,
1417
+ 1,
1418
+ 1
1419
+ ]),
1420
+ parallaxDepth: parseVec2(o.parallaxDepth, [1, 1]),
1421
+ copybackground: typeof o.copybackground === "boolean" ? o.copybackground : void 0,
1422
+ image,
1423
+ materialRefs: refs.materials,
1424
+ textureRefs: refs.textures,
1425
+ decodableTexture: decodable,
1426
+ puppet,
1427
+ animationIds: parseAnimationIds(o.animationlayers),
1428
+ attachment: typeof o.attachment === "string" ? o.attachment : null,
1429
+ particle,
1430
+ effects: parseLayerEffects(o)
1431
+ });
1432
+ }
1433
+ const textures = [];
1434
+ for (const e of pkg.entries) if (/\.(tex|png|jpe?g)$/i.test(e.name)) textures.push({
1435
+ name: e.name,
1436
+ decodable: /\.(png|jpe?g)$/i.test(e.name),
1437
+ size: e.size
1438
+ });
1439
+ return {
1440
+ width,
1441
+ height,
1442
+ camera,
1443
+ clearColor,
1444
+ layers,
1445
+ textures,
1446
+ layerCount: layers.length,
1447
+ decodableTextureCount: textures.filter((t) => t.decodable).length,
1448
+ particleRateScale,
1449
+ particleSizeScale,
1450
+ effectStrengthScale,
1451
+ puppetMeshRender
1452
+ };
1453
+ }
1454
+ function resolveKind(o, image) {
1455
+ if (image !== void 0) return "image";
1456
+ const keys = Object.keys(o);
1457
+ if (keys.some((k) => /particle/i.test(k))) return "particle";
1458
+ if (keys.some((k) => k === "effect" || k === "effects")) return "effect";
1459
+ return "unknown";
1460
+ }
1461
+ /** 沿 image → material → textures 解析纹理引用链(best-effort,容错) */
1462
+ function resolveTextureRefs(pkg, imagePath) {
1463
+ const materials = [];
1464
+ const textures = [];
1465
+ try {
1466
+ const modelBuf = pkg.read(imagePath);
1467
+ if (modelBuf !== null) {
1468
+ const model = parseJsonLike(modelBuf);
1469
+ if (typeof model.material === "string") {
1470
+ materials.push(model.material);
1471
+ const matBuf = pkg.read(model.material);
1472
+ if (matBuf !== null) {
1473
+ const mat = parseJsonLike(matBuf);
1474
+ if (Array.isArray(mat.passes)) {
1475
+ for (const pass of mat.passes) if (Array.isArray(pass.textures)) for (const t of pass.textures) {
1476
+ if (typeof t !== "string") continue;
1477
+ for (const cand of [
1478
+ "materials/" + t + ".tex",
1479
+ "materials/" + t + ".png",
1480
+ t
1481
+ ]) if (pkg.has(cand)) {
1482
+ textures.push(cand);
1483
+ break;
1484
+ }
1485
+ }
1486
+ }
1487
+ }
1488
+ }
1489
+ }
1490
+ } catch {}
1491
+ return {
1492
+ materials,
1493
+ textures,
1494
+ decodable: textures.find((t) => /\.(png|jpe?g)$/i.test(t)) ?? null
1495
+ };
1496
+ }
1497
+ /** 解析图层尺寸 "w h"(正数);无/非法返回 null */
1498
+ function parseSize(v) {
1499
+ if (typeof v !== "string") return null;
1500
+ const parts = v.trim().split(/\s+/).map(Number);
1501
+ if (parts.length < 2 || !parts.slice(0, 2).every((n) => Number.isFinite(n) && n > 0)) return null;
1502
+ return [parts[0], parts[1]];
1503
+ }
1504
+ /** 解析 animationlayers → 动画 id 列表 */
1505
+ function parseAnimationIds(v) {
1506
+ if (!Array.isArray(v)) return [];
1507
+ const ids = [];
1508
+ for (const a of v) if (a !== null && typeof a === "object" && typeof a.animation === "number") ids.push(a.animation);
1509
+ return ids;
1510
+ }
1511
+ /** 解析 puppet 骨骼模型:模型 json 的 puppet 字段 → _puppet.mdl → PuppetModel */
1512
+ function resolvePuppet(pkg, imagePath) {
1513
+ try {
1514
+ const modelBuf = pkg.read(imagePath);
1515
+ if (modelBuf === null) return null;
1516
+ const model = parseJsonLike(modelBuf);
1517
+ if (typeof model.puppet !== "string") return null;
1518
+ const mdlBuf = pkg.read(model.puppet);
1519
+ if (mdlBuf === null) return null;
1520
+ return parsePuppetMdl(mdlBuf);
1521
+ } catch {
1522
+ return null;
1523
+ }
1524
+ }
1525
+ function parseColor3(text) {
1526
+ const parts = text.trim().split(/\s+/).map(Number);
1527
+ if (parts.length < 3 || parts.some((n) => !Number.isFinite(n))) return [
1528
+ 0,
1529
+ 0,
1530
+ 0
1531
+ ];
1532
+ const scale = Math.max(...parts) > 1.01 ? 1 / 255 : 1;
1533
+ return [
1534
+ parts[0] * scale,
1535
+ parts[1] * scale,
1536
+ parts[2] * scale
1537
+ ];
1538
+ }
1539
+ /** 解析图层效果(o.effects:waterwaves/shake/opacity/bloom,取 passes[0].constantshadervalues)。
1540
+ * visible 语义:布尔 false / value:false → 不应用;SceneScript 脚本控制(无明确 value)→
1541
+ * 无法评估,默认不应用(如 3151551777 的 shownight 条件效果);缺失或 true → 应用。 */
1542
+ function parseLayerEffects(o) {
1543
+ const raw = o.effects;
1544
+ if (!Array.isArray(raw)) return [];
1545
+ const out = [];
1546
+ for (const e of raw) {
1547
+ if (e === null || typeof e !== "object") continue;
1548
+ const obj = e;
1549
+ const vis = obj.visible;
1550
+ if (typeof vis === "boolean") {
1551
+ if (!vis) continue;
1552
+ } else if (vis !== null && typeof vis === "object") {
1553
+ const vobj = vis;
1554
+ if (vobj.script !== void 0) continue;
1555
+ const vval = vobj.value;
1556
+ if (typeof vval === "boolean" && !vval) continue;
1557
+ }
1558
+ const file = typeof obj.file === "string" ? obj.file : "";
1559
+ const pass0 = (Array.isArray(obj.passes) ? obj.passes : [])[0] ?? {};
1560
+ const csv = pass0.constantshadervalues ?? {};
1561
+ const textures = Array.isArray(pass0.textures) ? pass0.textures : [];
1562
+ const mask = textures.length > 1 && typeof textures[1] === "string" && textures[1] !== "" ? textures[1] : null;
1563
+ const n = (v, d) => {
1564
+ const x = Number(v);
1565
+ return Number.isFinite(x) ? x : d;
1566
+ };
1567
+ const v2 = (v, d) => {
1568
+ if (typeof v === "string") {
1569
+ const p = v.trim().split(/\s+/).map(Number);
1570
+ if (p.length >= 2 && p.every((x) => Number.isFinite(x))) return [p[0], p[1]];
1571
+ }
1572
+ return d;
1573
+ };
1574
+ if (file.includes("waterwaves")) out.push({
1575
+ type: "waterwaves",
1576
+ direction: n(csv.direction, 0),
1577
+ speed: n(csv.speed, 5),
1578
+ scale: n(csv.scale, 200),
1579
+ strength: n(csv.strength, .1),
1580
+ exponent: n(csv.exponent, 1),
1581
+ mask
1582
+ });
1583
+ else if (file.includes("shake")) out.push({
1584
+ type: "shake",
1585
+ bounds: v2(csv.bounds, [0, 1]),
1586
+ friction: v2(csv.friction, [1, 1]),
1587
+ speed: n(csv.speed, 1),
1588
+ strength: n(csv.strength, .1),
1589
+ mask
1590
+ });
1591
+ else if (file.includes("opacity")) out.push({
1592
+ type: "opacity",
1593
+ alpha: n(csv.alpha, 1)
1594
+ });
1595
+ else if (file.includes("bloom")) out.push({
1596
+ type: "bloom",
1597
+ gamma: n(csv.gamma, 1),
1598
+ opacity: n(csv.opacity, 1),
1599
+ radius: n(csv.radius, 5),
1600
+ strength: n(csv.strength, .3),
1601
+ threshold: n(csv.threshold, 0)
1602
+ });
1603
+ else out.push({ type: "unknown" });
1604
+ }
1605
+ return out;
1606
+ }
1607
+ /** 解析粒子预设(particles/*.json)→ 归一化粒子系统描述(best-effort 容错) */
1608
+ function resolveParticleSystem(pkg, ref, obj) {
1609
+ try {
1610
+ const buf = pkg.read(ref);
1611
+ if (buf === null) return null;
1612
+ const preset = parseJsonLike(buf);
1613
+ const override = obj.instanceoverride ?? {};
1614
+ const matRef = typeof preset.material === "string" ? preset.material : "";
1615
+ const textureNames = [];
1616
+ let blending = "translucent";
1617
+ let overbright = 1;
1618
+ if (matRef !== "") try {
1619
+ const mat = parseJsonLike(pkg.read(matRef));
1620
+ if (Array.isArray(mat.passes)) for (const pass of mat.passes) {
1621
+ if (typeof pass.blending === "string" && pass.blending !== "") blending = pass.blending;
1622
+ const csv = pass.constantshadervalues;
1623
+ if (csv !== void 0 && typeof csv === "object") {
1624
+ const ob = Number(csv.ui_editor_properties_overbright);
1625
+ if (Number.isFinite(ob) && ob > 0) overbright = ob;
1626
+ }
1627
+ if (Array.isArray(pass.textures)) {
1628
+ for (const t of pass.textures) if (typeof t === "string" && !textureNames.includes(t)) textureNames.push(t);
1629
+ }
1630
+ }
1631
+ } catch {}
1632
+ const em = (Array.isArray(preset.emitter) ? preset.emitter : [])[0] ?? {};
1633
+ const maxcount = toInt(preset.maxcount, 40);
1634
+ const rate = em.rate !== void 0 ? numOr(em.rate, 1) : override.rate !== void 0 ? numOr(override.rate, 1) : Math.max(1, Math.round(maxcount / 15));
1635
+ const emitter = {
1636
+ type: typeof em.name === "string" ? em.name : "sphererandom",
1637
+ rate,
1638
+ directions: parseVec3(em.directions, [
1639
+ 1,
1640
+ 1,
1641
+ 0
1642
+ ]),
1643
+ distanceMin: numOr(em.distancemin, 0),
1644
+ distanceMax: typeof em.distancemax === "string" && em.distancemax.includes(" ") ? parseVec3(em.distancemax, [
1645
+ 1,
1646
+ 1,
1647
+ 1
1648
+ ]) : numOr(em.distancemax, 1),
1649
+ origin: parseVec3(em.origin, [
1650
+ 0,
1651
+ 0,
1652
+ 0
1653
+ ])
1654
+ };
1655
+ const initializers = {};
1656
+ const ops = {};
1657
+ const inits = Array.isArray(preset.initializer) ? preset.initializer : [];
1658
+ for (const init of inits) {
1659
+ const name = typeof init.name === "string" ? init.name : "";
1660
+ const mn = numOr(init.min, 0);
1661
+ const mx = numOr(init.max, 0);
1662
+ if (name === "lifetimerandom") initializers.lifetime = [mn, mx];
1663
+ else if (name === "sizerandom") {
1664
+ initializers.size = [mn, mx];
1665
+ initializers.sizeExponent = numOr(init.exponent, 1);
1666
+ } else if (name === "alpharandom") {
1667
+ initializers.alphaMin = mn;
1668
+ initializers.alphaMax = mx;
1669
+ } else if (name === "velocityrandom") {
1670
+ initializers.velocityMin = parseVec3(init.min, [
1671
+ 0,
1672
+ 0,
1673
+ 0
1674
+ ]);
1675
+ initializers.velocityMax = parseVec3(init.max, [
1676
+ 0,
1677
+ 0,
1678
+ 0
1679
+ ]);
1680
+ } else if (name === "colorrandom") {
1681
+ const cmn = parseVec3(init.min, [
1682
+ 1,
1683
+ 1,
1684
+ 1
1685
+ ]);
1686
+ initializers.colorMin = cmn;
1687
+ initializers.colorMax = init.max !== void 0 ? parseVec3(init.max, cmn) : cmn;
1688
+ } else if (name === "turbulentvelocityrandom") initializers.turbulentVelocity = {
1689
+ offset: numOr(init.offset, -.5),
1690
+ scale: numOr(init.scale, .1)
1691
+ };
1692
+ else if (name === "rotationrandom") initializers.rotation = [mn, mx !== 0 ? mx : Math.PI * 2];
1693
+ else if (name === "angularvelocityrandom") {
1694
+ const v = parseVec3(init.min, [
1695
+ 0,
1696
+ 0,
1697
+ 0
1698
+ ]);
1699
+ const w = parseVec3(init.max, [
1700
+ 0,
1701
+ 0,
1702
+ 0
1703
+ ]);
1704
+ initializers.angularVelocity = [v[2], w[2]];
1705
+ }
1706
+ }
1707
+ const operators = Array.isArray(preset.operator) ? preset.operator : [];
1708
+ for (const op of operators) {
1709
+ const name = typeof op.name === "string" ? op.name : "";
1710
+ if (name === "movement") {
1711
+ ops.gravity = parseVec3(op.gravity, [
1712
+ 0,
1713
+ 0,
1714
+ 0
1715
+ ]);
1716
+ ops.drag = numOr(op.drag, 0);
1717
+ } else if (name === "angularmovement") {
1718
+ ops.angularDrag = numOr(op.drag, 0);
1719
+ ops.angularForce = parseVec3(op.force, [
1720
+ 0,
1721
+ 0,
1722
+ 0
1723
+ ]);
1724
+ } else if (name === "alphafade") ops.alphaFade = {
1725
+ fadeIn: numOr(op.fadeintime, 0),
1726
+ fadeOut: numOr(op.fadeouttime, 0)
1727
+ };
1728
+ else if (name === "turbulence") ops.turbulence = {
1729
+ scale: numOr(op.scale, .002),
1730
+ speedMin: numOr(op.speedmin, 100),
1731
+ speedMax: numOr(op.speedmax, 150),
1732
+ phaseMax: numOr(op.phasemax, 5),
1733
+ mask: typeof op.mask === "string" ? op.mask : "1 0 0"
1734
+ };
1735
+ else if (name === "oscillatealpha") ops.oscillateAlpha = {
1736
+ frequencyMax: numOr(op.frequencymax, 20),
1737
+ scaleMin: numOr(op.scalemin, .7)
1738
+ };
1739
+ else if (name === "oscillateposition") ops.oscillatePosition = {
1740
+ frequencyMin: numOr(op.frequencymin, 1),
1741
+ frequencyMax: numOr(op.frequencymax, 1),
1742
+ scaleMin: numOr(op.scalemin, 1),
1743
+ scaleMax: numOr(op.scalemax, 1),
1744
+ mask: parseVec3(op.mask, [
1745
+ 1,
1746
+ 1,
1747
+ 0
1748
+ ])
1749
+ };
1750
+ else if (name === "sizechange") (ops.sizeChanges ??= []).push({
1751
+ startTime: numOr(op.starttime, 0),
1752
+ endTime: op.endtime !== void 0 ? numOr(op.endtime, 1) : void 0,
1753
+ startValue: numOr(op.startvalue, 1),
1754
+ endValue: numOr(op.endvalue, 1)
1755
+ });
1756
+ }
1757
+ const rd = (Array.isArray(preset.renderer) ? preset.renderer : [])[0] ?? {};
1758
+ const renderer = {
1759
+ type: typeof rd.name === "string" ? rd.name : "sprite",
1760
+ length: numOr(rd.length, void 0)
1761
+ };
1762
+ const children = [];
1763
+ if (Array.isArray(preset.children)) {
1764
+ for (const c of preset.children) if (c !== null && typeof c === "object" && typeof c.name === "string") {
1765
+ const child = resolveParticleSystem(pkg, c.name, obj);
1766
+ if (child !== null) children.push(child);
1767
+ }
1768
+ }
1769
+ if (typeof override.colorn === "string") {
1770
+ const c = parseColor3(override.colorn);
1771
+ initializers.colorMin = c;
1772
+ initializers.colorMax = c;
1773
+ }
1774
+ if (typeof override.alpha === "number") {
1775
+ initializers.alphaMin = override.alpha;
1776
+ initializers.alphaMax = override.alpha;
1777
+ }
1778
+ if (typeof override.lifetime === "number") initializers.lifetime = [override.lifetime, override.lifetime];
1779
+ if (typeof override.size === "number") initializers.size = [override.size, override.size];
1780
+ if (typeof override.count === "number") {}
1781
+ return {
1782
+ particleRef: ref,
1783
+ materialRef: matRef,
1784
+ blending,
1785
+ overbright,
1786
+ textureNames,
1787
+ maxCount: maxcount,
1788
+ startTime: numOr(preset.starttime, 0),
1789
+ emitter,
1790
+ initializers,
1791
+ operators: ops,
1792
+ renderer,
1793
+ children
1794
+ };
1795
+ } catch {
1796
+ return null;
1797
+ }
1798
+ }
1799
+ function numOr(v, def) {
1800
+ if (v === void 0) return def;
1801
+ const n = Number(v);
1802
+ return Number.isFinite(n) ? n : def;
1803
+ }
1804
+ function toInt(v, def) {
1805
+ const n = Number(v);
1806
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : def;
1807
+ }
1808
+ //#endregion
1809
+ //#region src/scene/TexDecode.ts
1810
+ /** 从 4×4 块解码一整个 mip(宽高任意,块不足补边处按块边界取整) */
1811
+ function decodeMip(format, data, dataOffset, width, height) {
1812
+ const out = new Uint8ClampedArray(width * height * 4);
1813
+ switch (format) {
1814
+ case "rgba8888":
1815
+ for (let i = 0; i < width * height; i++) {
1816
+ const s = dataOffset + i * 4;
1817
+ out[i * 4] = data[s];
1818
+ out[i * 4 + 1] = data[s + 1];
1819
+ out[i * 4 + 2] = data[s + 2];
1820
+ out[i * 4 + 3] = data[s + 3];
1821
+ }
1822
+ return out;
1823
+ case "rgba16f":
1824
+ for (let i = 0; i < width * height; i++) {
1825
+ const s = dataOffset + i * 8;
1826
+ out[i * 4] = halfToByte(data, s);
1827
+ out[i * 4 + 1] = halfToByte(data, s + 2);
1828
+ out[i * 4 + 2] = halfToByte(data, s + 4);
1829
+ out[i * 4 + 3] = halfToByte(data, s + 6);
1830
+ }
1831
+ return out;
1832
+ case "r16f":
1833
+ for (let i = 0; i < width * height; i++) {
1834
+ const v = halfToByte(data, dataOffset + i * 2);
1835
+ out[i * 4] = v;
1836
+ out[i * 4 + 1] = v;
1837
+ out[i * 4 + 2] = v;
1838
+ out[i * 4 + 3] = 255;
1839
+ }
1840
+ return out;
1841
+ case "l8":
1842
+ for (let i = 0; i < width * height; i++) {
1843
+ const v = data[dataOffset + i];
1844
+ out[i * 4] = v;
1845
+ out[i * 4 + 1] = v;
1846
+ out[i * 4 + 2] = v;
1847
+ out[i * 4 + 3] = 255;
1848
+ }
1849
+ return out;
1850
+ case "r8":
1851
+ for (let i = 0; i < width * height; i++) {
1852
+ const v = data[dataOffset + i];
1853
+ out[i * 4] = 255;
1854
+ out[i * 4 + 1] = 255;
1855
+ out[i * 4 + 2] = 255;
1856
+ out[i * 4 + 3] = v;
1857
+ }
1858
+ return out;
1859
+ case "rg88":
1860
+ for (let i = 0; i < width * height; i++) {
1861
+ const r = data[dataOffset + i * 2];
1862
+ const g = data[dataOffset + i * 2 + 1];
1863
+ out[i * 4] = r;
1864
+ out[i * 4 + 1] = r;
1865
+ out[i * 4 + 2] = r;
1866
+ out[i * 4 + 3] = g;
1867
+ }
1868
+ return out;
1869
+ case "dxt1": {
1870
+ const bw = Math.max(1, Math.ceil(width / 4));
1871
+ const bh = Math.max(1, Math.ceil(height / 4));
1872
+ for (let by = 0; by < bh; by++) for (let bx = 0; bx < bw; bx++) {
1873
+ const blockOffset = dataOffset + (by * bw + bx) * 8;
1874
+ if (blockOffset + 8 > data.length) break;
1875
+ decodeDxt1Block(data, blockOffset, out, bx * 4, by * 4, width, height);
1876
+ }
1877
+ return out;
1878
+ }
1879
+ case "dxt5": {
1880
+ const bw = Math.max(1, Math.ceil(width / 4));
1881
+ const bh = Math.max(1, Math.ceil(height / 4));
1882
+ for (let by = 0; by < bh; by++) for (let bx = 0; bx < bw; bx++) {
1883
+ const blockOffset = dataOffset + (by * bw + bx) * 16;
1884
+ if (blockOffset + 16 > data.length) break;
1885
+ decodeDxt5Block(data, blockOffset, out, bx * 4, by * 4, width, height);
1886
+ }
1887
+ return out;
1888
+ }
1889
+ case "dxt3": {
1890
+ const bw = Math.max(1, Math.ceil(width / 4));
1891
+ const bh = Math.max(1, Math.ceil(height / 4));
1892
+ for (let by = 0; by < bh; by++) for (let bx = 0; bx < bw; bx++) {
1893
+ const blockOffset = dataOffset + (by * bw + bx) * 16;
1894
+ if (blockOffset + 16 > data.length) break;
1895
+ decodeDxt3Block(data, blockOffset, out, bx * 4, by * 4, width, height);
1896
+ }
1897
+ return out;
1898
+ }
1899
+ case "bc7": throw new Error("BC7 decode not implemented yet");
1900
+ }
1901
+ }
1902
+ /** 单个 DXT1 块(8 字节)解码到输出(px,py 为该块左上角) */
1903
+ function decodeDxt1Block(data, off, out, px, py, width, height) {
1904
+ const c0v = data[off] | data[off + 1] << 8;
1905
+ const c1v = data[off + 2] | data[off + 3] << 8;
1906
+ const c0 = rgb565(c0v);
1907
+ const c1 = rgb565(c1v);
1908
+ let c2;
1909
+ let c3;
1910
+ let transparent = false;
1911
+ if (c0v > c1v) {
1912
+ c2 = [
1913
+ Math.round((2 * c0[0] + c1[0]) / 3),
1914
+ Math.round((2 * c0[1] + c1[1]) / 3),
1915
+ Math.round((2 * c0[2] + c1[2]) / 3)
1916
+ ];
1917
+ c3 = [
1918
+ Math.round((c0[0] + 2 * c1[0]) / 3),
1919
+ Math.round((c0[1] + 2 * c1[1]) / 3),
1920
+ Math.round((c0[2] + 2 * c1[2]) / 3)
1921
+ ];
1922
+ } else {
1923
+ c2 = [
1924
+ Math.round((c0[0] + c1[0]) / 2),
1925
+ Math.round((c0[1] + c1[1]) / 2),
1926
+ Math.round((c0[2] + c1[2]) / 2)
1927
+ ];
1928
+ c3 = [
1929
+ 0,
1930
+ 0,
1931
+ 0
1932
+ ];
1933
+ transparent = true;
1934
+ }
1935
+ for (let y = 0; y < 4; y++) {
1936
+ const row = data[off + 4 + y];
1937
+ for (let x = 0; x < 4; x++) {
1938
+ const idx = row >> x * 2 & 3;
1939
+ const tx = px + x;
1940
+ const ty = py + y;
1941
+ if (tx >= width || ty >= height) continue;
1942
+ let r, g, b, a = 255;
1943
+ if (idx === 0) {
1944
+ r = c0[0];
1945
+ g = c0[1];
1946
+ b = c0[2];
1947
+ } else if (idx === 1) {
1948
+ r = c1[0];
1949
+ g = c1[1];
1950
+ b = c1[2];
1951
+ } else if (idx === 2) {
1952
+ r = c2[0];
1953
+ g = c2[1];
1954
+ b = c2[2];
1955
+ } else {
1956
+ r = c3[0];
1957
+ g = c3[1];
1958
+ b = c3[2];
1959
+ if (transparent) a = 0;
1960
+ }
1961
+ const o = (ty * width + tx) * 4;
1962
+ out[o] = r;
1963
+ out[o + 1] = g;
1964
+ out[o + 2] = b;
1965
+ out[o + 3] = a;
1966
+ }
1967
+ }
1968
+ }
1969
+ /** 单个 DXT5 块(16 字节)解码 */
1970
+ function decodeDxt5Block(data, off, out, px, py, width, height) {
1971
+ const a0 = data[off];
1972
+ const a1 = data[off + 1];
1973
+ const alphas = [a0, a1];
1974
+ if (a0 > a1) for (let i = 2; i < 8; i++) alphas[i] = Math.round(((8 - i) * a0 + (i - 1) * a1) / 7);
1975
+ else {
1976
+ for (let i = 2; i < 6; i++) alphas[i] = Math.round(((6 - i) * a0 + (i - 1) * a1) / 5);
1977
+ alphas[6] = 0;
1978
+ alphas[7] = 255;
1979
+ }
1980
+ const alphaIdx = [];
1981
+ for (let p = 0; p < 16; p++) {
1982
+ const bit = p * 3;
1983
+ const byte = off + 2 + (bit >> 3);
1984
+ const shift = bit & 7;
1985
+ const lo = data[byte];
1986
+ const hi = byte + 1 < data.length ? data[byte + 1] : 0;
1987
+ alphaIdx.push((lo | hi << 8) >> shift & 7);
1988
+ }
1989
+ const c0v = data[off + 8] | data[off + 9] << 8;
1990
+ const c1v = data[off + 10] | data[off + 11] << 8;
1991
+ const c0 = rgb565(c0v);
1992
+ const c1 = rgb565(c1v);
1993
+ const c2 = [
1994
+ Math.round((2 * c0[0] + c1[0]) / 3),
1995
+ Math.round((2 * c0[1] + c1[1]) / 3),
1996
+ Math.round((2 * c0[2] + c1[2]) / 3)
1997
+ ];
1998
+ const c3 = [
1999
+ Math.round((c0[0] + 2 * c1[0]) / 3),
2000
+ Math.round((c0[1] + 2 * c1[1]) / 3),
2001
+ Math.round((c0[2] + 2 * c1[2]) / 3)
2002
+ ];
2003
+ for (let y = 0; y < 4; y++) {
2004
+ const row = data[off + 12 + y];
2005
+ for (let x = 0; x < 4; x++) {
2006
+ const ci = row >> x * 2 & 3;
2007
+ const tx = px + x;
2008
+ const ty = py + y;
2009
+ if (tx >= width || ty >= height) continue;
2010
+ let r, g, b;
2011
+ if (ci === 0) {
2012
+ r = c0[0];
2013
+ g = c0[1];
2014
+ b = c0[2];
2015
+ } else if (ci === 1) {
2016
+ r = c1[0];
2017
+ g = c1[1];
2018
+ b = c1[2];
2019
+ } else if (ci === 2) {
2020
+ r = c2[0];
2021
+ g = c2[1];
2022
+ b = c2[2];
2023
+ } else {
2024
+ r = c3[0];
2025
+ g = c3[1];
2026
+ b = c3[2];
2027
+ }
2028
+ const o = (ty * width + tx) * 4;
2029
+ const a = alphas[alphaIdx[y * 4 + x]];
2030
+ out[o] = r;
2031
+ out[o + 1] = g;
2032
+ out[o + 2] = b;
2033
+ out[o + 3] = a;
2034
+ }
2035
+ }
2036
+ }
2037
+ /** 单个 DXT3 块(16 字节):8 字节 4-bit alpha + DXT1 4-color 颜色 */
2038
+ function decodeDxt3Block(data, off, out, px, py, width, height) {
2039
+ const c0v = data[off + 8] | data[off + 9] << 8;
2040
+ const c1v = data[off + 10] | data[off + 11] << 8;
2041
+ const c0 = rgb565(c0v);
2042
+ const c1 = rgb565(c1v);
2043
+ const c2 = [
2044
+ Math.round((2 * c0[0] + c1[0]) / 3),
2045
+ Math.round((2 * c0[1] + c1[1]) / 3),
2046
+ Math.round((2 * c0[2] + c1[2]) / 3)
2047
+ ];
2048
+ const c3 = [
2049
+ Math.round((c0[0] + 2 * c1[0]) / 3),
2050
+ Math.round((c0[1] + 2 * c1[1]) / 3),
2051
+ Math.round((c0[2] + 2 * c1[2]) / 3)
2052
+ ];
2053
+ for (let y = 0; y < 4; y++) {
2054
+ const row = data[off + 12 + y];
2055
+ const alphaRow = data[off + y * 2] | data[off + y * 2 + 1] << 8;
2056
+ for (let x = 0; x < 4; x++) {
2057
+ const ci = row >> x * 2 & 3;
2058
+ const tx = px + x;
2059
+ const ty = py + y;
2060
+ if (tx >= width || ty >= height) continue;
2061
+ let r, g, b;
2062
+ if (ci === 0) {
2063
+ r = c0[0];
2064
+ g = c0[1];
2065
+ b = c0[2];
2066
+ } else if (ci === 1) {
2067
+ r = c1[0];
2068
+ g = c1[1];
2069
+ b = c1[2];
2070
+ } else if (ci === 2) {
2071
+ r = c2[0];
2072
+ g = c2[1];
2073
+ b = c2[2];
2074
+ } else {
2075
+ r = c3[0];
2076
+ g = c3[1];
2077
+ b = c3[2];
2078
+ }
2079
+ const a = (alphaRow >> x * 4 & 15) * 17;
2080
+ const o = (ty * width + tx) * 4;
2081
+ out[o] = r;
2082
+ out[o + 1] = g;
2083
+ out[o + 2] = b;
2084
+ out[o + 3] = a;
2085
+ }
2086
+ }
2087
+ }
2088
+ /** RGB565 → [r,g,b] 0-255 */
2089
+ function rgb565(v) {
2090
+ return [
2091
+ Math.round((v >> 11 & 31) * 255 / 31),
2092
+ Math.round((v >> 5 & 63) * 255 / 63),
2093
+ Math.round((v & 31) * 255 / 31)
2094
+ ];
2095
+ }
2096
+ /** IEEE 754 半精度浮点 → 0-255 字节 */
2097
+ function halfToByte(data, off) {
2098
+ const h = data[off] | data[off + 1] << 8;
2099
+ const sign = h >> 15 & 1;
2100
+ const exp = h >> 10 & 31;
2101
+ const mant = h & 1023;
2102
+ let f;
2103
+ if (exp === 0) f = mant * 2 ** -24;
2104
+ else if (exp === 31) f = mant === 0 ? Infinity : NaN;
2105
+ else f = (1 + mant / 1024) * 2 ** (exp - 15);
2106
+ if (sign) f = -f;
2107
+ if (!Number.isFinite(f)) return 0;
2108
+ const v = Math.round(f * 255);
2109
+ return v < 0 ? 0 : v > 255 ? 255 : v;
2110
+ }
2111
+ //#endregion
2112
+ //#region src/scene/SceneTex.ts
2113
+ /**
2114
+ * SceneTex —— Wallpaper Engine .tex 纹理容器完整解码器。
2115
+ *
2116
+ * 格式(已实测破解 + repkg(MIT, notscuffed) 源码语义确认,参考文件见
2117
+ * _dev/reference/):
2118
+ * Magic1 "0005\0" + Magic2 "TEXI0001\0"(null 终止字符串)
2119
+ * Header(7 × int32 raw):
2120
+ * Format(TexFormat:0=RGBA8888, 4=DXT5, 6=DXT3, 7=DXT1, 8=RG88, 9=R8)
2121
+ * Flags(TexFlags:2=ClampUVs …)、TextureWidth、TextureHeight、
2122
+ * ImageWidth、ImageHeight、UnkInt0
2123
+ * ImageContainer:
2124
+ * "TEXB0003\0" / "TEXB0004\0" + imageCount(int32)
2125
+ * TEXB0003:ImageFormat(FreeImageFormat:13=PNG, 2=JPEG, -1=raw)
2126
+ * TEXB0004:ImageFormat + isVideoMp4
2127
+ * 每个 image:mipmapCount + 每级 [W][H][IsLZ4][DecompressedBytesCount]
2128
+ * [byteCount][bytes]
2129
+ * - ImageFormat 为图片格式:bytes = 完整 PNG/JPEG 文件
2130
+ * - ImageFormat 为 raw:bytes = LZ4 压缩流 → 解压为 Header.Format
2131
+ * 的像素数据(DXT1/DXT3/DXT5/RGBA8888/RG88/R8)
2132
+ *
2133
+ * 本模块仅被 node 半使用(路由),因此可依赖 node:zlib(PNG 编码)。
2134
+ */
2135
+ /** Header.Format(TexFormat) */
2136
+ const TEX_FORMAT = {
2137
+ RGBA8888: 0,
2138
+ DXT5: 4,
2139
+ DXT3: 6,
2140
+ DXT1: 7,
2141
+ RG88: 8,
2142
+ R8: 9
2143
+ };
2144
+ /** 容器 ImageFormat(FreeImageFormat 子集) */
2145
+ const FIF = {
2146
+ UNKNOWN: -1,
2147
+ JPEG: 2,
2148
+ PNG: 13
2149
+ };
2150
+ /** 解析 .tex 容器;返回 null 表示无法解析 */
2151
+ function decodeTex(bytes) {
2152
+ try {
2153
+ let pos = 0;
2154
+ const readNString = () => {
2155
+ let s = "";
2156
+ while (pos < bytes.length) {
2157
+ const c = bytes[pos++];
2158
+ if (c === 0) break;
2159
+ s += String.fromCharCode(c);
2160
+ }
2161
+ return s;
2162
+ };
2163
+ const readI32 = () => {
2164
+ const v = bytes[pos] | bytes[pos + 1] << 8 | bytes[pos + 2] << 16 | bytes[pos + 3] << 24;
2165
+ pos += 4;
2166
+ return v;
2167
+ };
2168
+ const magic1 = readNString();
2169
+ const magic2 = readNString();
2170
+ if (magic1 !== "0005" && magic1 !== "TEXV0005" || magic2 !== "TEXI0001") return null;
2171
+ const format = readI32();
2172
+ const flags = readI32();
2173
+ const textureWidth = readI32();
2174
+ const textureHeight = readI32();
2175
+ const imageWidth = readI32();
2176
+ const imageHeight = readI32();
2177
+ readI32();
2178
+ const containerMagic = readNString();
2179
+ if (containerMagic !== "TEXB0001" && containerMagic !== "TEXB0002" && containerMagic !== "TEXB0003" && containerMagic !== "TEXB0004") return null;
2180
+ const imageCount = readI32();
2181
+ let imageFormat = FIF.UNKNOWN;
2182
+ if (containerMagic === "TEXB0003") imageFormat = readI32();
2183
+ else if (containerMagic === "TEXB0004") {
2184
+ imageFormat = readI32();
2185
+ readI32();
2186
+ }
2187
+ if (imageCount <= 0 || imageCount > 100) return null;
2188
+ const mipCount = readI32();
2189
+ if (mipCount <= 0 || mipCount > 32) return null;
2190
+ const w = readI32();
2191
+ const h = readI32();
2192
+ const isLz4 = readI32();
2193
+ const decompressed = readI32();
2194
+ const byteCount = readI32();
2195
+ if (w <= 0 || h <= 0 || w > 16384 || h > 16384 || byteCount < 0 || pos + byteCount > bytes.length) return null;
2196
+ const dataOffset = pos;
2197
+ let data = bytes.subarray(pos, pos + byteCount);
2198
+ pos += byteCount;
2199
+ let rgba = null;
2200
+ if (isLz4 === 1) {
2201
+ const raw = lz4Decompress(data, decompressed);
2202
+ if (raw === null) return null;
2203
+ data = raw;
2204
+ }
2205
+ let kind;
2206
+ if (imageFormat === FIF.PNG) kind = "image-png";
2207
+ else if (imageFormat === FIF.JPEG) kind = "image-jpeg";
2208
+ else kind = "raw";
2209
+ if (kind === "raw") {
2210
+ rgba = decodeRawPixels(format, w, h, data);
2211
+ if (rgba === null) return null;
2212
+ }
2213
+ return {
2214
+ format,
2215
+ flags,
2216
+ textureWidth,
2217
+ textureHeight,
2218
+ imageWidth,
2219
+ imageHeight,
2220
+ containerMagic,
2221
+ imageFormat,
2222
+ mipCount,
2223
+ mip0: {
2224
+ width: w,
2225
+ height: h,
2226
+ kind,
2227
+ data,
2228
+ dataOffset,
2229
+ rgba
2230
+ }
2231
+ };
2232
+ } catch {
2233
+ return null;
2234
+ }
2235
+ }
2236
+ /** raw 像素解码:DXT1/DXT3/DXT5/RGBA8888/R8 → RGBA8888 */
2237
+ function decodeRawPixels(format, w, h, data) {
2238
+ let fmt;
2239
+ switch (format) {
2240
+ case TEX_FORMAT.DXT1:
2241
+ fmt = "dxt1";
2242
+ break;
2243
+ case TEX_FORMAT.DXT3:
2244
+ fmt = "dxt3";
2245
+ break;
2246
+ case TEX_FORMAT.DXT5:
2247
+ fmt = "dxt5";
2248
+ break;
2249
+ case TEX_FORMAT.RGBA8888:
2250
+ fmt = "rgba8888";
2251
+ break;
2252
+ case TEX_FORMAT.R8:
2253
+ fmt = "r8";
2254
+ break;
2255
+ case TEX_FORMAT.RG88:
2256
+ fmt = "rg88";
2257
+ break;
2258
+ default: return null;
2259
+ }
2260
+ try {
2261
+ return decodeMip(fmt, data, 0, w, h);
2262
+ } catch {
2263
+ return null;
2264
+ }
2265
+ }
2266
+ /**
2267
+ * 把 mip0 转成可直接伺服/解码的字节:
2268
+ * image-png → 原 PNG 字节;image-jpeg → 原 JPEG 字节;raw → RGBA → PNG
2269
+ */
2270
+ function texMipToPng(tex) {
2271
+ const m0 = tex.mip0;
2272
+ if (m0 === null) return null;
2273
+ if (m0.kind === "image-png" || m0.kind === "image-jpeg") return m0.data;
2274
+ if (m0.rgba === null) return null;
2275
+ return rgbaToPng(m0.rgba, m0.width, m0.height);
2276
+ }
2277
+ /** mip0 的 MIME(image 类型返回图片 MIME;raw 返回 image/png) */
2278
+ function texMimeOf(tex) {
2279
+ const m0 = tex.mip0;
2280
+ if (m0 === null) return null;
2281
+ if (m0.kind === "image-png") return "image/png";
2282
+ if (m0.kind === "image-jpeg") return "image/jpeg";
2283
+ return "image/png";
2284
+ }
2285
+ function lz4Decompress(src, expectedLen) {
2286
+ const out = new Uint8Array(expectedLen);
2287
+ let ip = 0;
2288
+ let op = 0;
2289
+ const end = src.length;
2290
+ while (ip < end) {
2291
+ const token = src[ip++];
2292
+ let litLen = token >> 4;
2293
+ if (litLen === 15) {
2294
+ let b;
2295
+ do {
2296
+ if (ip >= end) return null;
2297
+ b = src[ip++];
2298
+ litLen += b;
2299
+ } while (b === 255);
2300
+ }
2301
+ if (op + litLen > expectedLen || ip + litLen > end) return null;
2302
+ for (let i = 0; i < litLen; i++) out[op++] = src[ip++];
2303
+ if (ip >= end) break;
2304
+ if (ip + 2 > end) return null;
2305
+ const offset = src[ip] | src[ip + 1] << 8;
2306
+ ip += 2;
2307
+ if (offset === 0 || offset > op) return null;
2308
+ let matchLen = token & 15;
2309
+ if (matchLen === 15) {
2310
+ let b;
2311
+ do {
2312
+ if (ip >= end) return null;
2313
+ b = src[ip++];
2314
+ matchLen += b;
2315
+ } while (b === 255);
2316
+ }
2317
+ matchLen += 4;
2318
+ for (let i = 0; i < matchLen; i++) {
2319
+ out[op] = out[op - offset];
2320
+ op++;
2321
+ if (op > expectedLen) return null;
2322
+ }
2323
+ }
2324
+ if (op !== expectedLen) return null;
2325
+ return out;
2326
+ }
2327
+ const CRC_TABLE = (() => {
2328
+ const t = /* @__PURE__ */ new Int32Array(256);
2329
+ for (let n = 0; n < 256; n++) {
2330
+ let c = n;
2331
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
2332
+ t[n] = c;
2333
+ }
2334
+ return t;
2335
+ })();
2336
+ function crc32(buf) {
2337
+ let c = 4294967295;
2338
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 255] ^ c >>> 8;
2339
+ return (c ^ 4294967295) >>> 0;
2340
+ }
2341
+ function pngChunk(type, data) {
2342
+ const len = /* @__PURE__ */ new Uint8Array(4);
2343
+ len[0] = data.length >>> 24 & 255;
2344
+ len[1] = data.length >>> 16 & 255;
2345
+ len[2] = data.length >>> 8 & 255;
2346
+ len[3] = data.length & 255;
2347
+ const td = new Uint8Array(4 + data.length);
2348
+ for (let i = 0; i < 4; i++) td[i] = type.charCodeAt(i);
2349
+ td.set(data, 4);
2350
+ const crc = /* @__PURE__ */ new Uint8Array(4);
2351
+ const c = crc32(td);
2352
+ crc[0] = c >>> 24 & 255;
2353
+ crc[1] = c >>> 16 & 255;
2354
+ crc[2] = c >>> 8 & 255;
2355
+ crc[3] = c & 255;
2356
+ const out = new Uint8Array(4 + td.length + 4);
2357
+ out.set(len, 0);
2358
+ out.set(td, 4);
2359
+ out.set(crc, 4 + td.length);
2360
+ return out;
2361
+ }
2362
+ function rgbaToPng(rgba, w, h) {
2363
+ const ihdr = /* @__PURE__ */ new Uint8Array(13);
2364
+ ihdr[0] = w >>> 24 & 255;
2365
+ ihdr[1] = w >>> 16 & 255;
2366
+ ihdr[2] = w >>> 8 & 255;
2367
+ ihdr[3] = w & 255;
2368
+ ihdr[4] = h >>> 24 & 255;
2369
+ ihdr[5] = h >>> 16 & 255;
2370
+ ihdr[6] = h >>> 8 & 255;
2371
+ ihdr[7] = h & 255;
2372
+ ihdr[8] = 8;
2373
+ ihdr[9] = 6;
2374
+ ihdr[10] = 0;
2375
+ ihdr[11] = 0;
2376
+ ihdr[12] = 0;
2377
+ const raw = new Uint8Array(h * (1 + w * 4));
2378
+ for (let y = 0; y < h; y++) {
2379
+ raw[y * (1 + w * 4)] = 0;
2380
+ for (let x = 0; x < w; x++) {
2381
+ const s = (y * w + x) * 4;
2382
+ const d = y * (1 + w * 4) + 1 + x * 4;
2383
+ raw[d] = rgba[s];
2384
+ raw[d + 1] = rgba[s + 1];
2385
+ raw[d + 2] = rgba[s + 2];
2386
+ raw[d + 3] = rgba[s + 3];
2387
+ }
2388
+ }
2389
+ const idat = deflateSync(Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength), { level: 6 });
2390
+ const sig = new Uint8Array([
2391
+ 137,
2392
+ 80,
2393
+ 78,
2394
+ 71,
2395
+ 13,
2396
+ 10,
2397
+ 26,
2398
+ 10
2399
+ ]);
2400
+ const ih = pngChunk("IHDR", ihdr);
2401
+ const id = pngChunk("IDAT", new Uint8Array(idat));
2402
+ const ie = pngChunk("IEND", /* @__PURE__ */ new Uint8Array(0));
2403
+ const out = new Uint8Array(sig.length + ih.length + id.length + ie.length);
2404
+ out.set(sig, 0);
2405
+ out.set(ih, sig.length);
2406
+ out.set(id, sig.length + ih.length);
2407
+ out.set(ie, sig.length + ih.length + id.length);
2408
+ return out;
2409
+ }
2410
+ //#endregion
2411
+ //#region src/index.ts
2412
+ /**
2413
+ * dsh-wallpaper_share · node half(内部 id / 路由前缀仍为 we-sync)
2414
+ * Wallpaper Engine ↔ DSH 壁纸同步(纯显示):轮询 WE 的 config.json,
2415
+ * 通过 HTTP 路由提供当前壁纸状态、预览图与增强模式源文件。
2416
+ * 多显示器:跟踪所有条目,默认跟随"最近变化"的一台;客户端可用
2417
+ * ?monitor= 参数锁定某台。
2418
+ *
2419
+ * 无敏感信息。安装目录运行时自动检测(注册表 → 常见 Steam 路径),
2420
+ * 检测不到时在下方 CONFIG.wallpaperEngineDir 手动指定。
2421
+ */
2422
+ const inject = ["webServer"];
2423
+ const CONFIG = {
2424
+ /** Wallpaper Engine 安装目录;留空 = 自动检测(注册表 HKCU\Software\WallpaperEngine\installPath → 常见 Steam 路径) */
2425
+ wallpaperEngineDir: "",
2426
+ /** 工作坊内容目录;留空自动推导为 <Steam库>/steamapps/workshop/content/431960 */
2427
+ workshopContentDir: "",
2428
+ /** 轮询间隔(毫秒) */
2429
+ pollIntervalMs: 2e3,
2430
+ /** 预览图大小上限(字节) */
2431
+ previewMaxBytes: 6291456,
2432
+ /** 外部 scene renderer 可执行文件;留空 = 使用内置参考 renderer(诊断动画,非真实渲染) */
2433
+ sceneRendererPath: "",
2434
+ /** Wallpaper Engine engine assets 目录;留空自动推导为 <weDir>/assets */
2435
+ wallpaperEngineAssetsDir: "",
2436
+ /** scene renderer 输出分辨率(真实 renderer 建议 1920x1080;参考 renderer 会自行 clamp) */
2437
+ sceneRenderWidth: 1920,
2438
+ sceneRenderHeight: 1080,
2439
+ /** scene renderer 目标帧率 */
2440
+ sceneRenderFps: 30,
2441
+ /** JPEG/WebP 帧质量(0..100) */
2442
+ sceneRenderQuality: 80,
2443
+ /** scene 渲染模式:'auto'(默认:浏览器子集渲染器为主;显式配置 sceneRendererPath 则 external)| 'browser'(强制浏览器子集渲染器)| 'external'(强制外部 renderer 子进程) */
2444
+ sceneRenderMode: "auto",
2445
+ /** 粒子发射率缩放(视觉校准项;WE rate 单位 = 每秒粒子数,默认 1) */
2446
+ particleRateScale: 1,
2447
+ /** 粒子尺寸缩放(视觉校准项,默认 1) */
2448
+ particleSizeScale: 1,
2449
+ /** 图层效果强度缩放(waterwaves/shake 幅度全局系数;原版参数普遍偏强,默认 0.6) */
2450
+ effectStrengthScale: .6,
2451
+ /** puppet 网格蒙皮渲染(部件按顶点网格渲染;buildMeshCanvas 已与参考 v2 渲染逐像素一致) */
2452
+ puppetMeshRender: true
2453
+ };
2454
+ function apply(ctx) {
2455
+ const webServer = ctx.get("webServer");
2456
+ if (webServer === void 0) return;
2457
+ const state = {
2458
+ version: 0,
2459
+ snapshot: null,
2460
+ latestMonitor: "",
2461
+ monitors: [],
2462
+ previews: {},
2463
+ lastError: "",
2464
+ weDir: ""
2465
+ };
2466
+ /** Scene renderer 编排器(在 detectWeDir 成功后实例化) */
2467
+ let sceneAdapter = null;
2468
+ /** SceneModel 缓存(key=指纹;避免每次轮询重新解析 scene.pkg) */
2469
+ let sceneModelCache = null;
2470
+ const disposers = [];
2471
+ ctx.effect(() => () => {
2472
+ for (const d of disposers) d();
2473
+ });
2474
+ function normalize(path) {
2475
+ return path.replace(/\\/g, "/");
2476
+ }
2477
+ function detectWeDir() {
2478
+ if (CONFIG.wallpaperEngineDir.trim() !== "") return normalize(CONFIG.wallpaperEngineDir.trim());
2479
+ try {
2480
+ const out = execFileSync("reg", [
2481
+ "query",
2482
+ "HKCU\\Software\\WallpaperEngine",
2483
+ "/v",
2484
+ "installPath"
2485
+ ], {
2486
+ encoding: "utf8",
2487
+ windowsHide: true,
2488
+ timeout: 5e3
2489
+ });
2490
+ const match = /REG_SZ\s+(.+)/.exec(out);
2491
+ if (match !== null) {
2492
+ const installPath = match[1];
2493
+ if (installPath !== void 0) return normalize(installPath.trim()).replace(/\/wallpaper(64|32)\.exe$/i, "");
2494
+ }
2495
+ } catch {}
2496
+ for (const dir of [
2497
+ "C:/Program Files (x86)/Steam/steamapps/common/wallpaper_engine",
2498
+ "D:/Program Files (x86)/Steam/steamapps/common/wallpaper_engine",
2499
+ "C:/Steam/steamapps/common/wallpaper_engine",
2500
+ "D:/Steam/steamapps/common/wallpaper_engine"
2501
+ ]) if (existsSync(dir + "/wallpaper64.exe")) return dir;
2502
+ return null;
2503
+ }
2504
+ function resolveWorkshopDir(weDir) {
2505
+ if (CONFIG.workshopContentDir.trim() !== "") return normalize(CONFIG.workshopContentDir.trim());
2506
+ const idx = weDir.indexOf("/steamapps/common/");
2507
+ if (idx >= 0) return weDir.slice(0, idx) + "/steamapps/workshop/content/431960";
2508
+ return weDir.replace(/\/common\/[^/]+$/, "") + "/workshop/content/431960";
2509
+ }
2510
+ function readText(path) {
2511
+ return readFileSync(path, "utf8");
2512
+ }
2513
+ function readBytes(path) {
2514
+ const buf = readFileSync(path);
2515
+ if (buf.byteLength > CONFIG.previewMaxBytes) throw new Error("preview exceeds " + CONFIG.previewMaxBytes + " bytes");
2516
+ return new Uint8Array(buf);
2517
+ }
2518
+ function exists(path) {
2519
+ return existsSync(path);
2520
+ }
2521
+ function dirOf(file) {
2522
+ const slash = normalize(file);
2523
+ const idx = slash.lastIndexOf("/");
2524
+ return idx >= 0 ? slash.slice(0, idx) : slash;
2525
+ }
2526
+ /** 读取所有显示器的壁纸条目 + 最近选中的显示器 */
2527
+ function readEntries(weDir) {
2528
+ const root = JSON.parse(readText(weDir + "/config.json").replace(/^\uFEFF/, ""));
2529
+ let cfg = null;
2530
+ for (const key of Object.keys(root)) {
2531
+ const value = root[key];
2532
+ if (value !== null && typeof value === "object" && value.general !== void 0) {
2533
+ cfg = value;
2534
+ break;
2535
+ }
2536
+ }
2537
+ const general = cfg?.general ?? {};
2538
+ const sel = (general.wallpaperconfig ?? {}).selectedwallpapers ?? {};
2539
+ const entries = {};
2540
+ for (const key of Object.keys(sel)) {
2541
+ if (!key.startsWith("Monitor")) continue;
2542
+ const value = sel[key];
2543
+ if (value === null || typeof value !== "object") continue;
2544
+ const file = value.file;
2545
+ if (typeof file === "string" && file.length > 0) entries[key] = { file };
2546
+ }
2547
+ const browser = general.browser ?? {};
2548
+ return {
2549
+ entries,
2550
+ last: typeof browser.lastselectedmonitor === "string" ? browser.lastselectedmonitor : ""
2551
+ };
2552
+ }
2553
+ /** workshopcache 的 workshopid → {title, type} 映射(一次解析,全体复用) */
2554
+ function readCacheMeta(weDir) {
2555
+ const map = /* @__PURE__ */ new Map();
2556
+ try {
2557
+ const cache = JSON.parse(readText(weDir + "/bin/workshopcache.json"));
2558
+ for (const w of cache.wallpapers ?? []) if (w.workshopid !== void 0 && w.workshopid !== null) map.set(String(w.workshopid), {
2559
+ title: String(w.title ?? ""),
2560
+ type: String(w.type ?? "")
2561
+ });
2562
+ } catch {}
2563
+ return map;
2564
+ }
2565
+ function resolveMeta(file, workshopDir, cacheMap) {
2566
+ const slash = normalize(file);
2567
+ const match = /431960\/(\d+)/.exec(slash);
2568
+ const id = (match !== null ? match[1] : "") ?? "";
2569
+ let title = "";
2570
+ let type = "";
2571
+ const cached = id !== "" ? cacheMap.get(id) : void 0;
2572
+ if (cached !== void 0) {
2573
+ title = cached.title;
2574
+ type = cached.type;
2575
+ }
2576
+ if (title === "") try {
2577
+ const base = id !== "" ? workshopDir + "/" + id : dirOf(slash);
2578
+ const project = JSON.parse(readText(base + "/project.json"));
2579
+ if (project !== null && typeof project === "object") {
2580
+ if (project.title !== void 0) title = String(project.title);
2581
+ if (type === "" && project.type !== void 0) type = String(project.type);
2582
+ }
2583
+ } catch {}
2584
+ if (title === "") title = id !== "" ? id : slash.slice(slash.lastIndexOf("/") + 1);
2585
+ return {
2586
+ title,
2587
+ type,
2588
+ id
2589
+ };
2590
+ }
2591
+ function probePreview(dir) {
2592
+ for (const [name, mime] of [
2593
+ ["preview.jpg", "image/jpeg"],
2594
+ ["preview.png", "image/png"],
2595
+ ["preview.gif", "image/gif"]
2596
+ ]) {
2597
+ const path = dir + "/" + name;
2598
+ try {
2599
+ if (exists(path)) return {
2600
+ path,
2601
+ mime
2602
+ };
2603
+ } catch {}
2604
+ }
2605
+ return null;
2606
+ }
2607
+ /** 按扩展名判断源文件能否被浏览器直接渲染 */
2608
+ function sourceKindOf(file) {
2609
+ const lower = normalize(file).toLowerCase();
2610
+ if (lower.endsWith(".mp4")) return {
2611
+ kind: "video",
2612
+ mime: "video/mp4"
2613
+ };
2614
+ if (lower.endsWith(".webm")) return {
2615
+ kind: "video",
2616
+ mime: "video/webm"
2617
+ };
2618
+ if (lower.endsWith(".mov")) return {
2619
+ kind: "video",
2620
+ mime: "video/quicktime"
2621
+ };
2622
+ if (lower.endsWith(".avi")) return {
2623
+ kind: "video",
2624
+ mime: "video/x-msvideo"
2625
+ };
2626
+ if (lower.endsWith(".mkv")) return {
2627
+ kind: "video",
2628
+ mime: "video/x-matroska"
2629
+ };
2630
+ if (lower.endsWith(".html") || lower.endsWith(".htm")) return {
2631
+ kind: "web",
2632
+ mime: "text/html"
2633
+ };
2634
+ if (lower.endsWith(".pkg")) return {
2635
+ kind: "scene",
2636
+ mime: ""
2637
+ };
2638
+ if (lower.endsWith(".exe")) return {
2639
+ kind: "application",
2640
+ mime: ""
2641
+ };
2642
+ if (lower.endsWith(".png")) return {
2643
+ kind: "image",
2644
+ mime: "image/png"
2645
+ };
2646
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return {
2647
+ kind: "image",
2648
+ mime: "image/jpeg"
2649
+ };
2650
+ if (lower.endsWith(".gif")) return {
2651
+ kind: "image",
2652
+ mime: "image/gif"
2653
+ };
2654
+ if (lower.endsWith(".webp")) return {
2655
+ kind: "image",
2656
+ mime: "image/webp"
2657
+ };
2658
+ return {
2659
+ kind: "other",
2660
+ mime: ""
2661
+ };
2662
+ }
2663
+ /** 从 scene.pkg(Wallpaper Engine 私有 PKGV 容器)中扫描最大的一张 JPEG/PNG 纹理。
2664
+ * scene 壁纸的真实画面由 WE 引擎(shader / 粒子 / 纹理)渲染,浏览器无法执行;
2665
+ * 这里提取内嵌背景纹理的 mipmap 链中最高清的一张,作为增强模式的近似背景。 */
2666
+ function scanPkgImage(file) {
2667
+ let buf;
2668
+ try {
2669
+ buf = readFileSync(file);
2670
+ } catch {
2671
+ return null;
2672
+ }
2673
+ let best = null;
2674
+ const consider = (start, end, mime, w, h) => {
2675
+ if (w < 64 || h < 64 || w > 16384 || h > 16384) return;
2676
+ const area = w * h;
2677
+ if (best === null || area > best.width * best.height) best = {
2678
+ start,
2679
+ end,
2680
+ mime,
2681
+ width: w,
2682
+ height: h
2683
+ };
2684
+ };
2685
+ let pos = 0;
2686
+ while (pos < buf.length - 4) {
2687
+ if (buf[pos] === 255 && buf[pos + 1] === 216 && buf[pos + 2] === 255) {
2688
+ let scan = pos + 2;
2689
+ let w = 0;
2690
+ let h = 0;
2691
+ for (let guard = 0; scan < buf.length - 9 && guard < 64; guard++) {
2692
+ if (buf[scan] !== 255) {
2693
+ scan++;
2694
+ continue;
2695
+ }
2696
+ const marker = buf[scan + 1];
2697
+ if (marker === 216 || marker >= 208 && marker <= 215) {
2698
+ scan += 2;
2699
+ continue;
2700
+ }
2701
+ const len = buf.readUInt16BE(scan + 2);
2702
+ if (len < 2) break;
2703
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
2704
+ h = buf.readUInt16BE(scan + 5);
2705
+ w = buf.readUInt16BE(scan + 7);
2706
+ break;
2707
+ }
2708
+ scan += 2 + len;
2709
+ }
2710
+ if (w > 0 && h > 0) {
2711
+ const eoi = buf.indexOf(Buffer.from([255, 217]), scan);
2712
+ const end = eoi >= 0 ? eoi + 1 : buf.length - 1;
2713
+ consider(pos, end, "image/jpeg", w, h);
2714
+ pos = end;
2715
+ continue;
2716
+ }
2717
+ }
2718
+ if (buf[pos] === 137 && buf[pos + 1] === 80 && buf[pos + 2] === 78 && buf[pos + 3] === 71 && buf.readUInt32BE(pos + 12) === 1229472850) {
2719
+ const w = buf.readUInt32BE(pos + 16);
2720
+ const h = buf.readUInt32BE(pos + 20);
2721
+ const iend = buf.indexOf(Buffer.from("49454e44ae426082", "hex"), pos);
2722
+ const end = iend >= 0 ? iend + 7 : buf.length - 1;
2723
+ consider(pos, end, "image/png", w, h);
2724
+ pos = end;
2725
+ continue;
2726
+ }
2727
+ pos++;
2728
+ }
2729
+ return best;
2730
+ }
2731
+ function mimeOfPath(path) {
2732
+ const lower = normalize(path).toLowerCase();
2733
+ if (lower.endsWith(".html") || lower.endsWith(".htm")) return "text/html; charset=utf-8";
2734
+ if (lower.endsWith(".css")) return "text/css; charset=utf-8";
2735
+ if (lower.endsWith(".js")) return "application/javascript; charset=utf-8";
2736
+ if (lower.endsWith(".json")) return "application/json; charset=utf-8";
2737
+ if (lower.endsWith(".png")) return "image/png";
2738
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
2739
+ if (lower.endsWith(".gif")) return "image/gif";
2740
+ if (lower.endsWith(".webp")) return "image/webp";
2741
+ if (lower.endsWith(".svg")) return "image/svg+xml";
2742
+ if (lower.endsWith(".woff2")) return "font/woff2";
2743
+ if (lower.endsWith(".woff")) return "font/woff";
2744
+ if (lower.endsWith(".ttf")) return "font/ttf";
2745
+ if (lower.endsWith(".mp4")) return "video/mp4";
2746
+ if (lower.endsWith(".webm")) return "video/webm";
2747
+ if (lower.endsWith(".mp3")) return "audio/mpeg";
2748
+ if (lower.endsWith(".wav")) return "audio/wav";
2749
+ return "application/octet-stream";
2750
+ }
2751
+ /** 读取壁纸 project.json 的 general.properties 默认值,构造 WE applyUserProperties 入参 */
2752
+ function buildWallpaperProps(dir) {
2753
+ try {
2754
+ const project = JSON.parse(readText(dir + "/project.json"));
2755
+ const props = {};
2756
+ for (const key of Object.keys(project?.general?.properties ?? {})) {
2757
+ const p = project.general?.properties?.[key];
2758
+ if (p !== void 0 && "value" in p) props[key] = { value: p.value };
2759
+ }
2760
+ if (project?.general?.properties?.modelresolution !== void 0) {
2761
+ for (const res of [
2762
+ "2k",
2763
+ "4k",
2764
+ "8k"
2765
+ ]) if (exists(dir + "/assets/" + res)) {
2766
+ props.modelresolution = { value: res };
2767
+ break;
2768
+ }
2769
+ }
2770
+ if (Object.keys(props).length > 0) return props;
2771
+ } catch {}
2772
+ return { introanimation: { value: true } };
2773
+ }
2774
+ /** 注入到壁纸页面里的 WE 环境 shim:复刻 WE 默认环境(html/body 铺满黑底 + 主 canvas 全屏),
2775
+ * 并等 wallpaperPropertyListener 注册后自动调用 applyUserProperties */
2776
+ function wallpaperShim(props) {
2777
+ return "<style>html,body{width:100%;height:100%;overflow:hidden;background:#000;margin:0;padding:0}</style><script>(function(){var c=document.getElementById(\"canvas\");if(c&&getComputedStyle(c).position===\"static\"){c.style.position=\"fixed\";c.style.top=\"0\";c.style.left=\"0\";c.style.width=\"100%\";c.style.height=\"100%\"};" + ("var p=" + JSON.stringify(props).replace(/</g, "\\u003c") + ";var f=function(){if(window.wallpaperPropertyListener&&typeof window.wallpaperPropertyListener.applyUserProperties===\"function\"){window.wallpaperPropertyListener.applyUserProperties(p);return true}return false};if(!f()){var n=0;var t=setInterval(function(){n++;if(f()||n>200)clearInterval(t)},50)}") + "})();<\\/script>";
2778
+ }
2779
+ /** 伺服 web 壁纸文件;HTML 注入 WE 属性 shim(否则 introAnimation 等属性永远 undefined,渲染被卡住) */
2780
+ function serveWebFile(dir, target, req, res) {
2781
+ const lower = target.toLowerCase();
2782
+ if (lower.endsWith(".html") || lower.endsWith(".htm")) try {
2783
+ const html = readText(target);
2784
+ const shim = wallpaperShim(buildWallpaperProps(dir));
2785
+ const injected = html.replace(/<\/body>/i, shim + "</body>");
2786
+ const out = injected === html ? html + shim : injected;
2787
+ res.statusCode = 200;
2788
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
2789
+ res.setHeader("Cache-Control", "no-store");
2790
+ res.end(out);
2791
+ return;
2792
+ } catch {}
2793
+ serveFile(target, mimeOfPath(target), req, res);
2794
+ }
2795
+ /** 解析 HTTP Range 头;返回 undefined=无 Range,null=非法范围,否则为闭区间 */
2796
+ function parseRange(header, total) {
2797
+ if (typeof header !== "string") return void 0;
2798
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
2799
+ if (m === null) return void 0;
2800
+ const left = m[1] ?? "";
2801
+ const right = m[2] ?? "";
2802
+ if (left === "" && right === "") return null;
2803
+ let start;
2804
+ let end;
2805
+ if (left === "") {
2806
+ const n = Number(right);
2807
+ if (!Number.isFinite(n) || n <= 0) return null;
2808
+ start = Math.max(0, total - n);
2809
+ end = total - 1;
2810
+ } else {
2811
+ start = Number(left);
2812
+ if (!Number.isFinite(start) || start < 0 || start >= total) return null;
2813
+ end = right === "" ? total - 1 : Math.min(Number(right), total - 1);
2814
+ if (!Number.isFinite(end) || end < start) return null;
2815
+ }
2816
+ return {
2817
+ start,
2818
+ end
2819
+ };
2820
+ }
2821
+ /** 流式返回文件(视频等大文件不能整读进内存),支持 HTTP Range 以便视频可 seek/播放 */
2822
+ function serveFile(path, mime, req, res) {
2823
+ let info;
2824
+ try {
2825
+ info = statSync(path);
2826
+ } catch {
2827
+ res.statusCode = 404;
2828
+ res.end("not found");
2829
+ return;
2830
+ }
2831
+ if (!info.isFile()) {
2832
+ res.statusCode = 404;
2833
+ res.end("not found");
2834
+ return;
2835
+ }
2836
+ const total = info.size;
2837
+ res.setHeader("Accept-Ranges", "bytes");
2838
+ res.setHeader("Content-Type", mime);
2839
+ res.setHeader("Cache-Control", "no-store");
2840
+ const range = parseRange(req.headers?.range, total);
2841
+ if (range === null) {
2842
+ res.statusCode = 416;
2843
+ res.setHeader("Content-Range", "bytes */" + total);
2844
+ res.end();
2845
+ return;
2846
+ }
2847
+ if (range !== void 0) {
2848
+ res.statusCode = 206;
2849
+ res.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + total);
2850
+ res.setHeader("Content-Length", String(range.end - range.start + 1));
2851
+ const stream = createReadStream(path, {
2852
+ start: range.start,
2853
+ end: range.end
2854
+ });
2855
+ stream.on("error", () => {
2856
+ try {
2857
+ res.end();
2858
+ } catch {}
2859
+ });
2860
+ stream.pipe(res);
2861
+ return;
2862
+ }
2863
+ res.statusCode = 200;
2864
+ res.setHeader("Content-Length", String(total));
2865
+ const stream = createReadStream(path);
2866
+ stream.on("error", () => {
2867
+ try {
2868
+ res.end();
2869
+ } catch {}
2870
+ });
2871
+ stream.pipe(res);
2872
+ }
2873
+ /** 流式返回文件的一个字节切片(用于从 scene.pkg 内提取纹理),支持 HTTP Range */
2874
+ function serveSlice(path, start, end, mime, req, res) {
2875
+ const total = end - start + 1;
2876
+ res.setHeader("Accept-Ranges", "bytes");
2877
+ res.setHeader("Content-Type", mime);
2878
+ res.setHeader("Cache-Control", "no-store");
2879
+ const range = parseRange(req.headers?.range, total);
2880
+ if (range === null) {
2881
+ res.statusCode = 416;
2882
+ res.setHeader("Content-Range", "bytes */" + total);
2883
+ res.end();
2884
+ return;
2885
+ }
2886
+ if (range !== void 0) {
2887
+ res.statusCode = 206;
2888
+ res.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + total);
2889
+ res.setHeader("Content-Length", String(range.end - range.start + 1));
2890
+ const stream = createReadStream(path, {
2891
+ start: start + range.start,
2892
+ end: start + range.end
2893
+ });
2894
+ stream.on("error", () => {
2895
+ try {
2896
+ res.end();
2897
+ } catch {}
2898
+ });
2899
+ stream.pipe(res);
2900
+ return;
2901
+ }
2902
+ res.statusCode = 200;
2903
+ res.setHeader("Content-Length", String(total));
2904
+ const stream = createReadStream(path, {
2905
+ start,
2906
+ end
2907
+ });
2908
+ stream.on("error", () => {
2909
+ try {
2910
+ res.end();
2911
+ } catch {}
2912
+ });
2913
+ stream.pipe(res);
2914
+ }
2915
+ /** 重建全量显示器信息 + 每台预览缓存;识别"最近变化"的显示器 */
2916
+ function refresh(entries, last, weDir, workshopDir) {
2917
+ state.lastError = "";
2918
+ const prev = state.snapshot;
2919
+ state.snapshot = entries;
2920
+ let changedKey = null;
2921
+ for (const key of Object.keys(entries)) {
2922
+ const entry = entries[key];
2923
+ if (entry === void 0) continue;
2924
+ const prevEntry = prev === null ? void 0 : prev[key];
2925
+ if (prevEntry === void 0 || prevEntry.file !== entry.file) {
2926
+ changedKey = key;
2927
+ break;
2928
+ }
2929
+ }
2930
+ if (changedKey === null && prev !== null) {
2931
+ for (const key of Object.keys(prev)) if (entries[key] === void 0) {
2932
+ changedKey = key;
2933
+ break;
2934
+ }
2935
+ }
2936
+ if (changedKey !== null) state.latestMonitor = changedKey;
2937
+ if (state.latestMonitor === "" || entries[state.latestMonitor] === void 0) state.latestMonitor = entries[last] !== void 0 ? last : Object.keys(entries)[0] ?? "";
2938
+ const cacheMap = readCacheMeta(weDir);
2939
+ state.monitors = Object.keys(entries).flatMap((key) => {
2940
+ const entry = entries[key];
2941
+ if (entry === void 0) return [];
2942
+ const meta = resolveMeta(entry.file, workshopDir, cacheMap);
2943
+ const src = sourceKindOf(entry.file);
2944
+ let kind = src.kind;
2945
+ let mime = src.mime;
2946
+ let sourceFile = entry.file;
2947
+ let sceneImage = null;
2948
+ if (kind === "other") {
2949
+ const index = dirOf(entry.file) + "/index.html";
2950
+ if (exists(index)) {
2951
+ kind = "web";
2952
+ mime = "text/html";
2953
+ sourceFile = index;
2954
+ }
2955
+ }
2956
+ if (kind === "scene") sceneImage = scanPkgImage(entry.file);
2957
+ return [{
2958
+ key,
2959
+ file: entry.file,
2960
+ title: meta.title,
2961
+ type: meta.type,
2962
+ kind,
2963
+ mime,
2964
+ sourceFile,
2965
+ sceneImage
2966
+ }];
2967
+ });
2968
+ const previews = {};
2969
+ for (const monitor of state.monitors) {
2970
+ let info = {
2971
+ bytes: null,
2972
+ mime: "",
2973
+ kind: "none"
2974
+ };
2975
+ if (!/^https?:\/\//i.test(monitor.file)) {
2976
+ const preview = probePreview(dirOf(monitor.file));
2977
+ if (preview !== null) try {
2978
+ info = {
2979
+ bytes: readBytes(preview.path),
2980
+ mime: preview.mime,
2981
+ kind: "image"
2982
+ };
2983
+ } catch (e) {
2984
+ state.lastError = String(e.message ?? e);
2985
+ }
2986
+ } else info = {
2987
+ bytes: null,
2988
+ mime: "",
2989
+ kind: "web"
2990
+ };
2991
+ previews[monitor.key] = info;
2992
+ }
2993
+ state.previews = previews;
2994
+ state.version += 1;
2995
+ syncSceneTarget();
2996
+ }
2997
+ function poll(weDir) {
2998
+ if (weDir === "") return;
2999
+ try {
3000
+ const { entries, last } = readEntries(weDir);
3001
+ if (JSON.stringify(entries) !== JSON.stringify(state.snapshot)) refresh(entries, last, weDir, resolveWorkshopDir(weDir));
3002
+ } catch (e) {
3003
+ state.lastError = String(e.message ?? e);
3004
+ }
3005
+ }
3006
+ function sendJson(res, body) {
3007
+ res.statusCode = 200;
3008
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
3009
+ res.setHeader("Cache-Control", "no-store");
3010
+ res.end(JSON.stringify(body));
3011
+ }
3012
+ function monitorFromQuery(req) {
3013
+ const match = /[?&]monitor=([^&]+)/.exec(req.url ?? "");
3014
+ if (match === null || match[1] === void 0) return "";
3015
+ try {
3016
+ return decodeURIComponent(match[1]);
3017
+ } catch {
3018
+ return "";
3019
+ }
3020
+ }
3021
+ function effectiveKey(locked) {
3022
+ const keys = state.monitors.map((m) => m.key);
3023
+ if (keys.includes(locked)) return locked;
3024
+ if (state.latestMonitor !== "" && keys.includes(state.latestMonitor)) return state.latestMonitor;
3025
+ return keys[0] ?? "";
3026
+ }
3027
+ /** 由显示器 key 构造 SceneAdapter 目标(仅 scene 壁纸;非 scene 返回 null) */
3028
+ function sceneTargetFor(key) {
3029
+ const monitor = state.monitors.find((m) => m.key === key);
3030
+ if (monitor === void 0 || monitor.kind !== "scene") return null;
3031
+ return {
3032
+ key: monitor.key,
3033
+ file: monitor.file,
3034
+ kind: monitor.kind
3035
+ };
3036
+ }
3037
+ /** 让 renderer 跟随当前生效的 scene 显示器(在 monitors 重建后调用) */
3038
+ function syncSceneTarget() {
3039
+ if (sceneAdapter === null) return;
3040
+ sceneAdapter.setTarget(sceneTargetFor(effectiveKey("")));
3041
+ }
3042
+ /** 汇总某台显示器的 scene renderer 状态(供 /we-sync/state 与 /we-sync/diag) */
3043
+ function sceneInfoFor(key) {
3044
+ const monitor = state.monitors.find((m) => m.key === key);
3045
+ if (monitor === void 0 || monitor.kind !== "scene") return null;
3046
+ const cap = sceneAdapter?.getCapabilities() ?? null;
3047
+ const status = sceneAdapter?.getStatus() ?? null;
3048
+ const hasPreview = state.previews[key]?.kind === "image";
3049
+ const fallback = sceneAdapter?.getFallback({
3050
+ kind: "scene",
3051
+ hasTexture: monitor.sceneImage !== null,
3052
+ hasPreview,
3053
+ renderMode: "source"
3054
+ }) ?? null;
3055
+ return {
3056
+ live: sceneAdapter?.isRunning() === true,
3057
+ available: cap?.available === true,
3058
+ version: cap?.version ?? "",
3059
+ status,
3060
+ texture: monitor.sceneImage !== null,
3061
+ fallback: fallback?.level ?? "generic",
3062
+ capabilities: cap,
3063
+ mode: resolveSceneMode(),
3064
+ model: getSceneModel(key) !== null
3065
+ };
3066
+ }
3067
+ /** 解析当前 scene 渲染模式:
3068
+ * 'external' → 外部 renderer;'browser' → 浏览器子集渲染器;
3069
+ * 'auto' → 显式配置了 sceneRendererPath 则 external,否则 browser(浏览器子集渲染器为主路线) */
3070
+ function resolveSceneMode() {
3071
+ if (CONFIG.sceneRenderMode === "external") return "external";
3072
+ if (CONFIG.sceneRenderMode === "browser") return "browser";
3073
+ return CONFIG.sceneRendererPath.trim() !== "" ? "external" : "browser";
3074
+ }
3075
+ /** 构建(并缓存)某显示器的 SceneModel;非 scene 或解析失败返回 null */
3076
+ function getSceneModel(key) {
3077
+ const monitor = state.monitors.find((m) => m.key === key);
3078
+ if (monitor === void 0 || monitor.kind !== "scene") return null;
3079
+ const fp = sceneFingerprint(monitor.file);
3080
+ if (sceneModelCache !== null && sceneModelCache.fp === fp) return sceneModelCache.model;
3081
+ let model = null;
3082
+ try {
3083
+ model = buildSceneModel(new Uint8Array(readFileSync(monitor.file)), {
3084
+ particleRateScale: CONFIG.particleRateScale,
3085
+ particleSizeScale: CONFIG.particleSizeScale,
3086
+ effectStrengthScale: CONFIG.effectStrengthScale,
3087
+ puppetMeshRender: CONFIG.puppetMeshRender
3088
+ });
3089
+ } catch {
3090
+ model = null;
3091
+ }
3092
+ sceneModelCache = {
3093
+ fp,
3094
+ model
3095
+ };
3096
+ return model;
3097
+ }
3098
+ disposers.push(webServer.register({
3099
+ kind: "exact",
3100
+ path: "/we-sync/state",
3101
+ handler(req, res) {
3102
+ const key = effectiveKey(monitorFromQuery(req));
3103
+ const monitor = state.monitors.find((m) => m.key === key);
3104
+ const preview = key !== "" ? state.previews[key] : void 0;
3105
+ sendJson(res, {
3106
+ version: state.version,
3107
+ kind: preview !== void 0 ? preview.kind : "none",
3108
+ hash: monitor !== void 0 ? key + "|" + monitor.file : "none",
3109
+ monitor: key,
3110
+ latestMonitor: state.latestMonitor,
3111
+ monitors: state.monitors.length > 1 ? state.monitors : [],
3112
+ wallpaper: monitor !== void 0 ? {
3113
+ title: monitor.title,
3114
+ type: monitor.type
3115
+ } : null,
3116
+ source: monitor !== void 0 ? {
3117
+ kind: monitor.kind,
3118
+ mime: monitor.mime,
3119
+ scene: monitor.sceneImage !== null
3120
+ } : {
3121
+ kind: "",
3122
+ mime: "",
3123
+ scene: false
3124
+ },
3125
+ scene: sceneInfoFor(key),
3126
+ webPort
3127
+ });
3128
+ }
3129
+ }));
3130
+ disposers.push(webServer.register({
3131
+ kind: "exact",
3132
+ path: "/we-sync/source",
3133
+ handler(req, res) {
3134
+ const key = effectiveKey(monitorFromQuery(req));
3135
+ const monitor = state.monitors.find((m) => m.key === key);
3136
+ if (monitor === void 0) {
3137
+ res.statusCode = 404;
3138
+ res.end("no wallpaper");
3139
+ return;
3140
+ }
3141
+ if (monitor.kind === "video" || monitor.kind === "image") {
3142
+ serveFile(monitor.sourceFile, monitor.mime !== "" ? monitor.mime : "application/octet-stream", req, res);
3143
+ return;
3144
+ }
3145
+ if (monitor.kind === "web") {
3146
+ serveFile(monitor.sourceFile, "text/html; charset=utf-8", req, res);
3147
+ return;
3148
+ }
3149
+ res.statusCode = 415;
3150
+ res.end("source not renderable: " + monitor.kind);
3151
+ }
3152
+ }));
3153
+ disposers.push(webServer.register({
3154
+ kind: "exact",
3155
+ path: "/we-sync/scene",
3156
+ handler(req, res) {
3157
+ const key = effectiveKey(monitorFromQuery(req));
3158
+ const monitor = state.monitors.find((m) => m.key === key);
3159
+ if (monitor === void 0 || monitor.kind !== "scene" || monitor.sceneImage === null) {
3160
+ res.statusCode = 404;
3161
+ res.end("no scene image");
3162
+ return;
3163
+ }
3164
+ const img = monitor.sceneImage;
3165
+ serveSlice(monitor.sourceFile, img.start, img.end, img.mime, req, res);
3166
+ }
3167
+ }));
3168
+ disposers.push(webServer.register({
3169
+ kind: "prefix",
3170
+ path: "/we-sync/wallpaper",
3171
+ handler(req, res) {
3172
+ const key = effectiveKey(monitorFromQuery(req));
3173
+ const monitor = state.monitors.find((m) => m.key === key);
3174
+ if (monitor === void 0 || monitor.kind !== "web") {
3175
+ res.statusCode = 404;
3176
+ res.end("no web wallpaper");
3177
+ return;
3178
+ }
3179
+ const dir = normalize(dirOf(monitor.sourceFile));
3180
+ const rel = (req.url ?? "").split("?")[0].replace(/^\/we-sync\/wallpaper\//, "");
3181
+ const target = normalize(dir + "/" + rel);
3182
+ if (!target.startsWith(dir + "/") || target.length <= dir.length + 1) {
3183
+ res.statusCode = 403;
3184
+ res.end("forbidden");
3185
+ return;
3186
+ }
3187
+ serveWebFile(dir, target, req, res);
3188
+ }
3189
+ }));
3190
+ disposers.push(webServer.register({
3191
+ kind: "exact",
3192
+ path: "/we-sync/diag",
3193
+ handler(_req, res) {
3194
+ const effKey = effectiveKey("");
3195
+ const effMonitor = state.monitors.find((m) => m.key === effKey);
3196
+ const adapterTarget = sceneAdapter?.getTarget() ?? null;
3197
+ const sceneKey = adapterTarget !== null ? adapterTarget.key : effKey;
3198
+ sendJson(res, {
3199
+ version: state.version,
3200
+ latestMonitor: state.latestMonitor,
3201
+ monitorCount: state.monitors.length,
3202
+ monitors: state.monitors.map((m) => ({
3203
+ key: m.key,
3204
+ file: m.file,
3205
+ kind: m.kind,
3206
+ sceneImage: m.sceneImage !== null ? {
3207
+ width: m.sceneImage.width,
3208
+ height: m.sceneImage.height,
3209
+ mime: m.sceneImage.mime
3210
+ } : null
3211
+ })),
3212
+ scene: sceneInfoFor(sceneKey),
3213
+ sceneTarget: effMonitor !== void 0 && effMonitor.kind === "scene" ? {
3214
+ key: effKey,
3215
+ file: effMonitor.file
3216
+ } : null,
3217
+ sceneAdapterTarget: adapterTarget !== null ? {
3218
+ key: adapterTarget.key,
3219
+ file: adapterTarget.file
3220
+ } : null,
3221
+ sceneModel: (() => {
3222
+ const m = getSceneModel(sceneKey);
3223
+ return m === null ? null : {
3224
+ width: m.width,
3225
+ height: m.height,
3226
+ layers: m.layerCount,
3227
+ textures: m.textures.length,
3228
+ decodableTextures: m.decodableTextureCount
3229
+ };
3230
+ })(),
3231
+ sceneMode: resolveSceneMode(),
3232
+ lastError: state.lastError,
3233
+ weDir: state.weDir
3234
+ });
3235
+ }
3236
+ }));
3237
+ disposers.push(webServer.register({
3238
+ kind: "exact",
3239
+ path: "/we-sync/preview",
3240
+ handler(req, res) {
3241
+ const key = effectiveKey(monitorFromQuery(req));
3242
+ const preview = key !== "" ? state.previews[key] : void 0;
3243
+ if (preview === void 0 || preview.bytes === null) {
3244
+ res.statusCode = 404;
3245
+ res.end("no preview: " + state.lastError);
3246
+ return;
3247
+ }
3248
+ res.statusCode = 200;
3249
+ res.setHeader("Content-Type", preview.mime);
3250
+ res.setHeader("Cache-Control", "no-store");
3251
+ res.end(Buffer.from(preview.bytes));
3252
+ }
3253
+ }));
3254
+ /** SceneModel JSON:浏览器子集渲染器(SceneModelRenderer)的数据源。
3255
+ * 返回归一化图层树(transform/visible/纹理引用链),并按指纹缓存。 */
3256
+ disposers.push(webServer.register({
3257
+ kind: "exact",
3258
+ path: "/we-sync/scene/model",
3259
+ handler(req, res) {
3260
+ const model = getSceneModel(effectiveKey(monitorFromQuery(req)));
3261
+ if (model === null) {
3262
+ res.statusCode = 404;
3263
+ res.end("no scene model");
3264
+ return;
3265
+ }
3266
+ sendJson(res, model);
3267
+ }
3268
+ }));
3269
+ /** SceneModel 纹理字节:仅提供 pkg 内可解码(jpg/png)条目,防止路径穿越 */
3270
+ disposers.push(webServer.register({
3271
+ kind: "exact",
3272
+ path: "/we-sync/scene/texture",
3273
+ handler(req, res) {
3274
+ const key = effectiveKey(monitorFromQuery(req));
3275
+ const monitor = state.monitors.find((m) => m.key === key);
3276
+ if (monitor === void 0 || monitor.kind !== "scene") {
3277
+ res.statusCode = 404;
3278
+ res.end("no scene wallpaper");
3279
+ return;
3280
+ }
3281
+ const match = /[?&]name=([^&]+)/.exec(req.url ?? "");
3282
+ if (match === null || match[1] === void 0) {
3283
+ res.statusCode = 400;
3284
+ res.end("missing name");
3285
+ return;
3286
+ }
3287
+ let name;
3288
+ try {
3289
+ name = decodeURIComponent(match[1]);
3290
+ } catch {
3291
+ name = "";
3292
+ }
3293
+ if (!/\.(tex|png|jpe?g)$/i.test(name)) {
3294
+ res.statusCode = 415;
3295
+ res.end("unsupported texture entry: " + name);
3296
+ return;
3297
+ }
3298
+ try {
3299
+ const pkg = parseScenePkg(new Uint8Array(readFileSync(monitor.file)));
3300
+ const entry = pkg.entries.find((e) => e.name === name);
3301
+ if (entry === void 0) {
3302
+ res.statusCode = 404;
3303
+ res.end("no such texture entry");
3304
+ return;
3305
+ }
3306
+ const absStart = pkg.dataStart + entry.offset;
3307
+ const absEnd = absStart + entry.size;
3308
+ if (/\.(png|jpe?g)$/i.test(name)) {
3309
+ const mime = /\.png$/i.test(name) ? "image/png" : "image/jpeg";
3310
+ serveSlice(monitor.file, absStart, absEnd, mime, req, res);
3311
+ return;
3312
+ }
3313
+ const tex = decodeTex(new Uint8Array(readFileSync(monitor.file).subarray(absStart, absEnd)));
3314
+ if (tex !== null && tex.mip0 !== null) {
3315
+ if (tex.imageWidth > 0 && tex.imageHeight > 0) {
3316
+ res.setHeader("X-WE-Image-W", String(tex.imageWidth));
3317
+ res.setHeader("X-WE-Image-H", String(tex.imageHeight));
3318
+ }
3319
+ const mime = texMimeOf(tex) ?? "image/png";
3320
+ if (tex.mip0.kind === "image-png" || tex.mip0.kind === "image-jpeg") serveSlice(monitor.file, absStart + tex.mip0.dataOffset, absStart + tex.mip0.dataOffset + tex.mip0.data.length, mime, req, res);
3321
+ else {
3322
+ const png = texMipToPng(tex);
3323
+ if (png === null) {
3324
+ res.statusCode = 500;
3325
+ res.end("tex decode failed: " + name);
3326
+ return;
3327
+ }
3328
+ res.statusCode = 200;
3329
+ res.setHeader("Content-Type", mime);
3330
+ res.setHeader("Cache-Control", "no-store");
3331
+ res.end(Buffer.from(png));
3332
+ }
3333
+ return;
3334
+ }
3335
+ res.statusCode = 415;
3336
+ res.end("tex decode failed: " + name);
3337
+ } catch {
3338
+ res.statusCode = 500;
3339
+ res.end("pkg read failed");
3340
+ }
3341
+ }
3342
+ }));
3343
+ /** 引擎资产纹理(粒子等):<weDir>/assets/materials/<name>.tex → 解码为 PNG。
3344
+ * name 如 particle/fog/fog1(材质 textures 的相对路径) */
3345
+ disposers.push(webServer.register({
3346
+ kind: "exact",
3347
+ path: "/we-sync/asset/texture",
3348
+ handler(req, res) {
3349
+ const match = /[?&]name=([^&]+)/.exec(req.url ?? "");
3350
+ if (match === null || match[1] === void 0) {
3351
+ res.statusCode = 400;
3352
+ res.end("missing name");
3353
+ return;
3354
+ }
3355
+ let name;
3356
+ try {
3357
+ name = decodeURIComponent(match[1]);
3358
+ } catch {
3359
+ name = "";
3360
+ }
3361
+ if (!/^[a-zA-Z0-9_\/\-\.]+$/.test(name) || name.includes("..") || state.weDir === "") {
3362
+ res.statusCode = 403;
3363
+ res.end("forbidden");
3364
+ return;
3365
+ }
3366
+ try {
3367
+ const tex = decodeTex(new Uint8Array(readFileSync(state.weDir + "/assets/materials/" + name + ".tex")));
3368
+ const png = tex !== null ? texMipToPng(tex) : null;
3369
+ if (png === null) {
3370
+ res.statusCode = 415;
3371
+ res.end("asset tex decode failed: " + name);
3372
+ return;
3373
+ }
3374
+ res.statusCode = 200;
3375
+ res.setHeader("Content-Type", "image/png");
3376
+ res.setHeader("Cache-Control", "no-store");
3377
+ res.end(Buffer.from(png));
3378
+ } catch {
3379
+ res.statusCode = 404;
3380
+ res.end("no such asset texture: " + name);
3381
+ }
3382
+ }
3383
+ }));
3384
+ /** scene 帧流 WebSocket:SceneCanvas 连到此路由接收二进制帧。
3385
+ * 连接时按 ?monitor= 锁定渲染目标(空 = 跟随生效显示器)。 */
3386
+ disposers.push(webServer.registerUpgrade({
3387
+ path: "/we-sync/scene/stream",
3388
+ handler(req, socket, head) {
3389
+ if (sceneAdapter === null) {
3390
+ try {
3391
+ socket.destroy();
3392
+ } catch {}
3393
+ return;
3394
+ }
3395
+ const key = monitorFromQuery(req);
3396
+ const target = sceneTargetFor(key !== "" ? key : effectiveKey(""));
3397
+ if (target !== null) sceneAdapter.setTarget(target);
3398
+ sceneAdapter.hub.handleUpgrade(req, socket, head);
3399
+ }
3400
+ }));
3401
+ /** 壁纸源服务器:把当前 web 壁纸目录作为独立源伺服(127.0.0.1 临时端口)。
3402
+ * Spine/WebGL 类壁纸在 iframe 里需要"自己的同源"才能渲染(贴图不 tainted、
3403
+ * ES module / fetch / import() 全通),且与 DSH 主源(3080)隔离,无安全后门。 */
3404
+ let sourceServer = null;
3405
+ let webPort = 0;
3406
+ try {
3407
+ sourceServer = createServer((req, res) => {
3408
+ const key = effectiveKey(monitorFromQuery(req));
3409
+ const monitor = state.monitors.find((m) => m.key === key);
3410
+ if (monitor === void 0 || monitor.kind !== "web") {
3411
+ res.statusCode = 404;
3412
+ res.end("no web wallpaper");
3413
+ return;
3414
+ }
3415
+ const dir = normalize(dirOf(monitor.sourceFile));
3416
+ const rel = (req.url ?? "").split("?")[0].replace(/^\/+/, "");
3417
+ const target = normalize(dir + "/" + rel);
3418
+ if (!target.startsWith(dir + "/") || target.length <= dir.length + 1) {
3419
+ res.statusCode = 403;
3420
+ res.end("forbidden");
3421
+ return;
3422
+ }
3423
+ serveWebFile(dir, target, req, res);
3424
+ });
3425
+ sourceServer.listen(0, "127.0.0.1", () => {
3426
+ const addr = sourceServer?.address();
3427
+ if (addr !== null && typeof addr === "object") webPort = addr.port;
3428
+ });
3429
+ disposers.push(() => {
3430
+ if (sourceServer !== null) try {
3431
+ sourceServer.close();
3432
+ } catch {}
3433
+ });
3434
+ } catch {
3435
+ webPort = 0;
3436
+ }
3437
+ const detected = detectWeDir();
3438
+ if (detected === null) {
3439
+ state.lastError = "未找到 Wallpaper Engine 安装目录:请在 dsh-wallpaper_share 包源码的 CONFIG.wallpaperEngineDir 手动指定";
3440
+ return;
3441
+ }
3442
+ state.weDir = detected;
3443
+ sceneAdapter = new SceneAdapter({
3444
+ config: {
3445
+ sceneRendererPath: CONFIG.sceneRendererPath,
3446
+ wallpaperEngineAssetsDir: CONFIG.wallpaperEngineAssetsDir,
3447
+ width: CONFIG.sceneRenderWidth,
3448
+ height: CONFIG.sceneRenderHeight,
3449
+ fps: CONFIG.sceneRenderFps,
3450
+ quality: CONFIG.sceneRenderQuality
3451
+ },
3452
+ weDir: detected,
3453
+ log: (line) => console.log(line)
3454
+ });
3455
+ disposers.push(() => {
3456
+ sceneAdapter?.dispose();
3457
+ sceneAdapter = null;
3458
+ });
3459
+ ctx.effect(() => {
3460
+ const timer = setInterval(() => poll(detected), CONFIG.pollIntervalMs);
3461
+ poll(detected);
3462
+ return () => clearInterval(timer);
3463
+ });
3464
+ }
3465
+ //#endregion
3466
+ export { apply, inject };