modelstage 0.5.28 → 0.6.1
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/CHANGELOG.md +83 -0
- package/README.md +2 -63
- package/config/models/live2d.assetbundls.json +1 -1
- package/config/models/live2d.azurlane.json +1 -1
- package/config/models/live2d.azurlane_bundle.json +1 -1
- package/config/models/live2d.azurlane_bundle.jsonl +2 -0
- package/config/models/live2d.azurlane_spine.json +1 -1
- package/config/models/live2d.bd2.json +1 -1
- package/config/models/live2d.bd2.jsonl +2 -0
- package/config/models/live2d.bluearchive.json +1 -1
- package/config/models/live2d.nikke.json +1 -1
- package/config/models/live2d.nikke.jsonl +2 -0
- package/config/models/live2d.nikke_2.json +1 -1
- package/config/models/live2d.npc.json +1 -1
- package/config/models/spine.assetbundls.json +1 -1
- package/config/models/spine.azurlane.json +1 -1
- package/config/models/spine.azurlane_bundle.json +1 -1
- package/config/models/spine.azurlane_bundle.jsonl +2 -0
- package/config/models/spine.azurlane_spine.json +1 -1
- package/config/models/spine.bd2.json +1 -1
- package/config/models/spine.bd2.jsonl +433 -0
- package/config/models/spine.bluearchive.json +1 -1
- package/config/models/spine.nikke.json +1 -1
- package/config/models/spine.nikke_2.json +1 -1
- package/config/models/spine.npc.json +1 -1
- package/package.json +2 -1
- package/src/server/assets/probe.mjs +1 -1
- package/src/server/core/config.mjs +2 -2
- package/src/server/core/log.mjs +1 -1
- package/src/server/data/catalog.mjs +11 -14
- package/src/server/data/live2d.mjs +139 -40
- package/src/server/data/manifest-cache.mjs +53 -10
- package/src/server/data/manifest-jsonl.mjs +264 -0
- package/src/server/data/manifest-stamp.mjs +52 -2
- package/src/server/data/scan-progress.mjs +305 -0
- package/src/server/data/scan-queue.mjs +99 -0
- package/src/server/data/spine.mjs +324 -106
- package/src/server/plugins/registry.mjs +1 -1
- package/src/server/routes/data.mjs +76 -17
- package/src/server/serve.mjs +9 -4
- package/src/server/unpack/plugin-store.mjs +1 -1
- package/src/server/unpack/runtime/exec.mjs +37 -8
- package/src/server/unpack/worker-client.mjs +23 -8
- package/src/shared/scan-limits.mjs +23 -0
- package/src/web/app/anim-list.js +1 -1
- package/src/web/app/backgrounds.js +4 -8
- package/src/web/app/dir-picker.js +3 -5
- package/src/web/app/memory-budget.js +0 -1
- package/src/web/app/memory-guard.js +0 -1
- package/src/web/app/model-actions.js +18 -15
- package/src/web/app/root-manager.js +2 -4
- package/src/web/app/transport-actions.js +1 -1
- package/src/web/app/ui-state.js +1 -1
- package/src/web/components.css +90 -0
- package/src/web/debug/race-probe.js +36 -13
- package/src/web/engine/textures.js +1 -2
- package/src/web/infra/api.js +10 -19
- package/src/web/infra/document.js +3 -3
- package/src/web/infra/plugin-host-unpack.mjs +81 -9
- package/src/web/infra/ready.js +177 -39
- package/src/web/live2d/bridge.js +1 -1
- package/src/web/live2d/runtime-adapter.js +2 -4
- package/src/web/panel/panel-settings.js +88 -3
- package/src/web/roster/events.js +159 -0
- package/src/web/roster/filter.js +4 -4
- package/src/web/roster/list.js +3 -3
- package/src/web/roster/load.js +440 -0
- package/src/web/roster/persist.js +46 -37
- package/src/web/roster/render.js +284 -0
- package/src/web/roster/reveal.js +4 -9
- package/src/web/roster/rows.js +35 -13
- package/src/web/roster/scan-stream.js +346 -0
- package/src/web/roster/snapshot.js +14 -8
- package/src/web/roster/store.js +292 -0
- package/src/web/roster/type-filter.js +5 -5
- package/src/web/roster/view.js +147 -634
- package/src/web/spine/canvas-runtime.js +4 -5
- package/src/web/spine/runtimes.js +1 -1
|
@@ -17,6 +17,12 @@ import { live2dGuide } from '../../../tool/lib/runtime-guides.mjs';
|
|
|
17
17
|
import { readRatioEnv } from '../core/env-num.mjs';
|
|
18
18
|
// 插槽目录(S1-a):与校验同源,见 plugins/slot-catalog.mjs
|
|
19
19
|
import { SLOT_CATALOG, CURRENT_STAGE } from '../plugins/slot-catalog.mjs';
|
|
20
|
+
// 扫描进度:单飞表 + NDJSON 帧写出(P0;设计 §4.6/§5/§6)
|
|
21
|
+
import { createScanRunner, streamScanFrames } from '../data/scan-progress.mjs';
|
|
22
|
+
|
|
23
|
+
// 扫描单飞表:**模块级一份**(不是每请求一份)—— 单飞的意义就是"同一时刻同一个根只扫一次"。
|
|
24
|
+
// P1 的 JSONL 存储会原样复用这张表(设计 §4.6)。
|
|
25
|
+
const scanRunner = createScanRunner();
|
|
20
26
|
|
|
21
27
|
/** 资源根 / 插件 / 品牌 / 前端配置 */
|
|
22
28
|
function apiBasics(ctx, res, p) {
|
|
@@ -69,7 +75,7 @@ function apiBasics(ctx, res, p) {
|
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
/** 目录 / 模型清单 / 名表 / 配置导出 / 目录浏览 */
|
|
72
|
-
function apiData(ctx, res, p, url) {
|
|
78
|
+
async function apiData(ctx, res, p, url) {
|
|
73
79
|
const { data, sendJson, browseDir } = ctx;
|
|
74
80
|
if (p === '/api/tree') {
|
|
75
81
|
const r = data.tree.listTree(url.searchParams.get('root') || '', url.searchParams.get('path') || '');
|
|
@@ -84,19 +90,70 @@ function apiData(ctx, res, p, url) {
|
|
|
84
90
|
const force = url.searchParams.get('refresh') === '1';
|
|
85
91
|
const cfg = data.config.readConfig().find((r) => r.id === rootId);
|
|
86
92
|
if (!cfg) return sendJson(res, 400, { error: `未找到资源根: ${rootId}` });
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
const isSpine = p === '/api/spine/models';
|
|
94
|
+
// 不带 stream=1:仍走原来的 sendJson —— 这段响应必须**逐字节不变**(验收 ①)
|
|
95
|
+
if (url.searchParams.get('stream') !== '1') {
|
|
96
|
+
try {
|
|
97
|
+
const r = isSpine ? await data.spine.getSpineModels(cfg, force) : await data.live2d.getLive2DModels(cfg, force);
|
|
98
|
+
return sendJson(res, 200, {
|
|
99
|
+
root: { id: cfg.id, name: cfg.name },
|
|
100
|
+
count: r.models.length,
|
|
101
|
+
cached: r.cached,
|
|
102
|
+
scannedAt: new Date(r.ts).toISOString(),
|
|
103
|
+
models: r.models,
|
|
104
|
+
});
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return sendJson(res, 500, { error: `${p === '/api/spine/models' ? 'Spine 扫描失败' : 'Live2D 扫描失败'}: ${e.message}` });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── stream=1:NDJSON 逐帧写进度。模型数据仍走上面的 JSON 接口(P0 只发进度,见设计 §6.3)
|
|
111
|
+
const kind = isSpine ? 'spine' : 'live2d';
|
|
112
|
+
const startedAt = Date.now();
|
|
113
|
+
const joined = scanRunner.startOrJoin({
|
|
114
|
+
kind,
|
|
115
|
+
rootId: cfg.id,
|
|
116
|
+
// onModel:扫描每产出一条模型就转发成 model 帧 —— 这是"首模型早出现"的关键
|
|
117
|
+
run: (onProgress, onStart, onModel) => (isSpine
|
|
118
|
+
? data.spine.getSpineModels(cfg, force, { onProgress, onStart, onModel })
|
|
119
|
+
: data.live2d.getLive2DModels(cfg, force, onProgress, onStart, onModel)),
|
|
120
|
+
});
|
|
121
|
+
// 加入已有扫描时,把当前快照立刻推给这个新连接(设计 §4.6:后续请求不必等下一次节流)
|
|
122
|
+
if (joined.joined && joined.progress) {
|
|
123
|
+
setImmediate(() => {
|
|
124
|
+
try { joined.emitter.emit('progress', joined.progress); } catch { /* 订阅者可能已走 */ }
|
|
96
125
|
});
|
|
97
|
-
} catch (e) {
|
|
98
|
-
return sendJson(res, 500, { error: `${p === '/api/spine/models' ? 'Spine 扫描失败' : 'Live2D 扫描失败'}: ${e.message}` });
|
|
99
126
|
}
|
|
127
|
+
// **不 await**:这是一条长连接(扫描结束时才 res.end()),调用方不该被它挂住。
|
|
128
|
+
// 若改成 await,handleData 就得变 async —— 而它的调用方写的是 `if (handleData(...))`,
|
|
129
|
+
// Promise 恒真会让**所有请求都被它认领**(这是个真实踩到过的坑,见本文件末尾注释)。
|
|
130
|
+
streamScanFrames({
|
|
131
|
+
res,
|
|
132
|
+
emitter: joined.emitter,
|
|
133
|
+
buildStartFrame: () => ({
|
|
134
|
+
type: 'start',
|
|
135
|
+
kind,
|
|
136
|
+
rootId: cfg.id,
|
|
137
|
+
rootPath: cfg.path,
|
|
138
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
139
|
+
}),
|
|
140
|
+
buildDoneFrame: (result) => ({
|
|
141
|
+
type: 'done',
|
|
142
|
+
count: result && result.models ? result.models.length : 0,
|
|
143
|
+
cached: false,
|
|
144
|
+
elapsedMs: Date.now() - startedAt,
|
|
145
|
+
}),
|
|
146
|
+
buildCachedFrame: (result) => ({
|
|
147
|
+
type: 'cached',
|
|
148
|
+
count: result && result.models ? result.models.length : 0,
|
|
149
|
+
ts: result && result.ts,
|
|
150
|
+
}),
|
|
151
|
+
}).catch((e) => {
|
|
152
|
+
// 帧写出阶段出意外(例如 header 已发再抛):尽量收尾,别留下半开连接
|
|
153
|
+
try { res.end(); } catch { /* 已断开 */ }
|
|
154
|
+
if (ctx.log && ctx.log.warn) ctx.log.warn('扫描进度流写出异常', { root: cfg.id, err: (e && e.message) || e });
|
|
155
|
+
});
|
|
156
|
+
return true;
|
|
100
157
|
}
|
|
101
158
|
if (p === '/api/names') return apiNames(ctx, res, url);
|
|
102
159
|
// /api/config/export 与 /api/config/import 同属配置组,见 routes/config.mjs(此处不重复实现)
|
|
@@ -137,7 +194,7 @@ function apiNames(ctx, res, url) {
|
|
|
137
194
|
}
|
|
138
195
|
|
|
139
196
|
/** 需要按需 import 的三条(catalog / bundle probe / model meta) */
|
|
140
|
-
function apiAsync(ctx, req, res, p, url) {
|
|
197
|
+
async function apiAsync(ctx, req, res, p, url) {
|
|
141
198
|
const { data, sendJson } = ctx;
|
|
142
199
|
const done = (r) => sendJson(res, r.status, r.body);
|
|
143
200
|
const fail = (label) => (e) => sendJson(res, 500, { ok: false, error: `${label}: ${e.message}` });
|
|
@@ -157,7 +214,7 @@ function apiAsync(ctx, req, res, p, url) {
|
|
|
157
214
|
const known = data.config.readConfig().map((r) => r.id);
|
|
158
215
|
return sendJson(res, 400, { ok: false, error: '缺少 root 参数(?root=<资源根 id>)', known });
|
|
159
216
|
}
|
|
160
|
-
return done(data.catalog.listCatalog(rootId));
|
|
217
|
+
return done(await data.catalog.listCatalog(rootId));
|
|
161
218
|
}
|
|
162
219
|
if (p === '/api/bundle/probe') {
|
|
163
220
|
data.bundleProbe.probeBundle(url.searchParams.get('root') || '', url.searchParams.get('path') || '')
|
|
@@ -289,7 +346,7 @@ function serveLive2DLib(ctx, req, res, p) {
|
|
|
289
346
|
* @param {object} ctx 需要 data/fs/path/sendJson/sendFile/resolveSafe/browseDir/toRootView/writeApi/readBody
|
|
290
347
|
* @returns {boolean} 是否已处理
|
|
291
348
|
*/
|
|
292
|
-
export function handleData(req, res, p, url, ctx) {
|
|
349
|
+
export async function handleData(req, res, p, url, ctx) {
|
|
293
350
|
const { sendJson, readBody, writeApi } = ctx;
|
|
294
351
|
|
|
295
352
|
// 资源根写操作(增 / 删 / 改名 / 启停):校验 → 备份 → 写 → 失效缓存
|
|
@@ -303,8 +360,10 @@ export function handleData(req, res, p, url, ctx) {
|
|
|
303
360
|
return true;
|
|
304
361
|
}
|
|
305
362
|
if (apiBasics(ctx, res, p)) return true;
|
|
306
|
-
|
|
307
|
-
if (
|
|
363
|
+
// await:apiData 内部要等扫描(async 化后),但**返回的仍是 boolean** ——
|
|
364
|
+
// 不能写成 `if (apiData(...))`(Promise 恒真会让所有 /api 请求都被它认领,上一轮踩过)
|
|
365
|
+
if (await apiData(ctx, res, p, url)) return true;
|
|
366
|
+
if (await apiAsync(ctx, req, res, p, url)) return true;
|
|
308
367
|
if (serveModelFile(ctx, req, res, p, url)) return true;
|
|
309
368
|
if (serveIcon(ctx, req, res, p)) return true;
|
|
310
369
|
if (serveAvatar(ctx, req, res, p)) return true;
|
package/src/server/serve.mjs
CHANGED
|
@@ -31,6 +31,8 @@ import { createLogger } from './core/log.mjs';
|
|
|
31
31
|
import { createUnpackStore } from './unpack/plugin-store.mjs';
|
|
32
32
|
import { createUnpackRoutes } from './unpack/routes.mjs';
|
|
33
33
|
import { handleData } from './routes/data.mjs';
|
|
34
|
+
// 扫描并发上限与让路提示(P0 退化修复);serve 只负责把"在途请求数"同步进去
|
|
35
|
+
import { noteRequestStart, noteRequestEnd } from './data/scan-queue.mjs';
|
|
34
36
|
import { handleConfig } from './routes/config.mjs';
|
|
35
37
|
import { handleDiag } from './routes/diag.mjs';
|
|
36
38
|
import { handleReady } from './routes/ready.mjs';
|
|
@@ -281,7 +283,7 @@ function handleHealth(req, res, p) {
|
|
|
281
283
|
return true;
|
|
282
284
|
}
|
|
283
285
|
|
|
284
|
-
function dispatch(req, res, p, url) {
|
|
286
|
+
async function dispatch(req, res, p, url) {
|
|
285
287
|
// 探活放最前:它不该被其它路由的鉴权/副作用影响(也就不会因为那些逻辑出错而"连探活都挂")
|
|
286
288
|
if (handleHealth(req, res, p)) return true;
|
|
287
289
|
if (unpackRoutes(req, res, p, url)) return true;
|
|
@@ -294,7 +296,8 @@ function dispatch(req, res, p, url) {
|
|
|
294
296
|
if (handleBackgrounds(req, res, p, ctx)) return true;
|
|
295
297
|
// 条目状态(写读一体):必须在 handleData 之前认领,否则 /api/ 前缀兜底会当未知接口 404
|
|
296
298
|
if (handleEntries(req, res, p, url, ctx)) return true;
|
|
297
|
-
|
|
299
|
+
// handleData 已 async 化(内部要等扫描):必须 await,否则 Promise 恒真会让所有请求都被它认领
|
|
300
|
+
if (await handleData(req, res, p, url, ctx)) return true;
|
|
298
301
|
if (staticRoutes.serveAssets(req, res, p)) return true;
|
|
299
302
|
if (res.headersSent) return true;
|
|
300
303
|
if (p.startsWith('/api/')) {
|
|
@@ -306,25 +309,27 @@ function dispatch(req, res, p, url) {
|
|
|
306
309
|
return true;
|
|
307
310
|
}
|
|
308
311
|
|
|
309
|
-
const server = http.createServer((req, res) => {
|
|
312
|
+
const server = http.createServer(async (req, res) => {
|
|
310
313
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
311
314
|
// 长连接(SSE)不计入活跃度:它一挂几分钟,计入就等于"服务永远忙"
|
|
312
315
|
const longLived = url.pathname.includes('/stream');
|
|
313
316
|
if (!longLived) {
|
|
314
317
|
activity.inflight += 1;
|
|
318
|
+
noteRequestStart(); // 同一个事实同步给扫描调度(它据此让路)
|
|
315
319
|
activity.lastRequestAt = Date.now();
|
|
316
320
|
let counted = true;
|
|
317
321
|
const done = () => {
|
|
318
322
|
if (!counted) return; // close 与 finish 都会来,只减一次
|
|
319
323
|
counted = false;
|
|
320
324
|
activity.inflight -= 1;
|
|
325
|
+
noteRequestEnd(); // 必须与 noteRequestStart 成对(放在同一个 done 回调里)
|
|
321
326
|
activity.lastRequestAt = Date.now();
|
|
322
327
|
};
|
|
323
328
|
res.on('finish', done);
|
|
324
329
|
res.on('close', done);
|
|
325
330
|
}
|
|
326
331
|
try {
|
|
327
|
-
dispatch(req, res, url.pathname, url);
|
|
332
|
+
await dispatch(req, res, url.pathname, url);
|
|
328
333
|
} catch (e) {
|
|
329
334
|
log.error('请求处理异常', { err: (e && e.stack) ? e.stack : String(e) });
|
|
330
335
|
try {
|
|
@@ -287,7 +287,7 @@ export function createUnpackStore(opts) {
|
|
|
287
287
|
|
|
288
288
|
// 解包 worker 客户端:**常驻 1 个**(与既有串行队列一致,不并发解包)。
|
|
289
289
|
// 硬超时 120s 只用于"防死循环把队列永久堵住",不是"约束合法解包" ——
|
|
290
|
-
//
|
|
290
|
+
// 实测大体积 LZMA 包也在 20s 内完成,
|
|
291
291
|
// 120s 有 6.7 倍余量,正常解包一辈子不该触发它。
|
|
292
292
|
// ⚠️ 别把这个 120s 与"客户端不设总时长上限"当成矛盾,两者防的不是一回事:
|
|
293
293
|
// 这里防的是「worker 死循环把串行队列永久堵死」(服务端自保,与有没有人看无关);
|
|
@@ -162,6 +162,30 @@ function expandPlaceholder(value, opts = {}) {
|
|
|
162
162
|
* @param {object} meta 插件清单
|
|
163
163
|
* @returns {string} 解释器路径或命令名
|
|
164
164
|
*/
|
|
165
|
+
/** PATH 里的目录(空段过滤掉:空串会让 path.join 拼出当前目录,那是安全隐患) */
|
|
166
|
+
function pathDirs() {
|
|
167
|
+
return String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 按**名字**在 PATH 里找可执行文件,返回绝对路径;找不到返回 null。
|
|
172
|
+
*
|
|
173
|
+
* 为什么不能写一串"常见安装路径":那样换机器(或换 Node/Python 安装方式)就失效,
|
|
174
|
+
* 而且会让"这个解释器一定在 /usr/local/bin"变成隐含假设 —— 这正是要被消灭的形态。
|
|
175
|
+
* PATH 是运行时的权威,问它就行。
|
|
176
|
+
* @param {string} binaryName 不带路径的命令名(如 'python3.14')
|
|
177
|
+
* @returns {string|null}
|
|
178
|
+
*/
|
|
179
|
+
function findInPath(binaryName) {
|
|
180
|
+
for (const dir of pathDirs()) {
|
|
181
|
+
const candidate = path.join(dir, binaryName);
|
|
182
|
+
try {
|
|
183
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
184
|
+
} catch { /* 该 PATH 目录不可读:跳过,继续下一个 */ }
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
165
189
|
let interpreterProbeCache = null;
|
|
166
190
|
function resolveInterpreter(meta) {
|
|
167
191
|
// ① 环境变量最优先(部署期覆盖,便于换机/换版本而不改清单)
|
|
@@ -171,14 +195,16 @@ function resolveInterpreter(meta) {
|
|
|
171
195
|
const declared = meta && meta.interpreter ? String(meta.interpreter) : '';
|
|
172
196
|
if (declared) return declared;
|
|
173
197
|
if (interpreterProbeCache) return interpreterProbeCache;
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
198
|
+
// ③ 按名字在 PATH 里探(**不写死任何安装位置**)。
|
|
199
|
+
// 候选集合**严格沿用**改动前的那三个(python3.14 → python3.10 → python3),
|
|
200
|
+
// 按"新到旧"排:新版本装的依赖不保证能在旧版本里 import。
|
|
201
|
+
// 只扩大候选集合(比如补上 3.13/3.12/3.11)会改变"选中哪个解释器"这个结果,本批要求行为不变,故不加。
|
|
202
|
+
for (const binaryName of ['python3.14', 'python3.10', 'python3']) {
|
|
203
|
+
const found = findInPath(binaryName);
|
|
204
|
+
if (found) {
|
|
205
|
+
interpreterProbeCache = found;
|
|
206
|
+
return found;
|
|
207
|
+
}
|
|
182
208
|
}
|
|
183
209
|
// 都没探到 ⇒ 交给 PATH(spawn 会报 ENOENT,错误信息里带命令名,够定位)
|
|
184
210
|
interpreterProbeCache = 'python3';
|
|
@@ -218,6 +244,9 @@ class ExecWorker {
|
|
|
218
244
|
if (this.ready) return this.ready;
|
|
219
245
|
this.ready = new Promise((resolve, reject) => {
|
|
220
246
|
const interpreter = resolveInterpreter(this.meta);
|
|
247
|
+
// 记一行解释器路径:spawn 失败(ENOENT / 版本不对 / 依赖没装)时,日志里能直接看到
|
|
248
|
+
// "用哪个 python 起的"——否则只能从报错反推,多绕一圈。
|
|
249
|
+
if (this.log) this.log('info', `exec worker 使用解释器:${interpreter}`, { plugin: this.pluginId || null });
|
|
221
250
|
const entryAbs = path.join(this.pluginDir, String(this.meta.entry || 'worker.py'));
|
|
222
251
|
if (!fs.existsSync(entryAbs)) {
|
|
223
252
|
this.ready = null;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// 硬超时语义(写死在注释里,避免被误读成"包太大被拒"):
|
|
13
13
|
// 120s 只用于**防死循环把队列永久堵住**,不是"约束合法解包"。
|
|
14
|
-
//
|
|
14
|
+
// 实测大体积 LZMA 包也在 20s 内完成,
|
|
15
15
|
// 120s 有 6.7 倍余量 —— 正常解包一辈子不该触发它。
|
|
16
16
|
|
|
17
17
|
import fs from 'node:fs';
|
|
@@ -20,6 +20,12 @@ import { Worker } from 'node:worker_threads';
|
|
|
20
20
|
|
|
21
21
|
const WORKER_URL = new URL('./unpack-worker.mjs', import.meta.url);
|
|
22
22
|
|
|
23
|
+
// worker 单次解包的默认超时。
|
|
24
|
+
// 语义:**worker 活着但不响应**的上限(卡住),不是"崩溃检测"——崩溃由 exit 事件立即结算。
|
|
25
|
+
// 为什么 120s:最大的包(几十 MB + LZMA 解压 + 贴图解码)实测在 30~60s 量级,
|
|
26
|
+
// 留一倍余量避免正常慢包被误杀;更长的等待意味着真卡住,继续等没有收益。
|
|
27
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
28
|
+
|
|
23
29
|
/**
|
|
24
30
|
* @param {{
|
|
25
31
|
* rootDir: string,
|
|
@@ -30,7 +36,10 @@ const WORKER_URL = new URL('./unpack-worker.mjs', import.meta.url);
|
|
|
30
36
|
export function createUnpackWorkerClient(opts) {
|
|
31
37
|
const rootDir = opts && opts.rootDir;
|
|
32
38
|
if (!rootDir) throw new Error('createUnpackWorkerClient: rootDir 必填');
|
|
33
|
-
|
|
39
|
+
// workerUrl 可注入:仅用于测试「worker 启动即崩溃」这条路径。
|
|
40
|
+
// 生产调用方不传 ⇒ 用模块级常量,行为与改动前逐字一致。
|
|
41
|
+
const workerUrl = (opts && opts.workerUrl) || WORKER_URL;
|
|
42
|
+
const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : DEFAULT_TIMEOUT_MS;
|
|
34
43
|
const log = typeof opts.log === 'function' ? opts.log : () => {};
|
|
35
44
|
|
|
36
45
|
/** @type {Worker|null} */
|
|
@@ -101,22 +110,28 @@ export function createUnpackWorkerClient(opts) {
|
|
|
101
110
|
|
|
102
111
|
function ensureWorker() {
|
|
103
112
|
if (worker) return worker;
|
|
104
|
-
const w = new Worker(
|
|
113
|
+
const w = new Worker(workerUrl, { workerData: { rootDir } });
|
|
105
114
|
w.on('message', onMessage);
|
|
106
115
|
w.on('error', (e) => {
|
|
107
116
|
// worker 自己抛未捕获异常 ⇒ 该次请求 reject、句柄置空、下次重建
|
|
108
117
|
failCurrent('unpack worker crashed', (e && e.message) || e);
|
|
109
118
|
worker = null;
|
|
110
119
|
});
|
|
111
|
-
w.on('exit', (code) => {
|
|
120
|
+
w.on('exit', (code, signal) => {
|
|
112
121
|
// 只有"它仍是当前句柄"才算意外退出:超时/close 路径会先把 worker 置空再 terminate,
|
|
113
122
|
// 所以这里判断句柄归属比维护一个全局 terminating 标志更硬 —— 不依赖 exit 与 finally 的先后。
|
|
114
123
|
const wasCurrent = worker === w;
|
|
115
124
|
if (wasCurrent) worker = null;
|
|
116
125
|
if (!wasCurrent) return;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
// 为什么 code === 0 也要让在途请求失败:worker 正常走完生命周期就不会再回消息了,
|
|
127
|
+
// 不 reject 的话这个请求要白等到 120s 超时,而且报出来的是"timeout"——
|
|
128
|
+
// 把"已退出"误报成"卡住",排查方向直接跑偏(这两类要分开)。
|
|
129
|
+
// 没有在途请求时 failCurrent 内部会早退,所以这里无条件调用是安全的。
|
|
130
|
+
const hadPending = Boolean(current);
|
|
131
|
+
failCurrent('unpack worker 已退出', `code=${code === null ? 'null' : code}, signal=${signal || 'none'}`);
|
|
132
|
+
// 日志只在"异常退出"或"有请求被牵连"时打,避免正常退出刷日志
|
|
133
|
+
if (code !== 0 || hadPending) {
|
|
134
|
+
log('warn', `解包 worker 已退出(code=${code === null ? 'null' : code}, signal=${signal || 'none'}),已丢弃句柄,下次请求将重建`);
|
|
120
135
|
}
|
|
121
136
|
});
|
|
122
137
|
worker = w;
|
|
@@ -149,7 +164,7 @@ export function createUnpackWorkerClient(opts) {
|
|
|
149
164
|
Promise.resolve(dead && dead.terminate && dead.terminate()).catch(() => undefined);
|
|
150
165
|
// 清理该 fp 的残留临时目录(writeResult 的 .tmp-<fp>-* 命名规则)
|
|
151
166
|
cleanupTmp(fp);
|
|
152
|
-
reject(new Error(`unpack worker
|
|
167
|
+
reject(new Error(`unpack worker 无响应:超过 ${timeoutMs}ms 未返回(worker 仍在运行但卡住了,已终止并重建;与「worker 已退出」是两类问题)`));
|
|
153
168
|
}, timeoutMs);
|
|
154
169
|
current = { id, resolve, reject, timer, onProgress: typeof opts.onProgress === 'function' ? opts.onProgress : null };
|
|
155
170
|
try {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// shared/scan-limits.mjs —— 扫描判死的阈值(**单一来源**)
|
|
2
|
+
//
|
|
3
|
+
// 为什么放 shared:前端要用它判"多久没进度就算疑似卡住",将来服务端/插件也可能要读同一个数。
|
|
4
|
+
// 两处各写一份数字,调参时必然漂移(一处改了另一处没改,表现为"前端说卡住、服务端还在跑")。
|
|
5
|
+
//
|
|
6
|
+
// 口径(设计 §7.3):
|
|
7
|
+
// · 这是**无进展**阈值,不是总时长阈值:大目录本来就慢,用总时长判死会误杀正常扫描。
|
|
8
|
+
// · 每次收到任意一帧(start / progress / done / cached / error)都会重置计时。
|
|
9
|
+
// · 触发后只改状态与文案,**不自动取消请求** —— 是否重试交给调用方决定。
|
|
10
|
+
|
|
11
|
+
/** 连续无帧多久 ⇒ 判"疑似卡住"(毫秒) */
|
|
12
|
+
export const SCAN_STALL_MS = 10000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 解析可覆盖值(来自 `/api/web-config` 的 scanStallMs 或测试注入)。
|
|
16
|
+
* 非法/缺失一律回落默认:判死阈值必须始终有个可用数字,不能因为配置脏就变成 NaN。
|
|
17
|
+
* @param {unknown} raw
|
|
18
|
+
* @returns {number}
|
|
19
|
+
*/
|
|
20
|
+
export function resolveScanStallMs(raw) {
|
|
21
|
+
const value = Number(raw);
|
|
22
|
+
return Number.isFinite(value) && value > 0 ? value : SCAN_STALL_MS;
|
|
23
|
+
}
|
package/src/web/app/anim-list.js
CHANGED
|
@@ -125,7 +125,7 @@ export function syncTransportOptions() {
|
|
|
125
125
|
* 为什么需要:动作是**模型属性**(设计 §1.9),但它的三个副本都是应用层状态,不会自己跟着模型走:
|
|
126
126
|
* · `cycle.currentId` —— 循环推进用,名字命中新清单就会继续按旧项推进
|
|
127
127
|
* · `state.transportMotion` —— 底栏下拉的副本,被 `currentAnimationId()` 优先读取
|
|
128
|
-
* · 底栏下拉自身的值 ——
|
|
128
|
+
* · 底栏下拉自身的值 —— 新模型若有同名动作,会被误当成"上次选的那个",
|
|
129
129
|
* "保留同名项"的规则会让它原样停在上一个模型选的那条 ⇒ 新模型**真的播它**
|
|
130
130
|
* 三者不清,就会出现"选了 home、切哪个模型都播 home"。
|
|
131
131
|
*/
|
|
@@ -42,16 +42,14 @@ export function describeFailure(body, status) {
|
|
|
42
42
|
/**
|
|
43
43
|
* 统一请求封装:解析 JSON、把失败翻成 {ok:false, reason, message}。
|
|
44
44
|
* @param {string} url 目标地址
|
|
45
|
-
* @param {object} [init] fetch
|
|
45
|
+
* @param {object} [init] fetch 参数(可传 signal 主动取消)
|
|
46
46
|
* @returns {Promise<object>} 成功时为响应体(含 ok:true),失败时 {ok:false, reason, message}
|
|
47
47
|
*/
|
|
48
48
|
async function request(url, init) {
|
|
49
49
|
let res;
|
|
50
50
|
try {
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
// 上传那处 body 最大 4MB(dataURL)⇒ 由调用方传更长的 signal 覆盖(见 uploadBackground)。
|
|
54
|
-
res = await fetch(url, { ...(init || {}), signal: (init && init.signal) || AbortSignal.timeout(8000) });
|
|
51
|
+
// 不设时限:调用方要取消就传 signal。
|
|
52
|
+
res = await fetch(url, { ...(init || {}), ...(init && init.signal ? { signal: init.signal } : {}) });
|
|
55
53
|
} catch (e) {
|
|
56
54
|
return { ok: false, reason: 'network', message: `网络请求失败:${(e && e.message) || e}` };
|
|
57
55
|
}
|
|
@@ -93,9 +91,7 @@ export function uploadBackground(file) {
|
|
|
93
91
|
'x-filename': encodeURIComponent(file.name || ''),
|
|
94
92
|
},
|
|
95
93
|
body: file,
|
|
96
|
-
//
|
|
97
|
-
// 兜底的 8s 对这一步太紧;其余三个调用点都是小 JSON,用兜底值即可。
|
|
98
|
-
signal: AbortSignal.timeout(15000),
|
|
94
|
+
// 不设时限。
|
|
99
95
|
});
|
|
100
96
|
}
|
|
101
97
|
|
|
@@ -55,7 +55,7 @@ export function createDirPicker({ onPick, formatError, getLastError } = {}) {
|
|
|
55
55
|
<div class="dir-picker-alert" data-role="alert" role="alert" hidden></div>
|
|
56
56
|
<div class="dir-picker-foot">
|
|
57
57
|
<span class="muted-hint">显示名称(同名会合并成一个游戏,用于归类原版/Mod)</span>
|
|
58
|
-
<input type="text" data-role="name" placeholder="
|
|
58
|
+
<input type="text" data-role="name" placeholder="显示名称" aria-label="资源根显示名称" />
|
|
59
59
|
<button type="button" class="mini-btn is-accent" data-role="add">添加</button>
|
|
60
60
|
</div>
|
|
61
61
|
</div>`;
|
|
@@ -75,10 +75,8 @@ export function createDirPicker({ onPick, formatError, getLastError } = {}) {
|
|
|
75
75
|
body.append(text('div', 'dir-picker-hint', '加载中…'));
|
|
76
76
|
let data = null;
|
|
77
77
|
try {
|
|
78
|
-
// 幂等 GET(连点同一目录会被 api.js 去重)⇒
|
|
79
|
-
|
|
80
|
-
// 按项目既有「catalog 冷启动最坏 31s」(同属目录扫描链路)**类推**取 30s。
|
|
81
|
-
data = await getJson(`/api/browse?path=${encodeURIComponent(currentPath)}`, { timeoutMs: 30000 });
|
|
78
|
+
// 幂等 GET(连点同一目录会被 api.js 去重)⇒ 走统一层;统一层不设时限,慢就等。
|
|
79
|
+
data = await getJson(`/api/browse?path=${encodeURIComponent(currentPath)}`);
|
|
82
80
|
if (!data || data.error) throw new Error((data && data.error) || '服务端返回空目录数据');
|
|
83
81
|
} catch (err) {
|
|
84
82
|
body.replaceChildren(text('div', 'dir-picker-error',
|
|
@@ -92,7 +92,6 @@ async function fetchServerJudgement(sourceMb) {
|
|
|
92
92
|
fetchCount += 1;
|
|
93
93
|
const res = await fetch(`/api/unpack/capacity${qs}`, {
|
|
94
94
|
headers: { Accept: 'application/json' },
|
|
95
|
-
signal: AbortSignal.timeout(2000),
|
|
96
95
|
});
|
|
97
96
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
98
97
|
const j = await res.json();
|
|
@@ -161,7 +161,6 @@ export async function refreshMemoryReadout(target) {
|
|
|
161
161
|
try {
|
|
162
162
|
const res = await fetch(`/api/unpack/capacity${qs}`, {
|
|
163
163
|
headers: { Accept: 'application/json' },
|
|
164
|
-
signal: AbortSignal.timeout(2000),
|
|
165
164
|
});
|
|
166
165
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
167
166
|
const j = await res.json();
|
|
@@ -212,10 +212,10 @@ async function probeSkelVersion(url) {
|
|
|
212
212
|
try {
|
|
213
213
|
const vfs = getSharedVirtualFS();
|
|
214
214
|
const real = (vfs && typeof vfs.resolve === 'function' ? vfs.resolve(url) : null) || url;
|
|
215
|
-
//
|
|
216
|
-
// 注意 `.slice(0,32)` 是在**已拿到的 blob** 上切,不是 HTTP Range ⇒ 实际仍下载整个 .skel
|
|
217
|
-
//
|
|
218
|
-
const res = await fetch(real
|
|
215
|
+
// 不走 api.js:这里要的是二进制头,不是 JSON —— getJson 会 res.json() 解坏。
|
|
216
|
+
// 注意 `.slice(0,32)` 是在**已拿到的 blob** 上切,不是 HTTP Range ⇒ 实际仍下载整个 .skel。
|
|
217
|
+
// 不设时限。
|
|
218
|
+
const res = await fetch(real);
|
|
219
219
|
if (!res || !res.ok) return null;
|
|
220
220
|
// 读前 64 字节:3.8 格式是「hash 串 + 版本串」,两段加起来可能 40+ 字节(实测 1+27+1+7=36)
|
|
221
221
|
const head = new Uint8Array(await (await res.blob()).slice(0, 64).arrayBuffer());
|
|
@@ -289,11 +289,11 @@ async function expandContainerEntry(src, name) {
|
|
|
289
289
|
let fetchNote = '';
|
|
290
290
|
try {
|
|
291
291
|
if (fetchUrl) {
|
|
292
|
-
// 归 **B
|
|
292
|
+
// 归 **B 类**:这里必须保留 `cache: 'no-store'`(entry.json 是解包产物,
|
|
293
293
|
// 读到浏览器缓存会拿到上一版产物);而 getJson/fetchWithRetry 都不透传 cache 选项,
|
|
294
|
-
// 走 api.js
|
|
295
|
-
//
|
|
296
|
-
const res = await fetch(fetchUrl, { cache: 'no-store'
|
|
294
|
+
// 走 api.js 会静默丢掉这个语义。
|
|
295
|
+
// 不设时限。
|
|
296
|
+
const res = await fetch(fetchUrl, { cache: 'no-store' });
|
|
297
297
|
if (res && res.ok) meta = await res.json();
|
|
298
298
|
else fetchNote = `HTTP ${res && res.status}`;
|
|
299
299
|
} else fetchNote = 'resolve 返回空';
|
|
@@ -494,8 +494,9 @@ function mountEntry(entry, name) {
|
|
|
494
494
|
};
|
|
495
495
|
|
|
496
496
|
// 唯一的装载通路:来源解析在 loadModel 内部完成,这里不再按 kind/source 分支
|
|
497
|
+
// **返回这个 promise**:冷启动要能等"首个模型真的出来"再放行加载队列。
|
|
497
498
|
const loadPromise = loadModel(entry, name);
|
|
498
|
-
loadPromise
|
|
499
|
+
return loadPromise
|
|
499
500
|
.then((info) => {
|
|
500
501
|
// null = 本次请求被排队/被更新的选择取代(Surface 的 last-wins 语义):
|
|
501
502
|
// 不能当成功读结果字段,否则出现"旧面已释放 + 新面没装"的空白 + TypeError
|
|
@@ -571,7 +572,7 @@ function onSelectModel(model) {
|
|
|
571
572
|
|
|
572
573
|
// 换模型 = 换内容:先把「当前动作」的副本清掉(动作是模型属性,见设计 §1.9)。
|
|
573
574
|
// 必须在这里清而不是装载成功后清:装载失败/被拒时也不该残留上一个模型的动作名,
|
|
574
|
-
//
|
|
575
|
+
// 否则新模型的清单里只要恰好有同名项,就会被继续播。
|
|
575
576
|
resetMotionState();
|
|
576
577
|
// 无残缺(绝大多数情况):直接走统一装载入口
|
|
577
578
|
return mountEntry(entry, name);
|
|
@@ -602,12 +603,14 @@ async function loadRoots(opts = {}) {
|
|
|
602
603
|
onSelect: onSelectModel,
|
|
603
604
|
});
|
|
604
605
|
state.roster = roster;
|
|
605
|
-
// 关闭自动恢复时,连"上次展开的节点"
|
|
606
|
-
|
|
606
|
+
// 关闭自动恢复时,连"上次展开的节点"也不还原:保持空舞台、空展开。
|
|
607
|
+
// mount 内部会做恢复并返回结果 —— 恢复只允许有一个所有者,不再在这里二次调用 restoreLast
|
|
608
|
+
// (两个入口会让"先恢复、后排队"的次序随时序漂移而失效)。
|
|
607
609
|
state.autoRestore = autoRestore;
|
|
608
|
-
state.restore = (
|
|
609
|
-
|
|
610
|
-
:
|
|
610
|
+
state.restore = await roster.mount(roots, {
|
|
611
|
+
restoreExpanded: autoRestore,
|
|
612
|
+
skipRestore: !(autoRestore && !opts.skipRestore),
|
|
613
|
+
});
|
|
611
614
|
// 资源根管理(F4):首次建实例并渲染;之后每次刷新都用最新列表重画
|
|
612
615
|
if (!state.rootManager) {
|
|
613
616
|
state.rootManager = createRootManager({
|
|
@@ -50,10 +50,8 @@ export function createRootManager({ els, onChanged, formatError } = {}) {
|
|
|
50
50
|
method: 'POST',
|
|
51
51
|
headers: { 'content-type': 'application/json' },
|
|
52
52
|
body: JSON.stringify(body),
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// 留 8 倍余量覆盖 CIFS 落盘抖动。不用 api.js:它的默认重试会重发写请求。
|
|
56
|
-
signal: AbortSignal.timeout(8000),
|
|
53
|
+
// 写操作:**不加去重、不自动重试**(重复写会建两个根)。
|
|
54
|
+
// 不设时限。
|
|
57
55
|
});
|
|
58
56
|
let data = null;
|
|
59
57
|
try { data = await res.json(); } catch { data = null; }
|
|
@@ -129,7 +129,7 @@ async function stepVariant(dir, peersArg) {
|
|
|
129
129
|
* 翻模型(**按左栏树从上到下的顺序**,末尾回绕)。
|
|
130
130
|
*
|
|
131
131
|
* 池子必须是树,不能是某个接口的清单:
|
|
132
|
-
*
|
|
132
|
+
* 树的「游戏」节点是**同名根合并**出来的,
|
|
133
133
|
* 按单个 rootId 取清单会漏掉同名的其它根 ⇒ 表现为"只能在 live2d 之间翻、翻不到 spine"(实测过的现场)。
|
|
134
134
|
* @param {number} dir -1 上一个 / +1 下一个
|
|
135
135
|
* @param {object[]} [flatArg] 已取到的树序序列
|
package/src/web/app/ui-state.js
CHANGED
|
@@ -283,7 +283,7 @@ export function setDebugCollapsed(collapsed) {
|
|
|
283
283
|
try { saveViewSettings({ debug: next }); return true; } catch { return false; }
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
// ----------------------------------------------------------------
|
|
286
|
+
// ---------------------------------------------------------------- 名册键代理(键名不动)
|
|
287
287
|
/**
|
|
288
288
|
* 右栏背景「二级列表」当前展开的组('solid' | 'custom' | '')。
|
|
289
289
|
*
|