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
package/src/web/components.css
CHANGED
|
@@ -271,6 +271,96 @@
|
|
|
271
271
|
border-color: var(--color-primary);
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/* ------------------------------------------------------------ 扫描进度状态行 */
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 扫描进行中/卡住时,游戏行的状态位(复用 .tree-count 的位置,不新增行)。
|
|
278
|
+
* 用等宽数字:计数每帧跳动,不等宽会让整行左右抖。
|
|
279
|
+
*/
|
|
280
|
+
.tree-count.is-scanning {
|
|
281
|
+
color: var(--color-primary);
|
|
282
|
+
font-variant-numeric: tabular-nums;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/* 游戏行的「加载失败 · 重试」:与扫描〔重试〕同一尺寸语言 */
|
|
286
|
+
.tree-game-load,
|
|
287
|
+
.tree-game-retry {
|
|
288
|
+
height: 20px;
|
|
289
|
+
padding: 0 6px;
|
|
290
|
+
margin-left: 6px;
|
|
291
|
+
border: 1px solid var(--color-border);
|
|
292
|
+
border-radius: 999px;
|
|
293
|
+
background: var(--color-muted);
|
|
294
|
+
color: var(--color-muted-foreground, var(--color-foreground));
|
|
295
|
+
font-size: 11px;
|
|
296
|
+
line-height: 18px;
|
|
297
|
+
cursor: pointer;
|
|
298
|
+
white-space: nowrap;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.tree-game-load:hover,
|
|
302
|
+
.tree-game-retry:hover {
|
|
303
|
+
border-color: var(--color-primary);
|
|
304
|
+
color: var(--color-primary);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.tree-game-retry {
|
|
308
|
+
border-style: dashed;
|
|
309
|
+
border-color: var(--color-primary);
|
|
310
|
+
color: var(--color-primary);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* 〔重试〕:只在"疑似卡住/失败"时出现,尺寸压到 20px,避免把游戏行撑高 */
|
|
314
|
+
.tree-scan-retry {
|
|
315
|
+
height: 20px;
|
|
316
|
+
padding: 0 6px;
|
|
317
|
+
margin-left: 6px;
|
|
318
|
+
border: 1px solid var(--color-primary);
|
|
319
|
+
border-radius: 999px;
|
|
320
|
+
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
|
321
|
+
color: var(--color-primary);
|
|
322
|
+
font-size: 11px;
|
|
323
|
+
line-height: 18px;
|
|
324
|
+
cursor: pointer;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.tree-scan-retry:hover {
|
|
328
|
+
background: color-mix(in srgb, var(--color-primary) 24%, transparent);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* ------------------------------------------------------------ 文件清单失败提示 */
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 「清单获取失败 · 点击重试」提示。
|
|
335
|
+
*
|
|
336
|
+
* 为什么用绝对定位叠加、而不是在面板里插一行:
|
|
337
|
+
* 右栏是固定宽度的一列控件,插入一行会把下面所有控件整体下移(提示出现/消失各跳一次),
|
|
338
|
+
* 属于"为了报错反而制造一个视觉故障"。这里贴在右栏顶部、**不参与布局**,
|
|
339
|
+
* 出现与消失都不会挤动控件;它只在失败期间存在,重试成功即移除。
|
|
340
|
+
* 位置:.panel 本身是 position:fixed ⇒ 相对它定位;top 用头部高度(41px)之下,避免盖住标题。
|
|
341
|
+
*/
|
|
342
|
+
.doc-files-hint {
|
|
343
|
+
position: absolute;
|
|
344
|
+
top: calc(41px + 6px);
|
|
345
|
+
right: 8px;
|
|
346
|
+
max-width: calc(100% - 16px);
|
|
347
|
+
z-index: 5;
|
|
348
|
+
padding: 4px 10px;
|
|
349
|
+
border: 1px dashed var(--color-border);
|
|
350
|
+
border-radius: 999px;
|
|
351
|
+
background: var(--color-card);
|
|
352
|
+
color: var(--color-muted-foreground, var(--color-foreground));
|
|
353
|
+
font-size: 12px;
|
|
354
|
+
line-height: 1.4;
|
|
355
|
+
cursor: pointer;
|
|
356
|
+
box-shadow: 0 2px 8px rgb(0 0 0 / 25%);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.doc-files-hint:hover {
|
|
360
|
+
border-color: var(--color-primary);
|
|
361
|
+
color: var(--color-primary);
|
|
362
|
+
}
|
|
363
|
+
|
|
274
364
|
/* ------------------------------------------------------------ 类型筛选胶囊行 */
|
|
275
365
|
|
|
276
366
|
/**
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const qa = (s) => [...document.querySelectorAll(s)];
|
|
11
11
|
const w = window.__WEB__;
|
|
12
12
|
const stageEl = q('#stage');
|
|
13
|
-
for (let i = 0; i < 80 && qa('[data-
|
|
13
|
+
for (let i = 0; i < 80 && qa('[data-group-key]').length === 0; i += 1) await sleep(150);
|
|
14
14
|
|
|
15
15
|
const trajectory = [];
|
|
16
16
|
let last = '__init__';
|
|
@@ -19,13 +19,30 @@
|
|
|
19
19
|
if (v !== last) { last = v; trajectory.push(v); }
|
|
20
20
|
}, 30);
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
// 取样不写死任何资源根/模型名:从**页面当前已加载的数据**里各找一条 Spine 与 Live2D。
|
|
23
|
+
// 页面没有已加载角色时,先点第一个游戏的「加载」(这是用户在页面上的等价动作,不是硬编码)。
|
|
24
|
+
const pickSample = async () => {
|
|
25
|
+
if (qa('[data-model-id]').length === 0) {
|
|
26
|
+
const loadBtn = q('#roster-list .tree-game button');
|
|
27
|
+
if (loadBtn) loadBtn.click();
|
|
28
|
+
for (let i = 0; i < 600 && qa('[data-model-id]').length === 0; i += 1) await sleep(100);
|
|
29
|
+
}
|
|
30
|
+
const rows = qa('[data-model-id]');
|
|
31
|
+
const labelOf = (row) => (row.textContent || '');
|
|
32
|
+
const spineRow = rows.find((row) => /\bspine\b/i.test(labelOf(row)));
|
|
33
|
+
const live2dRow = rows.find((row) => /live2d/i.test(labelOf(row)));
|
|
34
|
+
const idOf = (row) => (row ? { modelId: row.dataset.modelId, rootId: row.dataset.rootId || '' } : null);
|
|
35
|
+
return { spine: idOf(spineRow), live2d: idOf(live2dRow) };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const clickById = async (ref) => {
|
|
39
|
+
if (!ref || !ref.modelId) return false;
|
|
40
|
+
let row = qa('[data-model-id]').find((n) => n.dataset.modelId === ref.modelId);
|
|
41
|
+
if (!row) { // 行被折叠/重渲染过:展开一次再找
|
|
42
|
+
const group = q(`[data-group-key].tree-game`);
|
|
43
|
+
if (group) group.click();
|
|
44
|
+
for (let i = 0; i < 60 && !row; i += 1) { await sleep(100); row = qa('[data-model-id]').find((n) => n.dataset.modelId === ref.modelId); }
|
|
45
|
+
}
|
|
29
46
|
if (!row) return false;
|
|
30
47
|
row.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
31
48
|
return true;
|
|
@@ -41,16 +58,22 @@
|
|
|
41
58
|
return +w.stage.samplePixels().ratio.toFixed(4);
|
|
42
59
|
};
|
|
43
60
|
|
|
44
|
-
const
|
|
45
|
-
|
|
61
|
+
const sample = await pickSample();
|
|
62
|
+
if (!sample.spine || !sample.live2d) {
|
|
63
|
+
clearInterval(watch);
|
|
64
|
+
return { failed: 1, rounds: [], skipped: true,
|
|
65
|
+
reason: `页面里找不到同一批已加载数据中的 Spine 与 Live2D 各一条(spine=${JSON.stringify(sample.spine)} live2d=${JSON.stringify(sample.live2d)})` };
|
|
66
|
+
}
|
|
67
|
+
const L = sample.live2d;
|
|
68
|
+
const S = sample.spine;
|
|
46
69
|
const rounds = [];
|
|
47
70
|
for (const gap of [60, 150, 300]) {
|
|
48
71
|
for (const pair of [[L, S, 'spine'], [S, L, 'live2d']]) {
|
|
49
72
|
const [first, second, wantMode] = pair;
|
|
50
73
|
trajectory.length = 0;
|
|
51
|
-
const a = await
|
|
74
|
+
const a = await clickById(first);
|
|
52
75
|
await sleep(gap);
|
|
53
|
-
const b = await
|
|
76
|
+
const b = await clickById(second);
|
|
54
77
|
// 等到切换真正落定(首次装载 Live2D 要按需加载运行时库,固定 sleep 会误判成"还在 loading")
|
|
55
78
|
for (let i = 0; i < 120 && stageEl.getAttribute('data-stage-mode') === 'loading'; i += 1) await sleep(150);
|
|
56
79
|
await sleep(1200);
|
|
@@ -58,7 +81,7 @@
|
|
|
58
81
|
const ratio = await contentRatio();
|
|
59
82
|
rounds.push({
|
|
60
83
|
gapMs: gap,
|
|
61
|
-
lastClick: second
|
|
84
|
+
lastClick: second.modelId,
|
|
62
85
|
clicked: [a, b],
|
|
63
86
|
mode: stageEl.getAttribute('data-stage-mode'),
|
|
64
87
|
wantMode,
|
|
@@ -80,9 +80,8 @@ export async function decodeImageSource(entry) {
|
|
|
80
80
|
*
|
|
81
81
|
* **必须避免"预乘 → 非预乘"往返**:直接把 <img> 画进 2D canvas 再 getImageData 时,
|
|
82
82
|
* canvas 内部按预乘存储、读回时再除一次 alpha ⇒ 半透明区域相对误差被放大(除以很小的 a),
|
|
83
|
-
* 会产生 `r > a`
|
|
83
|
+
* 会产生 `r > a` 的**假违反**。半透明边缘多的图会把假违反放大,
|
|
84
84
|
* ⇒ 被判成"非预乘" ⇒ 走 upload 又乘一次 alpha ⇒ 双重预乘(嘴巴黑圈)。
|
|
85
|
-
* azurlane 的图边缘干净 ⇒ 0.1% ⇒ 判对。
|
|
86
85
|
*
|
|
87
86
|
* 做法:用 `createImageBitmap(img, { premultiplyAlpha: 'none' })` 拿到**未预乘**的位图再画;
|
|
88
87
|
* 浏览器不支持或失败时退回原路径(并保持"能检测就检测"的语义,不因取不到样本而阻塞装载)。
|
package/src/web/infra/api.js
CHANGED
|
@@ -20,8 +20,7 @@
|
|
|
20
20
|
// · 去重键是「method + path」,不含请求头;同路径不同 Accept 会被合并——本项目 API 都是同源 JSON,
|
|
21
21
|
// 不存在这种用法。
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
const DEFAULT_TIMEOUT_MS = 8500;
|
|
23
|
+
// 本文件不设请求时限。需要取消的调用方自己传 signal;测试里可以设超时。
|
|
25
24
|
|
|
26
25
|
/** 退避基准(毫秒):第 1/2/3 次重试前的等待;抖动 ±20% */
|
|
27
26
|
const BACKOFF_MS = [100, 300, 900];
|
|
@@ -62,12 +61,13 @@ function retryableStatus(status) {
|
|
|
62
61
|
return status >= 500 && status <= 599;
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* 是否"网络层失败":只有真正的连接层错误才重试;慢不算失败。
|
|
66
|
+
*/
|
|
66
67
|
function retryableNetworkError(err) {
|
|
67
68
|
if (!err) return false;
|
|
68
69
|
const name = String(err.name || '');
|
|
69
70
|
const msg = String(err.message || '');
|
|
70
|
-
if (name === 'TimeoutError') return true; // AbortSignal.timeout 触发的超时
|
|
71
71
|
if (name === 'AbortError') return false; // 调用方主动取消:不能重试
|
|
72
72
|
return err instanceof TypeError || /Failed to fetch|network|fetch failed/i.test(msg);
|
|
73
73
|
}
|
|
@@ -93,12 +93,8 @@ async function readBody(res, path, method) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/** 单次尝试:返回解析后的 body,或抛出带 status 的错误 */
|
|
96
|
-
async function attemptOnce(path, { method, body, headers,
|
|
97
|
-
//
|
|
98
|
-
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
99
|
-
const merged = signal && typeof AbortSignal.any === 'function'
|
|
100
|
-
? AbortSignal.any([signal, timeoutSignal])
|
|
101
|
-
: (signal || timeoutSignal);
|
|
96
|
+
async function attemptOnce(path, { method, body, headers, signal, cache }) {
|
|
97
|
+
// 不设时限;只透传调用方的 signal(用户取消)。
|
|
102
98
|
const res = await fetch(path, {
|
|
103
99
|
method,
|
|
104
100
|
headers: {
|
|
@@ -110,7 +106,7 @@ async function attemptOnce(path, { method, body, headers, timeoutMs, signal, cac
|
|
|
110
106
|
// cache 必须能透传:插件清单 / 运行时清单这类"改完要立刻看到"的请求带 no-store,
|
|
111
107
|
// 丢掉它会读到浏览器缓存(新增插件在前端看不见)。以前 api.js 不透传,调用方只能裸 fetch。
|
|
112
108
|
...(cache ? { cache } : {}),
|
|
113
|
-
signal:
|
|
109
|
+
...(signal ? { signal } : {}),
|
|
114
110
|
});
|
|
115
111
|
|
|
116
112
|
if (res.ok) return readBody(res, path, method);
|
|
@@ -239,7 +235,6 @@ attachRetryStats();
|
|
|
239
235
|
* @returns {Promise<Response>} 成功(res.ok)的响应;4xx/重试耗尽会抛错
|
|
240
236
|
*/
|
|
241
237
|
export async function fetchWithRetry(url, opts = {}) {
|
|
242
|
-
const timeoutMs = Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
243
238
|
// 非幂等方法默认不重试(重发写请求=重复写);调用方显式给 retries 时以显式为准
|
|
244
239
|
const retries = Number.isInteger(opts.retries)
|
|
245
240
|
? opts.retries
|
|
@@ -261,16 +256,13 @@ export async function fetchWithRetry(url, opts = {}) {
|
|
|
261
256
|
for (let i = 0; i < maxAttempts; i += 1) {
|
|
262
257
|
retryStats.attemptsTotal += 1;
|
|
263
258
|
try {
|
|
264
|
-
|
|
265
|
-
const merged = opts.signal && typeof AbortSignal.any === 'function'
|
|
266
|
-
? AbortSignal.any([opts.signal, timeoutSignal])
|
|
267
|
-
: (opts.signal || timeoutSignal);
|
|
259
|
+
// 不设时限(慢 ≠ 失败)。只透传调用方的 signal(用户主动取消)。
|
|
268
260
|
const res = await fetch(url, {
|
|
269
261
|
method: opts.method || 'GET',
|
|
270
262
|
headers: opts.headers,
|
|
271
263
|
...(opts.body !== undefined ? { body: typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body) } : {}),
|
|
272
264
|
...(opts.keepalive ? { keepalive: true } : {}),
|
|
273
|
-
signal:
|
|
265
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
274
266
|
});
|
|
275
267
|
if (res.ok) {
|
|
276
268
|
retryStats.inflight -= 1;
|
|
@@ -316,7 +308,6 @@ export async function fetchWithRetry(url, opts = {}) {
|
|
|
316
308
|
*/
|
|
317
309
|
async function requestJson(path, opts = {}) {
|
|
318
310
|
const method = opts.method || 'GET';
|
|
319
|
-
const timeoutMs = Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
320
311
|
// 非幂等方法默认不重试(重发写请求=重复写);调用方显式给 retries 时以显式为准
|
|
321
312
|
const retries = Number.isInteger(opts.retries)
|
|
322
313
|
? opts.retries
|
|
@@ -326,7 +317,7 @@ async function requestJson(path, opts = {}) {
|
|
|
326
317
|
|
|
327
318
|
for (let i = 0; i < maxAttempts; i += 1) {
|
|
328
319
|
try {
|
|
329
|
-
return await attemptOnce(path, { method, body: opts.body, headers: opts.headers,
|
|
320
|
+
return await attemptOnce(path, { method, body: opts.body, headers: opts.headers, signal: opts.signal, cache: opts.cache });
|
|
330
321
|
} catch (e) {
|
|
331
322
|
lastErr = e;
|
|
332
323
|
const status = e && e.status;
|
|
@@ -46,7 +46,7 @@ function resolveUrl(vfs, url) {
|
|
|
46
46
|
async function readBytes(url, label, warnings) {
|
|
47
47
|
try {
|
|
48
48
|
// 带重试:网络错误 / 5xx 会退避重试(4xx 不重试)
|
|
49
|
-
const res = await fetchWithRetry(url, {
|
|
49
|
+
const res = await fetchWithRetry(url, { label: 'model-bytes' });
|
|
50
50
|
if (!res || !res.ok) {
|
|
51
51
|
warnings.push(`${label} 读取失败:HTTP ${(res && res.status) || '?'}(${url})`);
|
|
52
52
|
return null;
|
|
@@ -171,7 +171,7 @@ async function fillMotionDurations(doc, ref, baseRef, vfs) {
|
|
|
171
171
|
const abs = /^[a-z][a-z0-9+.-]*:/i.test(file) || file.startsWith('/') ? file : `${base}${file}`;
|
|
172
172
|
const url = resolveUrl(vfs, abs);
|
|
173
173
|
try {
|
|
174
|
-
const res = await fetchWithRetry(url, {
|
|
174
|
+
const res = await fetchWithRetry(url, { label: 'motion3' });
|
|
175
175
|
if (!res || !res.ok) return;
|
|
176
176
|
const json = await res.json();
|
|
177
177
|
const d = Number(json && json.Meta && json.Meta.Duration);
|
|
@@ -194,7 +194,7 @@ async function buildLive2dAssets(doc, entry, vfs) {
|
|
|
194
194
|
// motions/x.motion3.json 这类引用映射回已注册的前缀)。
|
|
195
195
|
doc.assets.entry = { role: 'definition', name: raw.split('/').pop() || '', url, ref: raw, bytes: null };
|
|
196
196
|
try {
|
|
197
|
-
const res = await fetchWithRetry(url, {
|
|
197
|
+
const res = await fetchWithRetry(url, { label: 'model-def' });
|
|
198
198
|
if (!res || !res.ok) {
|
|
199
199
|
warn.push(`模型定义读取失败:HTTP ${(res && res.status) || '?'}(${url})`);
|
|
200
200
|
return doc;
|
|
@@ -203,34 +203,106 @@ function onWorkerProgress(m) {
|
|
|
203
203
|
* @param {string} providerId
|
|
204
204
|
* @returns {Promise<object|null>}
|
|
205
205
|
*/
|
|
206
|
+
/** worker 载插件 lib 的就绪上限(ms)。**不要为了少报警而调大**:超时是要暴露问题,不是掩盖。 */
|
|
207
|
+
const WORKER_READY_TIMEOUT_MS = 8000;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 取(或建)某个 provider 的 worker;建不起来返回 null(调用方回落同步解包)。
|
|
211
|
+
*
|
|
212
|
+
* 三种结局必须分开(这是"可不可用"之外、事后能不能归因的关键):
|
|
213
|
+
* · 'ready-provider' worker 就绪且声明了 provider ⇒ 可用
|
|
214
|
+
* · 'ready-no-provider' worker 就绪但没声明 provider ⇒ 不可用(**正常结论,不是错误**)
|
|
215
|
+
* · 'timeout' / 'worker-error' / 'worker-construct-failed' / 'no-files' ⇒ 不可用**且带原因**
|
|
216
|
+
* 原因记在 `entry.readyReason`(结构化),并走既有 diag/日志通道打一条,外部契约不变
|
|
217
|
+
* (`entry.ready` 仍是 Promise<entry|null>,调用方照旧用真假判断)。
|
|
218
|
+
* @param {string} providerId
|
|
219
|
+
* @returns {Promise<object|null>}
|
|
220
|
+
*/
|
|
206
221
|
function ensureUnpackWorker(providerId) {
|
|
207
222
|
if (workerPool.has(providerId)) return workerPool.get(providerId).ready;
|
|
208
223
|
const files = filesOfProvider(providerId);
|
|
209
|
-
const entry = { worker: null, ready: null, seq: 0, pending: new Map() };
|
|
224
|
+
const entry = { worker: null, ready: null, seq: 0, pending: new Map(), readyReason: null };
|
|
210
225
|
workerPool.set(providerId, entry);
|
|
226
|
+
|
|
227
|
+
/** 记录结局:结构化 + 一条可读诊断(成功也记,便于区分"没 provider"和"压根没起来") */
|
|
228
|
+
const settle = (code, message, extra) => {
|
|
229
|
+
const reason = { code, message, at: Date.now(), ...(extra || {}) };
|
|
230
|
+
entry.readyReason = reason;
|
|
231
|
+
try {
|
|
232
|
+
emit('unpack.worker-ready', {
|
|
233
|
+
providerId, code, ok: code === 'ready-provider', message,
|
|
234
|
+
waitedMs: reason.waitedMs != null ? reason.waitedMs : null,
|
|
235
|
+
hasProvider: extra && extra.hasProvider != null ? extra.hasProvider : null,
|
|
236
|
+
});
|
|
237
|
+
} catch { /* 打点失败不影响解包 */ }
|
|
238
|
+
// 不可用且不是"正常结论"(无 provider)时给人一句 warn:这才是排障入口
|
|
239
|
+
if (code !== 'ready-provider' && code !== 'ready-no-provider') {
|
|
240
|
+
try { say.warn(`解包 worker 不可用(${providerId}):${message}`); } catch { /* 忽略 */ }
|
|
241
|
+
}
|
|
242
|
+
return reason;
|
|
243
|
+
};
|
|
244
|
+
|
|
211
245
|
entry.ready = (async () => {
|
|
212
|
-
|
|
246
|
+
const readyT0 = Date.now();
|
|
247
|
+
if (typeof Worker !== 'function') {
|
|
248
|
+
settle('worker-construct-failed', '当前环境没有 Worker 构造器(老浏览器 / 沙箱禁用)');
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if (!files || !files.length) {
|
|
252
|
+
settle('no-files', `插件 ${providerId} 的清单里没有 files[](无从载入 lib)`);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
213
255
|
let worker;
|
|
214
256
|
// 静态托管的映射是 src/web/ → 站点根(实测:/app/model-actions.js 可用),
|
|
215
257
|
// 所以 src/web/plugin/unpack-worker.js 的 URL 就是 /plugin/unpack-worker.js
|
|
216
|
-
try { worker = new Worker(new URL('/plugin/unpack-worker.js', import.meta.url)); } catch {
|
|
258
|
+
try { worker = new Worker(new URL('/plugin/unpack-worker.js', import.meta.url)); } catch (e) {
|
|
259
|
+
settle('worker-construct-failed', `Worker 构造失败:${(e && e.message) || e}`);
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
217
262
|
entry.worker = worker;
|
|
218
|
-
|
|
219
|
-
|
|
263
|
+
// 结局对象:{status:'ready', hasProvider} | {status:'timeout'} | {status:'error', message}
|
|
264
|
+
const outcome = await new Promise((resolve) => {
|
|
265
|
+
const timer = setTimeout(() => resolve({ status: 'timeout' }), WORKER_READY_TIMEOUT_MS);
|
|
220
266
|
worker.addEventListener('message', (ev) => {
|
|
221
267
|
const m = ev.data || {};
|
|
222
|
-
if (m.t === 'ready') { clearTimeout(timer); resolve(!!m.hasProvider); }
|
|
268
|
+
if (m.t === 'ready') { clearTimeout(timer); resolve({ status: 'ready', hasProvider: !!m.hasProvider }); }
|
|
223
269
|
else if (m.t === 'progress') onWorkerProgress(m);
|
|
224
270
|
else if (m.t === 'done' || m.t === 'fail') {
|
|
225
271
|
const p = entry.pending.get(m.id);
|
|
226
272
|
if (p) { entry.pending.delete(m.id); p(m); }
|
|
227
273
|
}
|
|
228
274
|
});
|
|
229
|
-
worker.addEventListener('error', () => {
|
|
275
|
+
worker.addEventListener('error', (ev) => {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
resolve({ status: 'error', message: (ev && ev.message) || 'worker error 事件(脚本加载失败 / CSP / 语法错误)' });
|
|
278
|
+
});
|
|
230
279
|
// files 元素已带插件目录(unity-live2d-bundle/lib/xxx.js),故 base 到 /plugins/
|
|
231
280
|
worker.postMessage({ t: 'init', base: new URL('/plugins/', import.meta.url).href, files });
|
|
232
281
|
});
|
|
233
|
-
|
|
282
|
+
const waitedMs = Date.now() - readyT0;
|
|
283
|
+
if (outcome.status === 'timeout') {
|
|
284
|
+
settle('timeout', `worker 未在 ${WORKER_READY_TIMEOUT_MS}ms 内 ready(lib 加载慢 / 事件循环被占 / worker 脚本未加载)`,
|
|
285
|
+
{ waitedMs, files: files.length });
|
|
286
|
+
try { worker.terminate(); } catch { /* 忽略 */ }
|
|
287
|
+
entry.worker = null;
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
if (outcome.status === 'error') {
|
|
291
|
+
settle('worker-error', `worker 报错:${outcome.message}`, { waitedMs, files: files.length });
|
|
292
|
+
try { worker.terminate(); } catch { /* 忽略 */ }
|
|
293
|
+
entry.worker = null;
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
if (!outcome.hasProvider) {
|
|
297
|
+
// 正常结论:worker 起来了,但该插件没声明 provider(不该继续用它解包)
|
|
298
|
+
settle('ready-no-provider', 'worker 已就绪但未声明 provider(plugin.json 的 provides 为空?)',
|
|
299
|
+
{ waitedMs, hasProvider: false });
|
|
300
|
+
try { worker.terminate(); } catch { /* 忽略 */ }
|
|
301
|
+
entry.worker = null;
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
settle('ready-provider', `worker 就绪(${WORKER_READY_TIMEOUT_MS}ms 上限内,用时 ${waitedMs}ms)`,
|
|
305
|
+
{ waitedMs, hasProvider: true });
|
|
234
306
|
return entry;
|
|
235
307
|
})();
|
|
236
308
|
return entry.ready;
|
|
@@ -375,7 +447,7 @@ async function resolveViaProvider(file, bytes) {
|
|
|
375
447
|
*
|
|
376
448
|
* 为什么必须有:同一个 UnityFS 包可能被多个 bundle 插件都判成"我能处理" ——
|
|
377
449
|
* 它们的判定只看包结构,不看"解出来的东西是否自洽"。
|
|
378
|
-
*
|
|
450
|
+
* 实测:live2d 插件抢先返回 ok,但它生成的 `model3.json`
|
|
379
451
|
* 里 Moc 指向的 `moc3.moc3` 内容只有 4 字节、字面量 `null`(包里根本没有 moc3)
|
|
380
452
|
* ⇒ 后续按 live2d 装载必然失败(控制台只有一句 Network error),
|
|
381
453
|
* 而真正能解这个包的 spine 插件永远轮不到 ⇒ 表现为"点一次不出画,
|