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.
Files changed (78) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +2 -63
  3. package/config/models/live2d.assetbundls.json +1 -1
  4. package/config/models/live2d.azurlane.json +1 -1
  5. package/config/models/live2d.azurlane_bundle.json +1 -1
  6. package/config/models/live2d.azurlane_bundle.jsonl +2 -0
  7. package/config/models/live2d.azurlane_spine.json +1 -1
  8. package/config/models/live2d.bd2.json +1 -1
  9. package/config/models/live2d.bd2.jsonl +2 -0
  10. package/config/models/live2d.bluearchive.json +1 -1
  11. package/config/models/live2d.nikke.json +1 -1
  12. package/config/models/live2d.nikke.jsonl +2 -0
  13. package/config/models/live2d.nikke_2.json +1 -1
  14. package/config/models/live2d.npc.json +1 -1
  15. package/config/models/spine.assetbundls.json +1 -1
  16. package/config/models/spine.azurlane.json +1 -1
  17. package/config/models/spine.azurlane_bundle.json +1 -1
  18. package/config/models/spine.azurlane_bundle.jsonl +2 -0
  19. package/config/models/spine.azurlane_spine.json +1 -1
  20. package/config/models/spine.bd2.json +1 -1
  21. package/config/models/spine.bd2.jsonl +433 -0
  22. package/config/models/spine.bluearchive.json +1 -1
  23. package/config/models/spine.nikke.json +1 -1
  24. package/config/models/spine.nikke_2.json +1 -1
  25. package/config/models/spine.npc.json +1 -1
  26. package/package.json +2 -1
  27. package/src/server/assets/probe.mjs +1 -1
  28. package/src/server/core/config.mjs +2 -2
  29. package/src/server/core/log.mjs +1 -1
  30. package/src/server/data/catalog.mjs +11 -14
  31. package/src/server/data/live2d.mjs +139 -40
  32. package/src/server/data/manifest-cache.mjs +53 -10
  33. package/src/server/data/manifest-jsonl.mjs +264 -0
  34. package/src/server/data/manifest-stamp.mjs +52 -2
  35. package/src/server/data/scan-progress.mjs +305 -0
  36. package/src/server/data/scan-queue.mjs +99 -0
  37. package/src/server/data/spine.mjs +324 -106
  38. package/src/server/plugins/registry.mjs +1 -1
  39. package/src/server/routes/data.mjs +76 -17
  40. package/src/server/serve.mjs +9 -4
  41. package/src/server/unpack/plugin-store.mjs +1 -1
  42. package/src/server/unpack/runtime/exec.mjs +37 -8
  43. package/src/server/unpack/worker-client.mjs +23 -8
  44. package/src/shared/scan-limits.mjs +23 -0
  45. package/src/web/app/anim-list.js +1 -1
  46. package/src/web/app/backgrounds.js +4 -8
  47. package/src/web/app/dir-picker.js +3 -5
  48. package/src/web/app/memory-budget.js +0 -1
  49. package/src/web/app/memory-guard.js +0 -1
  50. package/src/web/app/model-actions.js +18 -15
  51. package/src/web/app/root-manager.js +2 -4
  52. package/src/web/app/transport-actions.js +1 -1
  53. package/src/web/app/ui-state.js +1 -1
  54. package/src/web/components.css +90 -0
  55. package/src/web/debug/race-probe.js +36 -13
  56. package/src/web/engine/textures.js +1 -2
  57. package/src/web/infra/api.js +10 -19
  58. package/src/web/infra/document.js +3 -3
  59. package/src/web/infra/plugin-host-unpack.mjs +81 -9
  60. package/src/web/infra/ready.js +177 -39
  61. package/src/web/live2d/bridge.js +1 -1
  62. package/src/web/live2d/runtime-adapter.js +2 -4
  63. package/src/web/panel/panel-settings.js +88 -3
  64. package/src/web/roster/events.js +159 -0
  65. package/src/web/roster/filter.js +4 -4
  66. package/src/web/roster/list.js +3 -3
  67. package/src/web/roster/load.js +440 -0
  68. package/src/web/roster/persist.js +46 -37
  69. package/src/web/roster/render.js +284 -0
  70. package/src/web/roster/reveal.js +4 -9
  71. package/src/web/roster/rows.js +35 -13
  72. package/src/web/roster/scan-stream.js +346 -0
  73. package/src/web/roster/snapshot.js +14 -8
  74. package/src/web/roster/store.js +292 -0
  75. package/src/web/roster/type-filter.js +5 -5
  76. package/src/web/roster/view.js +147 -634
  77. package/src/web/spine/canvas-runtime.js +4 -5
  78. package/src/web/spine/runtimes.js +1 -1
@@ -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-game-key]').length === 0; i += 1) await sleep(150);
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
- const click = async (kw, root) => {
23
- const input = q('#roster-search');
24
- input.value = kw;
25
- input.dispatchEvent(new Event('input', { bubbles: true }));
26
- for (let i = 0; i < 140 && qa('[data-model-id]').length === 0; i += 1) await sleep(150);
27
- await sleep(250);
28
- const row = qa('[data-model-id]').find((n) => n.dataset.rootId === root);
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 L = ['abeikelongbi', 'azurlane'];
45
- const S = ['aijier', 'azurlane_spine'];
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 click(first[0], first[1]);
74
+ const a = await clickById(first);
52
75
  await sleep(gap);
53
- const b = await click(second[0], second[1]);
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[1],
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` 的**假违反**。现场:bd2 的图半透明边缘多 ⇒ 假违反 9.8%~22.4%
83
+ * 会产生 `r > a` 的**假违反**。半透明边缘多的图会把假违反放大,
84
84
  * ⇒ 被判成"非预乘" ⇒ 走 upload 又乘一次 alpha ⇒ 双重预乘(嘴巴黑圈)。
85
- * azurlane 的图边缘干净 ⇒ 0.1% ⇒ 判对。
86
85
  *
87
86
  * 做法:用 `createImageBitmap(img, { premultiplyAlpha: 'none' })` 拿到**未预乘**的位图再画;
88
87
  * 浏览器不支持或失败时退回原路径(并保持"能检测就检测"的语义,不因取不到样本而阻塞装载)。
@@ -20,8 +20,7 @@
20
20
  // · 去重键是「method + path」,不含请求头;同路径不同 Accept 会被合并——本项目 API 都是同源 JSON,
21
21
  // 不存在这种用法。
22
22
 
23
- /** 单次请求默认超时:8.5s(比常见反代 10s 略短,让"卡住"更早暴露成本地超时而非网关 504) */
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
- /** 是否"网络层失败"(fetch 抛 TypeError,或超时导致的 TimeoutError) */
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, timeoutMs, signal, cache }) {
97
- // 每次尝试都有独立超时(AbortSignal.timeout):卡死的请求不会无限挂住页面
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: merged,
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
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
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: merged,
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, timeoutMs, signal: opts.signal, cache: opts.cache });
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, { timeoutMs: 8000, label: 'model-bytes' });
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, { timeoutMs: 8000, label: 'motion3' });
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, { timeoutMs: 8000, label: 'model-def' });
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
- if (typeof Worker !== 'function' || !files || !files.length) return null;
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 { return null; }
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
- const ok = await new Promise((resolve) => {
219
- const timer = setTimeout(() => resolve(false), 8000); // 载 lib 超时 ⇒ 判不可用
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', () => { clearTimeout(timer); resolve(false); });
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
- if (!ok) { try { worker.terminate(); } catch { /* 忽略 */ } entry.worker = null; return null; }
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
- * 实测(NIKKE 的 spine 资源包):live2d 插件抢先返回 ok,但它生成的 `model3.json`
450
+ * 实测:live2d 插件抢先返回 ok,但它生成的 `model3.json`
379
451
  * 里 Moc 指向的 `moc3.moc3` 内容只有 4 字节、字面量 `null`(包里根本没有 moc3)
380
452
  * ⇒ 后续按 live2d 装载必然失败(控制台只有一句 Network error),
381
453
  * 而真正能解这个包的 spine 插件永远轮不到 ⇒ 表现为"点一次不出画,