modelstage 0.0.0-stage → 0.5.28

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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +137 -0
  5. package/config/diag-upload.example.json +7 -0
  6. package/config/models/live2d.assetbundls.json +1 -0
  7. package/config/models/live2d.azurlane.json +1 -0
  8. package/config/models/live2d.azurlane_bundle.json +1 -0
  9. package/config/models/live2d.azurlane_spine.json +1 -0
  10. package/config/models/live2d.bd2.json +1 -0
  11. package/config/models/live2d.bluearchive.json +1 -0
  12. package/config/models/live2d.live2d.json +1 -0
  13. package/config/models/live2d.nikke.json +1 -0
  14. package/config/models/live2d.nikke_2.json +1 -0
  15. package/config/models/live2d.npc.json +1 -0
  16. package/config/models/spine.assetbundls.json +1 -0
  17. package/config/models/spine.azurlane.json +1 -0
  18. package/config/models/spine.azurlane_bundle.json +1 -0
  19. package/config/models/spine.azurlane_spine.json +1 -0
  20. package/config/models/spine.bd2.json +1 -0
  21. package/config/models/spine.bluearchive.json +1 -0
  22. package/config/models/spine.live2d.json +1 -0
  23. package/config/models/spine.nikke.json +1 -0
  24. package/config/models/spine.nikke_2.json +1 -0
  25. package/config/models/spine.npc.json +1 -0
  26. package/config/names/NIKKE.json +1058 -0
  27. package/config/names/README.md +47 -0
  28. package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
  29. package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
  30. package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
  31. package/config/plugins.json +13 -0
  32. package/config/resources.example.json +40 -0
  33. package/package.json +63 -4
  34. package/plugins/unity-live2d-bundle/README.md +98 -0
  35. package/plugins/unity-live2d-bundle/index.js +891 -0
  36. package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
  37. package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
  38. package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
  39. package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
  40. package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
  41. package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
  42. package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
  43. package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
  44. package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  45. package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
  46. package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
  47. package/plugins/unity-live2d-bundle/lib/png.js +424 -0
  48. package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
  49. package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
  50. package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
  51. package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
  52. package/plugins/unity-live2d-bundle/plugin.json +81 -0
  53. package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
  54. package/plugins/unity-spine-bundle/index.js +535 -0
  55. package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
  56. package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
  57. package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
  58. package/plugins/unity-spine-bundle/lib/errors.js +79 -0
  59. package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
  60. package/plugins/unity-spine-bundle/lib/extract.js +242 -0
  61. package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  62. package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
  63. package/plugins/unity-spine-bundle/lib/png.js +424 -0
  64. package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
  65. package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
  66. package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
  67. package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
  68. package/plugins/unity-spine-bundle/plugin.json +75 -0
  69. package/src/server/README.md +69 -0
  70. package/src/server/assets/browse.mjs +56 -0
  71. package/src/server/assets/bundle-probe.mjs +94 -0
  72. package/src/server/assets/probe.mjs +184 -0
  73. package/src/server/core/config.mjs +173 -0
  74. package/src/server/core/env-num.mjs +60 -0
  75. package/src/server/core/env.mjs +49 -0
  76. package/src/server/core/fs-portable.mjs +37 -0
  77. package/src/server/core/log.mjs +107 -0
  78. package/src/server/core/paths.mjs +44 -0
  79. package/src/server/data/catalog.mjs +1091 -0
  80. package/src/server/data/entry-states.mjs +499 -0
  81. package/src/server/data/live2d.mjs +272 -0
  82. package/src/server/data/manifest-cache.mjs +58 -0
  83. package/src/server/data/manifest-stamp.mjs +135 -0
  84. package/src/server/data/model-meta.mjs +114 -0
  85. package/src/server/data/names.mjs +106 -0
  86. package/src/server/data/shape-probe-worker.mjs +33 -0
  87. package/src/server/data/shape-probe.mjs +626 -0
  88. package/src/server/data/spine.mjs +336 -0
  89. package/src/server/data/tree.mjs +126 -0
  90. package/src/server/http/atomic-json.mjs +145 -0
  91. package/src/server/http/background-solids.mjs +270 -0
  92. package/src/server/http/backgrounds.mjs +388 -0
  93. package/src/server/http/diag-upload.mjs +264 -0
  94. package/src/server/http/diag.mjs +173 -0
  95. package/src/server/http/ready.mjs +167 -0
  96. package/src/server/http/static.mjs +312 -0
  97. package/src/server/http/write.mjs +654 -0
  98. package/src/server/index.mjs +114 -0
  99. package/src/server/plugins/builtin-providers.json +30 -0
  100. package/src/server/plugins/registry.mjs +650 -0
  101. package/src/server/plugins/slot-catalog.mjs +286 -0
  102. package/src/server/routes/background-solids.mjs +56 -0
  103. package/src/server/routes/backgrounds.mjs +77 -0
  104. package/src/server/routes/config.mjs +38 -0
  105. package/src/server/routes/data.mjs +313 -0
  106. package/src/server/routes/diag.mjs +37 -0
  107. package/src/server/routes/entries.mjs +170 -0
  108. package/src/server/routes/ready.mjs +122 -0
  109. package/src/server/serve.mjs +396 -0
  110. package/src/server/unpack/capacity.mjs +217 -0
  111. package/src/server/unpack/mem-probe.mjs +27 -0
  112. package/src/server/unpack/plugin-runner.mjs +529 -0
  113. package/src/server/unpack/plugin-store.mjs +736 -0
  114. package/src/server/unpack/probe-store.mjs +73 -0
  115. package/src/server/unpack/routes.mjs +359 -0
  116. package/src/server/unpack/runtime/exec.mjs +558 -0
  117. package/src/server/unpack/runtime/index.mjs +141 -0
  118. package/src/server/unpack/runtime/node-vm.mjs +111 -0
  119. package/src/server/unpack/unpack-worker.mjs +105 -0
  120. package/src/server/unpack/worker-client.mjs +184 -0
  121. package/src/server/unpack/write-result.mjs +84 -0
  122. package/src/shared/bundle-shape.mjs +83 -0
  123. package/src/shared/entry-reasons.mjs +119 -0
  124. package/src/shared/names.mjs +45 -0
  125. package/src/shared/type-labels.mjs +31 -0
  126. package/src/web/README.md +193 -0
  127. package/src/web/app/anim-list.js +222 -0
  128. package/src/web/app/background-solids.js +150 -0
  129. package/src/web/app/backgrounds.js +152 -0
  130. package/src/web/app/camera-log.js +28 -0
  131. package/src/web/app/context.js +72 -0
  132. package/src/web/app/dir-picker.js +242 -0
  133. package/src/web/app/entry-report.js +71 -0
  134. package/src/web/app/memory-bars.js +91 -0
  135. package/src/web/app/memory-budget.js +228 -0
  136. package/src/web/app/memory-guard.js +204 -0
  137. package/src/web/app/model-actions.js +682 -0
  138. package/src/web/app/renderer-setting.js +70 -0
  139. package/src/web/app/root-manager.js +254 -0
  140. package/src/web/app/status.js +138 -0
  141. package/src/web/app/topbar-actions.js +357 -0
  142. package/src/web/app/transport-actions.js +297 -0
  143. package/src/web/app/ui-state.js +559 -0
  144. package/src/web/app/url-params.js +171 -0
  145. package/src/web/app/view-settings.js +186 -0
  146. package/src/web/components.css +524 -0
  147. package/src/web/debug/diag-hud.js +95 -0
  148. package/src/web/debug/drag.js +369 -0
  149. package/src/web/debug/log.js +320 -0
  150. package/src/web/debug/model-cmp.js +292 -0
  151. package/src/web/debug/panel.css +474 -0
  152. package/src/web/debug/panel.js +712 -0
  153. package/src/web/debug/race-probe.js +82 -0
  154. package/src/web/debug/upload.js +130 -0
  155. package/src/web/engine/adapters/atlas.js +47 -0
  156. package/src/web/engine/adapters/index.js +22 -0
  157. package/src/web/engine/adapters/renderer.js +223 -0
  158. package/src/web/engine/adapters/skeleton.js +105 -0
  159. package/src/web/engine/adapters/texture.js +64 -0
  160. package/src/web/engine/animation.js +181 -0
  161. package/src/web/engine/appearance.js +136 -0
  162. package/src/web/engine/camera-adapter.js +212 -0
  163. package/src/web/engine/effect-chain.js +138 -0
  164. package/src/web/engine/engine-state.js +120 -0
  165. package/src/web/engine/filters.js +224 -0
  166. package/src/web/engine/fit.js +197 -0
  167. package/src/web/engine/frame-gate.js +33 -0
  168. package/src/web/engine/gl-driver.js +56 -0
  169. package/src/web/engine/gl.js +77 -0
  170. package/src/web/engine/index.js +20 -0
  171. package/src/web/engine/loader.js +55 -0
  172. package/src/web/engine/model-info.js +26 -0
  173. package/src/web/engine/pipeline.js +200 -0
  174. package/src/web/engine/player.js +427 -0
  175. package/src/web/engine/pma/blend.js +76 -0
  176. package/src/web/engine/pma/detect.js +118 -0
  177. package/src/web/engine/pma/index.js +8 -0
  178. package/src/web/engine/pma/resolve.js +112 -0
  179. package/src/web/engine/pma/upload.js +127 -0
  180. package/src/web/engine/pma.js +5 -0
  181. package/src/web/engine/render-frame.js +93 -0
  182. package/src/web/engine/runtime-capabilities.js +209 -0
  183. package/src/web/engine/runtime.js +37 -0
  184. package/src/web/engine/skins.js +165 -0
  185. package/src/web/engine/textures.js +174 -0
  186. package/src/web/index.html +117 -0
  187. package/src/web/infra/api.js +531 -0
  188. package/src/web/infra/diag.js +509 -0
  189. package/src/web/infra/document.js +269 -0
  190. package/src/web/infra/dom.js +31 -0
  191. package/src/web/infra/events.mjs +27 -0
  192. package/src/web/infra/log.js +153 -0
  193. package/src/web/infra/mem-stats.js +112 -0
  194. package/src/web/infra/plugin-host-registration.mjs +285 -0
  195. package/src/web/infra/plugin-host-report.mjs +32 -0
  196. package/src/web/infra/plugin-host-unload.mjs +79 -0
  197. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  198. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  199. package/src/web/infra/plugin-host.js +628 -0
  200. package/src/web/infra/ready.js +461 -0
  201. package/src/web/infra/schedule.js +51 -0
  202. package/src/web/infra/shortcuts.js +129 -0
  203. package/src/web/infra/slot-registry.js +402 -0
  204. package/src/web/infra/vfs.js +247 -0
  205. package/src/web/live2d/bridge.js +400 -0
  206. package/src/web/live2d/model.js +321 -0
  207. package/src/web/live2d/overlay.js +142 -0
  208. package/src/web/live2d/pixi-app.js +111 -0
  209. package/src/web/live2d/playback-probe.js +92 -0
  210. package/src/web/live2d/probe-geometry.js +329 -0
  211. package/src/web/live2d/probe.js +119 -0
  212. package/src/web/live2d/release.js +66 -0
  213. package/src/web/live2d/runtime-adapter.js +289 -0
  214. package/src/web/live2d/stage-ctx.js +152 -0
  215. package/src/web/live2d/stage.js +204 -0
  216. package/src/web/live2d/vfs-settings.js +174 -0
  217. package/src/web/live2d/view.js +89 -0
  218. package/src/web/main.js +629 -0
  219. package/src/web/panel/background-list.js +373 -0
  220. package/src/web/panel/bg-source.js +291 -0
  221. package/src/web/panel/color-picker.css +199 -0
  222. package/src/web/panel/color-picker.js +278 -0
  223. package/src/web/panel/dialog.css +116 -0
  224. package/src/web/panel/dialog.js +173 -0
  225. package/src/web/panel/dom.js +35 -0
  226. package/src/web/panel/icons.js +58 -0
  227. package/src/web/panel/memory-bar.js +195 -0
  228. package/src/web/panel/panel-settings.js +1263 -0
  229. package/src/web/panel/panel.css +1304 -0
  230. package/src/web/panel/render-choice.js +104 -0
  231. package/src/web/panel/schema.js +784 -0
  232. package/src/web/panel/settings-io.js +291 -0
  233. package/src/web/panel/shell-controls.js +274 -0
  234. package/src/web/panel/slider.js +210 -0
  235. package/src/web/panel/toolbar-schema.js +111 -0
  236. package/src/web/panel/toolbar.js +195 -0
  237. package/src/web/panel/transport-picker.js +375 -0
  238. package/src/web/panel/transport.js +321 -0
  239. package/src/web/panel/view.js +553 -0
  240. package/src/web/plugin/unpack-worker.js +185 -0
  241. package/src/web/render/camera/constants.js +60 -0
  242. package/src/web/render/camera/index.js +123 -0
  243. package/src/web/render/camera/spine.js +124 -0
  244. package/src/web/render/capture.js +30 -0
  245. package/src/web/render/interaction.js +527 -0
  246. package/src/web/render/live2d.js +627 -0
  247. package/src/web/render/playback.js +47 -0
  248. package/src/web/render/reset-view.js +29 -0
  249. package/src/web/render/spine.js +386 -0
  250. package/src/web/render/surface-switch.js +176 -0
  251. package/src/web/render/surface.js +571 -0
  252. package/src/web/render/surfaces.js +70 -0
  253. package/src/web/roster/filter.js +31 -0
  254. package/src/web/roster/list.js +276 -0
  255. package/src/web/roster/persist.js +92 -0
  256. package/src/web/roster/reveal.js +77 -0
  257. package/src/web/roster/rows.js +248 -0
  258. package/src/web/roster/snapshot.js +39 -0
  259. package/src/web/roster/type-filter.js +97 -0
  260. package/src/web/roster/view.js +761 -0
  261. package/src/web/spine/canvas-api-groups.js +267 -0
  262. package/src/web/spine/canvas-api.js +97 -0
  263. package/src/web/spine/canvas-document.js +80 -0
  264. package/src/web/spine/canvas-engine.js +193 -0
  265. package/src/web/spine/canvas-entry.js +28 -0
  266. package/src/web/spine/canvas-pixels.js +36 -0
  267. package/src/web/spine/canvas-runtime.js +127 -0
  268. package/src/web/spine/canvas-view.js +108 -0
  269. package/src/web/spine/canvas.js +7 -0
  270. package/src/web/spine/runtime-hint.js +73 -0
  271. package/src/web/spine/runtimes.js +212 -0
  272. package/src/web/style.css +493 -0
  273. package/tool/lib/bundle-probe-node.mjs +121 -0
  274. package/tool/lib/paths.mjs +20 -0
  275. package/tool/lib/runtime-guides.mjs +65 -0
  276. package/tool/lib/runtime-package.mjs +403 -0
  277. package/tool/lib/spine-meta.mjs +227 -0
  278. package/tool/lib/spine-motions.mjs +104 -0
  279. package/tool/parse-jsonc.mjs +86 -0
  280. package/tool/resolve-live2d-runtimes.mjs +276 -0
  281. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,1091 @@
1
+ // catalog.mjs —— 模型来源规范化(GET /api/catalog 的数据层)
2
+ //
3
+ // 背景:Spine 与 Live2D 两路扫描输出的形状不同(入口字段、版本字段、动作字段、私有字段各一套),
4
+ // 消费方必须各自适配。这里合并两路结果,统一输出 ModelEntry(见 docs/架构/总览.md 第十节):
5
+ // { id, kind, rootId, rootName, name, srcDir,
6
+ // entry: { url, name, size },
7
+ // assets: [{ role, name, url, size }],
8
+ // meta: { version, motions, skins, expressions, textureCount, fileSize, extras },
9
+ // iconUrl, warnings }
10
+ //
11
+ // 三条硬规则:
12
+ // 1. kind = 内容类型(spine / live2d),只用于选渲染分支;容器单文件另用 `container` 标记;
13
+ // 2. 私有字段一律进 meta.extras,不进公共层;
14
+ // 3. 缺陷显式化:动作取不到时进 warnings,不用"空数组"隐式表达。
15
+ //
16
+ // 扫描不在此重复实现:直接复用 spine.getSpineModels() 与 live2d.getLive2DModels()。
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ // 单调时钟:计时口径用它,不能用 Date.now()(会被系统时间校准拉走)
20
+ import { performance } from 'node:perf_hooks';
21
+ import { encodePath } from '../assets/probe.mjs';
22
+ // 扩展名集合复用 tree.mjs 那一份:容器列举与目录浏览必须用同一套「哪些扩展名要探魔数」,
23
+ // 两处各写一份会漂移(一处加了新扩展名、另一处没加 ⇒ 某个根莫名列不出容器,且不报错)。
24
+ import { KNOWN_FILE_EXT } from './tree.mjs';
25
+ // 指纹判据只有一份实现:catalog / 上报路由 / 解包路径三处必须同源(INV-D6)
26
+ import { providerFingerprintOf, assetFingerprintOf } from './entry-states.mjs';
27
+
28
+ /**
29
+ * 路径式文件 URL(Live2D 定义文件内部用相对路径引用,必须用 /files/ 前缀)。
30
+ * @param {string} rootId
31
+ * @param {string} relPath 相对资源根的路径
32
+ */
33
+ function filesUrl(rootId, relPath) {
34
+ return `/files/${encodeURIComponent(rootId)}/${encodePath(relPath)}`;
35
+ }
36
+
37
+ /** 版本统一成数字(4.1 / 4);无法解析时原样返回字符串 */
38
+ function toVersionNumber(label) {
39
+ const n = Number(label);
40
+ return Number.isFinite(n) ? n : label;
41
+ }
42
+
43
+ /** 去掉动作文件名里的 .motion3.json / .motion.json / .json 后缀,作为可读动作名 */
44
+ function motionName(file) {
45
+ return path.basename(String(file)).replace(/\.(motion3|motion)?\.?json$/i, '') || String(file);
46
+ }
47
+
48
+ // 定义文件解析缓存:absPath → { mtimeMs, size, result }
49
+ // 为什么必须缓存:一次 catalog 请求要读 100+ 个 model3.json,逐个读盘会成为主要耗时。
50
+ const motionCache = new Map();
51
+ /** 纹理存在性缓存:key=定义文件绝对路径,按 mtime/size 失效(避免每次请求上千次 stat) */
52
+ const textureCache = new Map();
53
+
54
+ /**
55
+ * 从 Cubism 定义文件读动作分组,展开成 [{ group, index, name, url, fadeIn, fadeOut }]。
56
+ * Cubism 4 在 FileReferences.Motions,Cubism 2 在顶层 motions —— 两者都支持。
57
+ * @param {string} absDef 定义文件绝对路径
58
+ * @param {(rel: string) => string} toUrl 相对路径 → URL
59
+ * @param {string} [relPrefix] 定义文件所在目录(相对资源根)
60
+ * @returns {{motions: Array, warnings: string[]}}
61
+ */
62
+ export function readModel3Motions(absDef, toUrl, relPrefix) {
63
+ const st = statOf(absDef);
64
+ if (!st) return { motions: [], missingMotions: [], warnings: [`motions 不可得:定义文件读取失败(ENOENT)`] };
65
+ const cached = motionCache.get(absDef);
66
+ if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.result;
67
+ const parsedMotions = parseMotionsFile(absDef, toUrl, relPrefix);
68
+ motionCache.set(absDef, { mtimeMs: st.mtimeMs, size: st.size, result: parsedMotions });
69
+ return parsedMotions;
70
+ }
71
+
72
+ /**
73
+ * 读并解析定义文件,产出 {motions, missingMotions, warnings}。
74
+ * 只做"解析 + 汇总",具体提取与过滤各由下面两个小函数负责。
75
+ * @param {string} absDef 定义文件绝对路径
76
+ * @param {(rel: string) => string} toUrl 相对路径 → URL
77
+ * @param {string|null} relPrefix 模型目录前缀
78
+ */
79
+ function parseMotionsFile(absDef, toUrl, relPrefix) {
80
+ let def;
81
+ try {
82
+ def = JSON.parse(fs.readFileSync(absDef, 'utf8'));
83
+ } catch (e) {
84
+ return { motions: [], missingMotions: [], warnings: [`motions 不可得:定义文件解析失败(${e.message})`] };
85
+ }
86
+ const groups = (def.FileReferences && def.FileReferences.Motions) || def.motions || {};
87
+ const refs = listMotionRefs(groups);
88
+ const { existing, missing } = splitByExistence(refs, path.dirname(absDef));
89
+ const motions = existing.map((ref) => buildMotionItem(ref, toUrl, relPrefix));
90
+ if (!motions.length && !missing.length) {
91
+ return { motions: [], missingMotions: [], warnings: ['motions 不可得:定义文件未声明 FileReferences.Motions'] };
92
+ }
93
+ return { motions, missingMotions: missing.map(toMissingItem), warnings: [] };
94
+ }
95
+
96
+ /**
97
+ * 从 model3.json 的 Motions 提取引用清单(**不碰磁盘**)。
98
+ * @param {object} groups 形如 { idle: [{ File, FadeIn }] }
99
+ * @returns {Array<{group: string, index: number, file: string, raw: object}>}
100
+ */
101
+ export function listMotionRefs(groups) {
102
+ const out = [];
103
+ for (const [group, list] of Object.entries(groups || {})) {
104
+ if (!Array.isArray(list)) continue;
105
+ list.forEach((raw, index) => {
106
+ const file = raw ? (raw.File || raw.file) : null;
107
+ if (file) out.push({ group, index, file: String(file), raw });
108
+ });
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * 按"文件是否真的在磁盘上"把引用分成两堆(bug B 的核心)。
115
+ *
116
+ * 为什么要有这一步:定义文件**声明了**但磁盘**不存在**的动作文件,如果照原样下发,
117
+ * 前端就会去请求它 ⇒ 404 进 problems(每次加载这类模型都红)。
118
+ * 这里直接把它们分出去,前端不会请求;缺失项单独返回,让上层以 warn 级说明"为什么少个动作"。
119
+ * 注意:这属于**资产残缺**,不是路径拼错——所以是"过滤 + 记录",不是改路径。
120
+ *
121
+ * @param {Array<{group: string, index: number, file: string, raw: object}>} refs
122
+ * @param {string} baseDir 定义文件所在目录
123
+ * @returns {{existing: Array, missing: Array}}
124
+ */
125
+ export function splitByExistence(refs, baseDir) {
126
+ const existing = [];
127
+ const missing = [];
128
+ for (const ref of (Array.isArray(refs) ? refs : [])) {
129
+ if (fs.existsSync(path.join(baseDir, ref.file))) existing.push(ref);
130
+ else missing.push(ref);
131
+ }
132
+ return { existing, missing };
133
+ }
134
+
135
+ /** 单条可用动作 → 前端动作清单项(含 URL 与淡入淡出) */
136
+ function buildMotionItem(ref, toUrl, relPrefix) {
137
+ const rel = relPrefix ? `${relPrefix}/${ref.file}` : ref.file;
138
+ const raw = ref.raw || {};
139
+ return {
140
+ group: ref.group,
141
+ index: ref.index,
142
+ name: motionName(ref.file),
143
+ url: toUrl(rel),
144
+ fadeIn: raw.FadeIn !== undefined ? raw.FadeIn : raw.fade_in,
145
+ fadeOut: raw.FadeOut !== undefined ? raw.FadeOut : raw.fade_out,
146
+ };
147
+ }
148
+
149
+ /** 单条缺失动作 → 缺件记录(供上层 warn 级暴露) */
150
+ function toMissingItem(ref) {
151
+ return { group: ref.group, index: ref.index, name: motionName(ref.file), file: ref.file };
152
+ }
153
+
154
+ /** stat 封装:拿不到返回 null(调用方据此给出"读取失败"的结论) */
155
+ function statOf(absPath) {
156
+ try {
157
+ return fs.statSync(absPath);
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Spine 条目 → ModelEntry。
165
+ * @param {object} cfg 资源根配置
166
+ * @param {object} m 扫描结果条目
167
+ */
168
+ export function toSpineEntry(cfg, m) {
169
+ const fileUrl = (p) => filesUrl(cfg.id, p);
170
+ const assets = [{
171
+ role: 'atlas',
172
+ name: m.atlasFile.name,
173
+ url: fileUrl(m.atlasFile.path),
174
+ size: m.atlasFile.size,
175
+ }];
176
+ for (const t of m.textureFiles || []) {
177
+ assets.push({ role: 'texture', name: t.name, url: fileUrl(t.path), size: t.size });
178
+ }
179
+ const motions = Array.isArray(m.animations) ? m.animations : [];
180
+ const warnings = [];
181
+ // 扫描阶段只认骨架/图集,不解析动画表,因此这里如实说明"需运行时枚举"(而不是假装 0 个动作)
182
+ if (!motions.length) warnings.push('motions 需运行时枚举:扫描阶段未解析动画列表,可由 /api/model-meta 或渲染器补齐');
183
+ if (!Array.isArray(m.skins) || !m.skins.length) warnings.push('skins 需运行时枚举:扫描阶段未解析皮肤列表');
184
+
185
+ return {
186
+ id: m.id,
187
+ kind: 'spine',
188
+ source: 'files',
189
+ rootId: cfg.id,
190
+ rootName: cfg.name,
191
+ name: m.name,
192
+ srcDir: m.srcDir,
193
+ entry: { url: fileUrl(m.skeletonFile.path), name: m.skeletonFile.name, size: m.skeletonFile.size },
194
+ assets,
195
+ // ---- 渲染器兼容字段 ----
196
+ // 为什么补:/api/spine/models 的条目带 skeletonFile / atlasFile / textureFiles,渲染器直接读这三组;
197
+ // catalog 条目原本只有 entry / assets,拿它直接 mount 会 404。
198
+ // 只补字段:entry / assets 的内容与含义保持不变,已用它们的调用方不受影响。
199
+ skeletonFile: { name: m.skeletonFile.name, path: m.skeletonFile.path, url: fileUrl(m.skeletonFile.path), size: m.skeletonFile.size },
200
+ atlasFile: { name: m.atlasFile.name, path: m.atlasFile.path, url: fileUrl(m.atlasFile.path), size: m.atlasFile.size },
201
+ textureFiles: (m.textureFiles || []).map((t) => ({ name: t.name, path: t.path, url: fileUrl(t.path), size: t.size })),
202
+ // 顶层 version / format:渲染器按顶层 version 选运行时(meta.version 是数字,不适合做匹配键),
203
+ // 缺这两个字段会让运行时选错版本,进而报 "Bone name must not be null"。
204
+ version: m.version,
205
+ format: m.format,
206
+ meta: {
207
+ version: toVersionNumber(m.version),
208
+ motions,
209
+ skins: m.skins || [],
210
+ expressions: [],
211
+ textureCount: m.textureCount,
212
+ fileSize: m.fileSize,
213
+ extras: {
214
+ versionLabel: m.version,
215
+ format: m.format,
216
+ boneCount: m.boneCount,
217
+ slotCount: m.slotCount,
218
+ atlasPma: m.atlasPma,
219
+ directory: m.directory,
220
+ dirPath: m.dirPath,
221
+ importedAt: m.importedAt,
222
+ source: m.source,
223
+ localRoot: m.localRoot,
224
+ },
225
+ },
226
+ iconUrl: m.iconUrl || null,
227
+ warnings,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * 读取 model3.json 声明的纹理并核对磁盘是否存在(按定义文件 mtime/size 缓存)。
233
+ *
234
+ * 为什么要做:扫描阶段只列**磁盘上有的**文件,不会发现"定义了但缺文件"的情况;
235
+ * 现场实例:aijier_3 的 Textures 声明 texture_00+texture_01,磁盘只有 texture_01
236
+ * ⇒ 渲染时抛 "Texture loading error"(用户只看到这个,看不出是资产缺文件)。
237
+ * 这里提前判定,让上层能"预判 + 换源 + 给可执行提示",而不是等渲染炸。
238
+ *
239
+ * @param {string} absDef 定义文件绝对路径
240
+ * @returns {{declared: string[], missing: string[]}}
241
+ */
242
+ export function readModel3Textures(absDef) {
243
+ let st;
244
+ try {
245
+ st = fs.statSync(absDef);
246
+ } catch {
247
+ return { declared: [], missing: [] };
248
+ }
249
+ const cached = textureCache.get(absDef);
250
+ if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.result;
251
+ let textureState = { declared: [], missing: [] };
252
+ try {
253
+ const def = JSON.parse(fs.readFileSync(absDef, 'utf8'));
254
+ const declared = ((def.FileReferences && def.FileReferences.Textures) || []).map(String);
255
+ const dir = path.dirname(absDef);
256
+ textureState = { declared, missing: declared.filter((rel) => !fs.existsSync(path.join(dir, rel))) };
257
+ } catch { /* 解析失败按"无声明"处理,不制造噪音 */ }
258
+ textureCache.set(absDef, { mtimeMs: st.mtimeMs, size: st.size, result: textureState });
259
+ return textureState;
260
+ }
261
+
262
+ /**
263
+ * Live2D 条目 → ModelEntry。
264
+ * @param {object} cfg 资源根配置
265
+ * @param {object} lm 扫描结果条目
266
+ */
267
+ export function toLive2DEntry(cfg, lm) {
268
+ const fileUrl = (p) => filesUrl(cfg.id, p);
269
+ const absDef = lm.defFile ? path.join(cfg.path, lm.defFile.path) : null;
270
+ const fromDef = absDef
271
+ ? readModel3Motions(absDef, fileUrl, lm.srcDir)
272
+ : { motions: [], missingMotions: [], warnings: ['motions 不可得:缺少定义文件路径'] };
273
+ const textureCheck = absDef ? readModel3Textures(absDef) : { declared: [], missing: [] };
274
+
275
+ return {
276
+ id: lm.id,
277
+ kind: 'live2d',
278
+ source: 'files',
279
+ rootId: cfg.id,
280
+ rootName: cfg.name,
281
+ name: lm.name,
282
+ srcDir: lm.srcDir,
283
+ entry: { url: lm.modelUrl, name: lm.defFile.name, size: lm.defFile.size },
284
+ assets: buildLive2DAssets(lm, fileUrl),
285
+ meta: buildLive2DMeta(lm, fromDef, textureCheck),
286
+ iconUrl: lm.iconUrl || null,
287
+ warnings: buildLive2DWarnings(fromDef, textureCheck),
288
+ };
289
+ }
290
+
291
+ /** Live2D 资产清单(moc3 + 磁盘上实际存在的纹理页) */
292
+ function buildLive2DAssets(lm, fileUrl) {
293
+ const assets = [];
294
+ if (lm.mocFile) {
295
+ assets.push({ role: 'moc3', name: lm.mocFile.name, url: fileUrl(lm.mocFile.path), size: lm.mocFile.size });
296
+ }
297
+ for (const page of lm.textureFiles || []) {
298
+ assets.push({ role: 'texture', name: page.name, url: fileUrl(page.path), size: page.size });
299
+ }
300
+ return assets;
301
+ }
302
+
303
+ /**
304
+ * Live2D 元信息:动作清单 + 资产残缺标记。
305
+ * @param {object} lm 扫描结果条目
306
+ * @param {{motions: Array, missingMotions?: Array}} fromDef 定义文件解析结果
307
+ * @param {{missing: string[]}} textureCheck 纹理核对结果
308
+ */
309
+ function buildLive2DMeta(lm, fromDef, textureCheck) {
310
+ return {
311
+ version: toVersionNumber(lm.cubism),
312
+ motions: fromDef.motions,
313
+ missingMotions: fromDef.missingMotions || [],
314
+ skins: [],
315
+ expressions: lm.expressions || [],
316
+ textureCount: (lm.textureFiles || []).length,
317
+ // 声明了但磁盘缺失的纹理(前端据此预判并给出明确提示,不必等渲染报错)
318
+ missingTextures: textureCheck.missing,
319
+ fileSize: lm.fileSize,
320
+ extras: { cubism: lm.cubism, defFile: lm.defFile, mocFile: lm.mocFile },
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Live2D 告警:只收「真缺陷」(动作取不到、资产残缺)。
326
+ * iconUrl 缺失是这批资源的共性(Live2D 目录普遍没有 icon.png),不作为逐条告警刷屏。
327
+ */
328
+ function buildLive2DWarnings(fromDef, textureCheck) {
329
+ const warnings = [...(fromDef.warnings || [])];
330
+ if (textureCheck.missing.length) {
331
+ warnings.push(`纹理文件缺失(资产残缺,渲染会失败):${textureCheck.missing.join('、')}——建议改用资源包版本或补齐文件`);
332
+ }
333
+ const missingMotions = fromDef.missingMotions || [];
334
+ if (missingMotions.length) {
335
+ warnings.push(`动作文件缺失(资产残缺,已跳过 ${missingMotions.length} 个):${
336
+ missingMotions.map((ref) => `${ref.group}/${ref.name}`).join('、')}`);
337
+ }
338
+ return warnings;
339
+ }
340
+
341
+ /**
342
+ * 解析资源包提供者:取「已启用的、声明了 fileKinds 含 bundle」的插件。
343
+ * kind 一律取插件 produces.kind —— 不在 catalog 里写死内容类型,
344
+ * 将来出现 Spine 资源包只要插件改声明即可,无需改本文件。
345
+ *
346
+ * 多于一个 bundle 插件时,**kind 与 id 都必须留空**:
347
+ * 解包前无法知道某个容器属于哪个插件(要看包内到底有 moc3 还是 .skel),
348
+ * 取第一个是错的 —— 实测会返回 unity-live2d-bundle/live2d,于是 spine 包被标成 live2d,
349
+ * 前端据 kind 选渲染分支 ⇒ 点开必然失败,而现场表现是"这个包坏了",极难定位。
350
+ * 内容类型由**解包产物**确定(有 entry.json 且含 skeletonFile ⇒ spine;有 model3.json ⇒ live2d),
351
+ * 那是确定性的、且解包本来就要做,不需要在列举阶段额外 probe 一遍(每包 116ms × N)。
352
+ * @returns {{id: string|null, kind: string|null, warning: string|null}}
353
+ */
354
+ export function resolveBundleProvider(plugins) {
355
+ const list = plugins.readPlugins()
356
+ .filter((p) => Array.isArray(p.fileKinds) && p.fileKinds.includes('bundle'))
357
+ // 优先级只决定**候选列表的先后**(提示用),不决定装载、也不决定颜色:
358
+ // 解包前谁也判不出一个包该归哪个插件,颜色只认"已解包过的实际 pluginId"。
359
+ .sort((a, b) => (Number(b.matchPriority) || 0) - (Number(a.matchPriority) || 0));
360
+ /** 展示用候选(按 matchPriority 降序):前端拿它拼"归属待解包确定(候选:A、B)" */
361
+ const candidates = list.map((p) => ({ id: p.id, accent: p.accent || null, matchPriority: Number(p.matchPriority) || 0 }));
362
+ if (!list.length) {
363
+ return { id: null, kind: null, candidates, reason: 'no-provider', warning: '未找到能解包资源包的插件:条目 kind 无法确定' };
364
+ }
365
+ if (list.length > 1) {
366
+ return {
367
+ id: null,
368
+ kind: null,
369
+ candidates,
370
+ reason: 'multi-provider',
371
+ warning: `有 ${list.length} 个能解包资源包的插件(${list.map((p) => p.id).join('、')}):`
372
+ + '解包前无法确定内容类型,条目 kind 置空,由解包产物(entry.json 的 skeletonFile / model3.json)判定',
373
+ };
374
+ }
375
+ const provider = list[0];
376
+ const declared = provider.produces && provider.produces.kind ? String(provider.produces.kind) : null;
377
+ return {
378
+ id: provider.id,
379
+ kind: declared,
380
+ candidates,
381
+ reason: declared ? null : 'undeclared-kind',
382
+ warning: declared ? null : `提供者 ${provider.id} 未声明产出类型(produces.kind):kind 置空,由渲染层拒绝装载`,
383
+ };
384
+ }
385
+
386
+ /**
387
+ * 资源包条目 → ModelEntry。
388
+ * 解包前拿不到内部资产,故 assets 为空、meta 只填容器信息与大小。
389
+ * @param {{id: string, name: string}} cfg 资源根
390
+ * @param {{rel?: string, name: string, size?: number, container?: string}} file 容器文件
391
+ * @param {{id: string|null, kind: string|null, candidates?: object[], reason?: string|null, warning?: string|null}} provider 列举阶段的候选信息
392
+ * @param {{id: string, accent: string|null}|null} [owner] 已解包过的包:**实际解出它的插件**(唯一可信的展示归属)
393
+ */
394
+ export function toBundleEntry(cfg, file, provider, owner = null) {
395
+ const rel = file.rel || file.name;
396
+ const warnings = ['motions 需解包后枚举:资源包内部资产在打开时才可读'];
397
+ // kind 为空有两种成因,文案必须区分:否则排障时会去改插件的 produces.kind,
398
+ // 而真正原因是"多个资源包插件并存、解包前判不出类型"。
399
+ if (!provider.kind) {
400
+ warnings.unshift(provider.reason === 'multi-provider'
401
+ ? '内容类型待解包后确定(多个资源包插件并存,列举阶段无法判定)'
402
+ : '提供者未声明产出类型');
403
+ }
404
+ return {
405
+ // id 必须含**根内相对路径**,不能只用文件名:
406
+ // 资源包里不同子目录常有同名文件(如各角色目录下都叫 `c200_01_...`),只用 file.name
407
+ // 会让它们得到同一个 id ⇒ 名册按 id 匹配选中态 ⇒ 点一个、多个一起高亮,
408
+ // 而且"上次选择记忆"也会指到错的那一个。
409
+ // 与同族实现保持一致:live2d 用 relPath.replace(/\//g,'_')、spine 用 idPath.replace(/\//g,'_')。
410
+ id: `bundle-${cfg.id}-${String(rel).replace(/\//g, '_')}`,
411
+ kind: provider.kind, // 可能为 null:宁可显式空,也不按插件 id 猜
412
+ container: file.container || 'unityfs', // 装载层据此决定先解包(不是内容类型)
413
+ provider: provider.id,
414
+ rootId: cfg.id,
415
+ rootName: cfg.name,
416
+ name: file.name,
417
+ // rel 是容器在根内的相对路径:装载时要凭它让插件去解包(缺了插件无从定位)
418
+ rel,
419
+ size: file.size, // 顶层 size:插件解包前据此判断是否超限/是否需要流式处理
420
+ srcDir: '',
421
+ entry: { url: filesUrl(cfg.id, rel), name: file.name, size: file.size },
422
+ assets: [],
423
+ meta: {
424
+ version: null,
425
+ motions: [],
426
+ skins: [],
427
+ expressions: [],
428
+ textureCount: 0,
429
+ fileSize: file.size,
430
+ extras: { container: file.container || null },
431
+ },
432
+ iconUrl: null,
433
+ warnings,
434
+ // ---- 展示归属的**原始输入**(契约 §二 的三字段由 listCatalog 末尾统一 recompute 产出)----
435
+ // 这里只放"扫描阶段知道的事实",不自己拼 display/support/loadResult:
436
+ // 那三个字段的唯一产出点是 entry-states 的 recompute(),多拼一处就会多一套口径。
437
+ /** 容器在根内的相对路径(entry-states 的键 = `${rootId}/${rel}`) */
438
+ rel,
439
+ /** 已解包过的包:index.json 里真实记录的 pluginId(null = 没解过,归属未定) */
440
+ ownerProvider: owner && owner.id ? owner.id : null,
441
+ /** 声明可认领该类资源的提供者(按 matchPriority 降序) */
442
+ candidates: Array.isArray(provider.candidates) ? provider.candidates.map((c) => c.id) : [],
443
+ };
444
+ }
445
+
446
+ /**
447
+ * 容器列举的规模上限 —— 值来自**实测**,不是拍脑袋。
448
+ *
449
+ * 实测 6 个 enabled 资源根(2026-10 现场):
450
+ * 文件最大深度 3(bd2)|目录数 max 535(azurlane)|容器命中最多 19(azurlane_bundle,全在根层)
451
+ * nikke_2 的 3 个容器在**第二层**(`<角色名>/<包>`)—— 这正是"只扫根层"会漏掉的那类。
452
+ * 深度取「实测 max + 1」,给未来更深的 Mod 类根留一层余量。
453
+ *
454
+ * 超限**绝不静默截断**:stopped 带回原因,由调用方写进 warnings
455
+ * (静默截断的表现是"某些根莫名其妙列不出包",是最难查的一类故障)。
456
+ */
457
+ const BUNDLE_SCAN_MAX_DEPTH = 4;
458
+ const BUNDLE_SCAN_MAX_DIRS = 3000;
459
+ const BUNDLE_SCAN_MAX_HITS = 500;
460
+
461
+ /**
462
+ * 递归找容器单文件(纯 readdir,**不读已知扩展名的文件头**)。
463
+ *
464
+ * 为什么不复用 `tree.listTree`(实测驱动的决定,别改回去):
465
+ * listTree 除 readdir 外还会对每个 `.skel` 调 `detectSkelVersion`、对 `.atlas` 调 `detectAtlasKey`,
466
+ * 那是"给前端浏览展示元信息"才需要的。拿它做容器列举实测 12.5s(热)/ 31s(冷);
467
+ * 换成纯 readdir 后 1.36s(冷 1.46s),快 9 倍。
468
+ * 两者**职责不同**(一个要完整元信息、一个只想快速找到容器),不是重复实现。
469
+ * "顺手统一成 listTree"会把冷启动拖回 31 秒 —— 这条注释就是防这个的。
470
+ *
471
+ * 只对**未知扩展名**的文件读 8 字节魔数,扩展名集合复用 tree 的 `KNOWN_FILE_EXT`(同一份真相):
472
+ * 实测 bd2 / azurlane / azurlane_spine 三个大根的未知扩展名文件数都是 **0**,
473
+ * 即这三个根一次文件打开都不需要,成本全在 readdir 本身。
474
+ *
475
+ * 返回 `byDir`(按目录聚合)是为将来「按 dirSignature 缓存」留的钩子:
476
+ * 要缓存时可按目录粒度判断哪一层失效,不必整根重扫。当前不缓存(实测冷启动 1.46s 可接受)。
477
+ *
478
+ * @param {string} rootPath 资源根绝对路径
479
+ * @param {(abs: string) => (string|null)} detectContainer 魔数判定(返回 'unityfs' 或 null)
480
+ * @param {{maxDepth?: number, maxDirs?: number, maxHits?: number}} [opts] 上限覆盖(测试用)
481
+ * @returns {{files: Array<{name: string, rel: string, container: string, size: number}>,
482
+ * byDir: Record<string, Array<object>>, stopped: string|null,
483
+ * stats: {dirs: number, unknownFiles: number, hits: number}}}
484
+ */
485
+ export function findContainerFiles(rootPath, detectContainer, opts = {}) {
486
+ const maxDepth = opts.maxDepth ?? BUNDLE_SCAN_MAX_DEPTH;
487
+ const maxDirs = opts.maxDirs ?? BUNDLE_SCAN_MAX_DIRS;
488
+ const maxHits = opts.maxHits ?? BUNDLE_SCAN_MAX_HITS;
489
+ const files = [];
490
+ const byDir = {};
491
+ const stats = { dirs: 0, unknownFiles: 0, depthSkipped: 0, hits: 0 };
492
+ // stopped 只表示「列举被资源上限硬中断」(目录数 / 命中数)——那两种必须整体停手。
493
+ // 深度超限**不算硬中断**:它只意味着"不再往这一支深入",同层与其它兄弟目录必须继续扫。
494
+ // 曾经把深度超限也写进 stopped,结果 DFS 一碰到深支就整体 return,
495
+ // 同层还没处理的容器全被跳过(实测「上限 2 层」时命中数变成 0),属于过度截断。
496
+ let stopped = null;
497
+
498
+ function walk(abs, rel, depth) {
499
+ if (stopped) return;
500
+ if (depth > maxDepth) { stats.depthSkipped += 1; return; }
501
+ if (stats.dirs >= maxDirs) { stopped = `目录数上限 ${maxDirs} 个`; return; }
502
+ stats.dirs += 1;
503
+ let entries;
504
+ try {
505
+ entries = fs.readdirSync(abs, { withFileTypes: true });
506
+ } catch {
507
+ return; // 读不了的目录跳过(权限不足 / 并发删除),不阻断整根
508
+ }
509
+ for (const entry of entries) {
510
+ if (stopped) return;
511
+ const full = path.join(abs, entry.name);
512
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
513
+ if (entry.isDirectory()) { walk(full, childRel, depth + 1); continue; }
514
+ if (!entry.isFile()) continue;
515
+ const ext = path.extname(entry.name).toLowerCase();
516
+ if (ext && KNOWN_FILE_EXT.has(ext)) continue; // 已知普通文件:不打开、不探魔数
517
+ stats.unknownFiles += 1;
518
+ const container = detectContainer(full);
519
+ if (!container) continue;
520
+ let size = 0;
521
+ try { size = fs.statSync(full).size; } catch { /* 并发删除:按 0 记,不阻断 */ }
522
+ const containerFile = { name: entry.name, rel: childRel, container, size };
523
+ files.push(containerFile);
524
+ if (!byDir[rel]) byDir[rel] = [];
525
+ byDir[rel].push(containerFile);
526
+ stats.hits += 1;
527
+ if (stats.hits >= maxHits) { stopped = `容器命中上限 ${maxHits} 个`; return; }
528
+ }
529
+ }
530
+
531
+ walk(rootPath, '', 0);
532
+ return { files, byDir, stopped, stats };
533
+ }
534
+
535
+ /**
536
+ * 列资源包根下的条目(递归,不再只看根层)。
537
+ *
538
+ * 为什么必须递归:资源包的实际摆法不统一 —— 有的根把包平铺在根层
539
+ * (azurlane_bundle:19 个包全在 L0),有的按 `<角色名>/<包>` 分目录
540
+ * (nikke_2:3 个包都在 L1)。只看根层时后者 catalog 条目为 0,
541
+ * 表现是"资源包明明在盘上,模型列表里却没有"。
542
+ *
543
+ * @returns {{entries: Array, warnings: string[], skipped: number}}
544
+ */
545
+ export function listBundleEntries(cfg, { plugins, unpack = null }) {
546
+ const provider = resolveBundleProvider(plugins);
547
+ const warnings = provider.warning ? [provider.warning] : [];
548
+ // 已解包过的包 → 实际解出它的插件(展示归属的唯一可信来源)。每次列举只扫一遍缓存目录。
549
+ const accentById = new Map(plugins.readPlugins().map((p) => [p.id, p.accent || null]));
550
+ const owned = unpack && typeof unpack.cachedProviders === 'function' ? unpack.cachedProviders() : new Map();
551
+
552
+ const found = findContainerFiles(cfg.path, plugins.detectBundleContainer);
553
+ if (found.stopped) {
554
+ warnings.push(`容器列举在「${found.stopped}」处停止,可能有未列出的资源包(已列出 ${found.files.length} 个)`);
555
+ }
556
+ if (found.stats.depthSkipped) {
557
+ warnings.push(`有 ${found.stats.depthSkipped} 个目录超过深度上限 ${BUNDLE_SCAN_MAX_DEPTH} 层未扫描,`
558
+ + '更深的资源包不会被列出');
559
+ }
560
+
561
+ const entries = found.files.map((f) => {
562
+ const ownerId = owned.get(`${cfg.id}/${f.rel || f.name}`) || null;
563
+ return toBundleEntry(cfg, f, provider, ownerId ? { id: ownerId, accent: accentById.get(ownerId) || null } : null);
564
+ });
565
+ // skipped 保持旧语义:**根层**「有文件但没探到容器魔数」的数量,不因递归而改变含义
566
+ const rootLevelContainers = found.files.filter((f) => !f.rel.includes('/')).length;
567
+ return { entries, warnings, skipped: countSkippedFiles(cfg.path, rootLevelContainers) };
568
+ }
569
+
570
+ /** 目录里「没有容器魔数、因此未列出」的文件数(只做计数,不解析文件头) */
571
+ function countSkippedFiles(rootPath, listed) {
572
+ try {
573
+ const total = fs.readdirSync(rootPath, { withFileTypes: true })
574
+ .filter((e) => e.isFile() && !e.name.startsWith('.')).length;
575
+ return Math.max(0, total - listed);
576
+ } catch {
577
+ return 0;
578
+ }
579
+ }
580
+
581
+ /**
582
+ * 资源根路径体检:返回问题类型码(null = 正常)。
583
+ *
584
+ * 为什么单独体检而不是靠扫描结果判断:扫不到条目的原因有三类,
585
+ * 文案必须不同("路径没了" / "路径不是目录" / "目录正常但真没模型"),
586
+ * 否则前端只能笼统显示"该游戏没有可加载模型",用户拿不到任何线索。
587
+ * 只看本地文件系统、不抛错:体检失败本身不该让接口 500。
588
+ * @param {string} rootPath 配置里的根路径
589
+ * @returns {'missing'|'not-dir'|'unreadable'|null}
590
+ */
591
+ /**
592
+ * 这条 warning 是"通用能力说明"还是"这一根的具体问题"(契约 §四 的分轨判据)。
593
+ *
594
+ * 判据只看**语义类别**,不看具体文案措辞:能力说明讲的是"整体扫描/插件能力边界"
595
+ * (深度上限、插件数量、插件未声明类型…),根级问题讲的是"这一根哪里不对"
596
+ * (路径没了、扫描抛错、某文件缺失、列举被截断)。
597
+ * 宁严勿宽:拿不准的一律算根级问题(`rootIssues`)—— 混进 capabilityNotes 会让用户看不到该看的那条。
598
+ * @param {string} text
599
+ * @returns {boolean}
600
+ */
601
+ function isCapabilityNote(text) {
602
+ const s = String(text || '');
603
+ return /超过深度上限|未扫描|插件|提供者|未声明|需要运行时枚举|需运行时枚举/.test(s);
604
+ }
605
+
606
+ /**
607
+ * 目录型条目的"形态"键(契约 §一·五:目录型是 spine-dir / live2d-dir)。
608
+ *
609
+ * 为什么放模块级:它是"内容类型 ↔ 提供者 fileKinds"的固定映射,与任何一次请求无关;
610
+ * 放在装配函数里会变成每次调用重建一个闭包,也让那个函数更难读。
611
+ * @param {string|null} kind 内容类型(spine / live2d)
612
+ * @returns {string|null} 未知类型返回 null(不猜)
613
+ */
614
+ function dirKindOf(kind) {
615
+ if (kind === 'live2d') return 'live2d-dir';
616
+ if (kind === 'spine') return 'spine-dir';
617
+ return null;
618
+ }
619
+
620
+ function inspectRootPath(rootPath) {
621
+ let stat = null;
622
+ try {
623
+ stat = fs.statSync(rootPath);
624
+ } catch {
625
+ return 'missing';
626
+ }
627
+ if (!stat.isDirectory()) return 'not-dir';
628
+ try {
629
+ fs.accessSync(rootPath, fs.constants.R_OK);
630
+ } catch {
631
+ return 'unreadable';
632
+ }
633
+ return null;
634
+ }
635
+
636
+ /**
637
+ * 根级问题的用户可见文案(**不带绝对路径**:那是排障信息,只进日志/diag)。
638
+ * 只写"哪根出了什么问题",让前端能直接展示。
639
+ */
640
+ const ROOT_PATH_PROBLEM_TEXT = {
641
+ missing: '资源根路径不存在(目录被移动或磁盘未挂载)',
642
+ 'not-dir': '资源根路径不是目录(配置里填的可能是文件)', unreadable: '资源根目录不可读(权限不足)',
643
+ };
644
+
645
+ /**
646
+ * 装配模型目录(合并该资源根下的 Spine 与 Live2D 两路扫描)。
647
+ * @param {{config: object, spine: object, live2d: object}} deps
648
+ */
649
+ export function createCatalog({ config, spine, live2d, tree, plugins, unpack = null, entryStates = null }) {
650
+ /**
651
+ * 解包缓存句柄(只读用途:查"这个包被哪个插件解出来过")。
652
+ * 装配顺序上 store 在 data 之后创建(serve.mjs),所以留 setter 注入,
653
+ * 不去改启动顺序 —— 那会牵动一批初始化依赖。
654
+ * 没注入时一切照旧:容器条目 badgeAccent=null(前端中性色)。
655
+ */
656
+ let unpackRef = unpack;
657
+ function setUnpackStore(store) { unpackRef = store || null; }
658
+ /**
659
+ * 最近一次**各根** catalog 构建的服务端耗时(只保留最近一次,不做历史数组)。
660
+ *
661
+ * 为什么需要:`/api/catalog` 的耗时在「清单缓存命中」与「真扫盘重建」之间差一个数量级,
662
+ * 前端只能看到"总共多久",看不出是哪一根慢、慢在命中还是重建。
663
+ * 零副作用:只在这里记账;`getCatalogStats()` 只读这份内存,绝不触发任何重建。
664
+ */
665
+ const stats = { at: null, totalMs: 0, roots: [] };
666
+
667
+ /**
668
+ * 条目描述缓存:key(`${rootId}/${rel}`)→ 该条目的静态事实
669
+ * (候选提供者 / size / 缺贴图)。供 `/api/entries/result` 写读一体时喂 recompute。
670
+ *
671
+ * 为什么在 catalog 里记:这些事实来自扫描(谁认领这个形态、缺哪些贴图),
672
+ * 只有 catalog 知道;entry-states 只管"状态 + 上下文 → 三字段"。
673
+ * 容量上限:按最后访问顺序淘汰,避免长跑进程无限增长(条目数万级时也就几 MB)。
674
+ */
675
+ const ENTRY_DESC_MAX = 60000;
676
+ const entryDesc = new Map();
677
+ /**
678
+ * 待探测条目(§八 触发判据 a/b):读路径只**登记**,由后台任务消费。
679
+ *
680
+ * 为什么用 Set 且允许重复登记:同一根会在每次 /api/catalog 时重新遍历一遍,
681
+ * 探测还没跑完(或刚好被限速卡住)时这些条目会再次被登记 —— 去重靠 Set 天然做到,
682
+ * 重复登记不产生额外代价(不读盘、不排队多份)。
683
+ */
684
+ const probeQueue = new Set();
685
+ /**
686
+ * 探测触发钩子(§八 判据 a:清单重建后)。
687
+ *
688
+ * 为什么用"可注入的钩子"而不是让 catalog 直接 import 探测模块:
689
+ * catalog 是纯读路径、且被测试大量直接构造;一旦反向依赖后台任务,
690
+ * 单测就会连带起定时器/读盘。由装配层(index.mjs)把 kick 注进来,依赖方向保持单向。
691
+ * @type {null | ((rootId: string) => void)}
692
+ */
693
+ let probeNotifier = null;
694
+ /** 装配层注入触发钩子(传 null 可摘掉) */
695
+ function setProbeNotifier(fn) {
696
+ probeNotifier = typeof fn === 'function' ? fn : null;
697
+ }
698
+ function rememberEntry(key, desc) {
699
+ entryDesc.delete(key);
700
+ entryDesc.set(key, desc);
701
+ if (entryDesc.size > ENTRY_DESC_MAX) {
702
+ const oldest = entryDesc.keys().next();
703
+ if (!oldest.done) entryDesc.delete(oldest.value);
704
+ }
705
+ }
706
+
707
+ /**
708
+ * 查一条目的静态事实(没扫过这一根时返回空对象:调用方按"未知"处理,不编造)。
709
+ * @param {string} key `${rootId}/${rel}`
710
+ * @returns {{candidates?: string[], size?: number, missingTextures?: string[], assetDependency?: object|null}}
711
+ */
712
+ function describeEntry(key) {
713
+ return entryDesc.get(String(key || '')) || {};
714
+ }
715
+
716
+ /**
717
+ * 取走当前待探测队列(取走即清空,由探测任务持有,避免两边各跑一遍)。
718
+ *
719
+ * 为什么"取走"而不是"只读":探测任务按根串行、逐包限速,可能跨多次调用才跑完;
720
+ * 只要 key 还留在队列里,下一次 /api/catalog 就会再登记一次,队列永远清不空。
721
+ * @param {number} [max] 一次最多取多少(限速用)
722
+ * @returns {string[]} `${rootId}/${rel}` 列表
723
+ */
724
+ function takeProbeKeys(max = Infinity) {
725
+ const out = [];
726
+ for (const key of probeQueue) {
727
+ out.push(key);
728
+ probeQueue.delete(key);
729
+ if (out.length >= max) break;
730
+ }
731
+ return out;
732
+ }
733
+
734
+ /** 探测没跑完就中止时把 key 放回队列(不丢,下一轮继续) */
735
+ function requeueProbeKey(key) {
736
+ const k = String(key || '');
737
+ if (k) probeQueue.add(k);
738
+ }
739
+
740
+ /** 队列长度(自检/日志用:S10 要能证明"指纹没变时没有待探测项") */
741
+ function probeQueueSize() {
742
+ return probeQueue.size;
743
+ }
744
+
745
+ /** 记一条根的耗时(同一根只留最新一条;totalMs 按各根最新值求和) */
746
+ function recordRootStat(one) {
747
+ const i = stats.roots.findIndex((r) => r.rootId === one.rootId);
748
+ if (i >= 0) stats.roots[i] = one; else stats.roots.push(one);
749
+ stats.at = one.at;
750
+ stats.totalMs = stats.roots.reduce((s, r) => s + (Number(r.ms) || 0), 0);
751
+ }
752
+
753
+ /**
754
+ * @param {string} rootId
755
+ * @returns {{status: number, body: object}}
756
+ */
757
+ /**
758
+ * 第①段·扫描:把一个根扫成条目数组(普通模型 + 容器单文件)+ 这一根的诊断素材。
759
+ *
760
+ * 为什么拆出来:原 listCatalog 两百行、审计按"函数过长"拦过提交;按
761
+ * 「扫描 / 组装三字段 / 包装响应」三段切开后每段只做一件事,也便于各自单独验证。
762
+ * **行为零变化**:只是把原来线性排下来的代码按职责分成三段。
763
+ * @param {{id: string, name?: string, path: string}} cfg 资源根配置
764
+ * @returns {{entries: Array, warnings: string[], scanErrors: Error[],
765
+ * cachedAll: boolean, pathProblem: string|null}}
766
+ */
767
+ function scanRootEntries(cfg) {
768
+ const entries = [];
769
+ const warnings = [];
770
+ /** 扫描过程中抛的错(用于判断 rootWarning 该说"扫不动"还是"真的空") */
771
+ const scanErrors = [];
772
+ /** 两根扫描器是否都走了清单缓存(决定 source 报 cache 还是 built) */
773
+ let cachedAll = true;
774
+ // 根路径体检放在扫描之前:路径都没了就不要再问扫描器"里面有什么"
775
+ const pathProblem = inspectRootPath(cfg.path);
776
+
777
+ try {
778
+ const scan = spine.getSpineModels(cfg, false);
779
+ for (const m of scan.models) entries.push(toSpineEntry(cfg, m));
780
+ if (scan.cached !== true) cachedAll = false;
781
+ } catch (e) {
782
+ scanErrors.push(e);
783
+ warnings.push(`spine 扫描失败:${e.message}`);
784
+ }
785
+ try {
786
+ const scan = live2d.getLive2DModels(cfg, false);
787
+ for (const lm of scan.models) entries.push(toLive2DEntry(cfg, lm));
788
+ if (scan.cached !== true) cachedAll = false;
789
+ } catch (e) {
790
+ scanErrors.push(e);
791
+ warnings.push(`live2d 扫描失败:${e.message}`);
792
+ }
793
+
794
+ // 容器单文件(资源包本质是打包来的模型):与普通模型一起出现在同一份清单里,
795
+ // 所以"同一个根里既有普通模型又有容器"能被同时识别。
796
+ // 条目 kind 取插件声明的 produces.kind(内容类型),容器标记另行保留给装载层解包用。
797
+ const bundle = listBundleEntries(cfg, { tree, plugins, unpack: unpackRef });
798
+ entries.push(...bundle.entries);
799
+ if (bundle.warnings.length) warnings.push(...bundle.warnings);
800
+
801
+ return { entries, warnings, scanErrors, cachedAll, pathProblem, skipped: bundle.skipped };
802
+ }
803
+
804
+ function listCatalog(rootId) {
805
+ const t0 = performance.now();
806
+ const cfg = config.readConfig().find((r) => r.id === rootId);
807
+ if (!cfg) {
808
+ // 形状与其它接口的错误体保持一致;按约定这里用 404(资源根不存在)
809
+ return { status: 404, body: { error: `未找到资源根: ${rootId}` } };
810
+ }
811
+ const { entries, warnings, scanErrors, cachedAll, pathProblem, skipped } = scanRootEntries(cfg);
812
+ const { sawStale } = assembleEntryStates(cfg, entries);
813
+ // §八 触发来源(三选一,别混成一条):这一根里只要有一条是"旧结论作废"排进来的,
814
+ // 就说明这轮探测是**能力变化**引起的(fingerprint);否则是清单重建引起的(catalog)。
815
+ const probeTrigger = sawStale ? 'fingerprint' : 'catalog';
816
+ return buildCatalogResponse({
817
+ cfg, t0, entries, warnings, scanErrors, cachedAll, pathProblem, skipped, probeTrigger,
818
+ });
819
+ }
820
+
821
+ /**
822
+ * 第②段·组装三字段:给扫描出来的条目补 candidates / ownerProvider / display / support / loadResult。
823
+ *
824
+ * 这是"展示归属"的唯一装配点(契约 §一 INV-D1/D4):颜色、可装载性、最近一次结果
825
+ * 全部由 entry-states 的 recompute 产出,这里只负责把上下文喂对。
826
+ * @param {{id: string}} cfg 资源根
827
+ * @param {Array} entries 扫描产物(就地写入三字段)
828
+ */
829
+ function assembleEntryStates(cfg, entries) {
830
+ // ---- 展示归属:聚合键是 **fileKinds**,不是 produces.kind ----
831
+ //
832
+ // 为什么必须用 fileKinds(改错这里所有散目录条目会一起掉成中性色):
833
+ // 内置装载器(builtin-spine/builtin-live2d)与插件**同时**声明了 produces.kind='spine'/'live2d',
834
+ // 若按 produces.kind 反查"谁认领",每个目录型条目都会得到两个候选 ⇒ 判不出 ⇒ accent=null。
835
+ // 而 fileKinds 是**互斥**的:内置项是 'spine-dir'/'live2d-dir',资源包插件是 'bundle'。
836
+ // ⇒ 目录型条目只匹配到 builtin-*,容器条目只匹配到 bundle 插件。
837
+ const providers = plugins.readPlugins();
838
+ // 提供者指纹:**一次算一次**,喂给每条记录的规则①判定(INV-D6)。
839
+ // 不传的后果实测过:recompute 里判不了"能力是否变过" ⇒ 规则①整段跳过 ⇒
840
+ // 插件升级后旧的"不支持"结论永远挂着(这正是本次要修的断链)。
841
+ const providerFp = providerFingerprintOf(providers);
842
+ const byFileKind = buildFileKindIndex(providers);
843
+ // sawStale = 这一根里是否有条目因"旧结论作废"而进了待探测队列(决定 §八 的触发来源)
844
+ let sawStale = false;
845
+ for (const e of entries) {
846
+ const computed = applyEntryStateTo(cfg, e, { providers, providerFp, byFileKind });
847
+ if (computed && computed.pendingProbe) sawStale = true;
848
+ }
849
+ return { sawStale };
850
+ }
851
+
852
+ /**
853
+ * 建"条目形态 → 能认领它的提供者"索引(按 matchPriority 降序,同分按 id 稳定排序)。
854
+ *
855
+ * 单独成函数的原因:这张表只与注册表有关、与条目无关,一条目录几万条目也只该建一次。
856
+ * @param {Array<{id: string, fileKinds?: string[], matchPriority?: number}>} providers
857
+ * @returns {Map<string, Array>}
858
+ */
859
+ function buildFileKindIndex(providers) {
860
+ const byFileKind = new Map();
861
+ for (const p of providers) {
862
+ for (const fk of (Array.isArray(p.fileKinds) ? p.fileKinds : [])) {
863
+ const k = String(fk);
864
+ if (!byFileKind.has(k)) byFileKind.set(k, []);
865
+ byFileKind.get(k).push(p);
866
+ }
867
+ }
868
+ for (const list of byFileKind.values()) {
869
+ list.sort((a, b) => (Number(b.matchPriority) || 0) - (Number(a.matchPriority) || 0)
870
+ || String(a.id).localeCompare(String(b.id)));
871
+ }
872
+ return byFileKind;
873
+ }
874
+
875
+ /**
876
+ * 给**一个**条目组三字段(assembleEntryStates 的循环体)。
877
+ * 只做"把算好的结果写到条目上 + 归属兜底",判断逻辑都在下面两个函数里。
878
+ * @param {{id: string}} cfg 资源根
879
+ * @param {object} e 条目(就地写入)
880
+ * @param {{providers: Array, providerFp: string, byFileKind: Map}} common 每条共享的上下文
881
+ */
882
+ function applyEntryStateTo(cfg, e, common) {
883
+ e.candidates = candidatesFor(e, common.byFileKind);
884
+ if (!e.container) {
885
+ // 目录型:唯一候选 ⇒ 归属确定;多候选/零候选 ⇒ null(中性色,不猜)
886
+ e.ownerProvider = e.candidates.length === 1 ? e.candidates[0] : null;
887
+ }
888
+ const computed = computeEntryStateFor(cfg, e, common);
889
+ e.display = computed.display;
890
+ e.support = computed.support;
891
+ e.loadResult = computed.loadResult;
892
+ applyOwnerFallback(e, common.providers);
893
+ // 回报给调用方:assembleEntryStates 要靠 pendingProbe 判断"这轮探测是不是能力变化引起的"
894
+ return computed;
895
+ }
896
+
897
+ /**
898
+ * 这条目能被哪些提供者认领(顺序即 matchPriority 顺序)。
899
+ * @param {object} e 条目
900
+ * @param {Map<string, Array>} byFileKind 形态 → 提供者
901
+ * @returns {string[]} 提供者 id 列表(可能是空数组 = 没人认领)
902
+ */
903
+ function candidatesFor(e, byFileKind) {
904
+ const fileKind = e.container ? 'bundle' : dirKindOf(e.kind);
905
+ const cands = (fileKind && byFileKind.get(fileKind)) || [];
906
+ // 容器条目在 toBundleEntry 里已经带过一份候选,这里用 fileKinds 的结论覆盖(更全、口径统一)
907
+ return cands.map((p) => String(p.id));
908
+ }
909
+
910
+ /**
911
+ * 取这一条的静态事实 + 存储状态 ⇒ recompute 出三字段(顺带登记待探测)。
912
+ * @param {{id: string}} cfg 资源根
913
+ * @param {object} e 条目
914
+ * @param {{providers: Array, providerFp: string}} common
915
+ * @returns {{display: object, support: object, loadResult: object|null, pendingProbe: boolean}}
916
+ */
917
+ function computeEntryStateFor(cfg, e, common) {
918
+ const { providers, providerFp } = common;
919
+ const key = `${cfg.id}/${e.rel || e.name}`;
920
+ e.stateKey = key;
921
+ // 条目的静态事实(谁认领 / 尺寸 / 缺哪些贴图)——**只写一次**:
922
+ // 既记进 describeEntry 供 /api/entries/result 写读一体复用,也喂本行的 recompute。
923
+ // 两处各写一份字面量就是"改一处漏一处"的经典漂移源;
924
+ // 不含 providers:消费方(contextFor)自己取注册表,存进缓存只会白占内存。
925
+ const entryFacts = {
926
+ candidates: e.candidates,
927
+ size: Number.isFinite(e.size) ? Number(e.size) : undefined,
928
+ // 目录型条目没有"未解包"这一说 ⇒ 传 undefined 表示"没做资产检查"→ unknown,
929
+ // 有 missingTextures(含空数组)时才由 recompute 判 ready/unsupported
930
+ missingTextures: e.meta && Array.isArray(e.meta.missingTextures) ? e.meta.missingTextures : undefined,
931
+ assetDependency: null,
932
+ };
933
+ rememberEntry(key, entryFacts);
934
+ const state = entryStates ? entryStates.get(key) : null;
935
+ // 规则①(能力变了 ⇒ 结论作废)与规则②(文件换了 ⇒ 该条失效)的判据都在这里接上:
936
+ // providerFingerprint 缺了规则①形同虚设;assetFingerprint 缺了规则②永远不触发。
937
+ const computed = entryStates
938
+ ? entryStates.recompute(state, {
939
+ providers,
940
+ ...entryFacts,
941
+ providerFingerprint: providerFp,
942
+ assetFingerprint: assetFingerprintOf(e.size),
943
+ })
944
+ : { display: { accent: null, provider: null, candidates: e.candidates }, support: { state: 'unknown', detail: null }, loadResult: null, pendingProbe: false };
945
+ // 待探测登记(§八 判据 a/b):**同步路径只登记**,绝不在这次请求里解析包体。
946
+ // · 容器条目:从没结论,或旧结论因能力/文件变化作废 ⇒ 都要排
947
+ // · 目录型条目:support 来自资产检查(缺贴图),与包内形态无关 ⇒ 不排
948
+ if (e.container && (!state || computed.pendingProbe)) probeQueue.add(key);
949
+ return computed;
950
+ }
951
+
952
+ /**
953
+ * 归属兜底:唯一认领者明确时补上 provider/accent。
954
+ *
955
+ * 只在"还没有归属"时补 —— `loadResult.ok` 时 recompute 已经给对了(一致性约束:
956
+ * ok 的 loadResult 说了算),这里覆盖它就是拿声明色去盖事实色。
957
+ * @param {object} e 条目(就地写入 display)
958
+ * @param {Array} providers 提供者注册表
959
+ */
960
+ function applyOwnerFallback(e, providers) {
961
+ if (!e.ownerProvider || e.display.provider) return;
962
+ const hit = providers.find((p) => String(p.id) === String(e.ownerProvider));
963
+ if (!hit) return;
964
+ e.display.provider = String(hit.id);
965
+ e.display.accent = hit.accent ? String(hit.accent) : null;
966
+ }
967
+
968
+ /**
969
+ * 第③段·包装响应:根级诊断(rootWarning / rootIssues / capabilityNotes)+ 耗时记账 + 返回体。
970
+ * 只做"把前两段的产物翻译成 HTTP 响应",不再碰条目本身(想知道字段怎么来的看第②段)。
971
+ * @param {{cfg: object, t0: number, entries: Array, warnings: string[],
972
+ * scanErrors: Error[], cachedAll: boolean, pathProblem: string|null, skipped?: number,
973
+ * probeTrigger?: 'catalog'|'fingerprint'|'manual'}} p
974
+ * @returns {{status: number, body: object}}
975
+ */
976
+ function buildCatalogResponse({ cfg, t0, entries, warnings, scanErrors, cachedAll, pathProblem, skipped, probeTrigger }) {
977
+ const rootWarning = computeRootWarning({ cfg, pathProblem, scanErrors, entries });
978
+
979
+ // 记账(只此一处写):耗时口径 = 本次 listCatalog 从入口到末尾的墙钟时间,
980
+ // source 只有两种:两根扫描器都命中清单缓存 ⇒ cache;任一重建(或抛错)⇒ built。
981
+ // 容器列举本身没有清单缓存、每次都要 readdir,所以它不计入 cache/built 的判定
982
+ // (这一点必须写清:否则"cache 却也慢"会被当成 bug 去查)。
983
+ recordRootStat({
984
+ rootId: cfg.id,
985
+ ms: Math.round(performance.now() - t0),
986
+ count: entries.length,
987
+ source: cachedAll ? 'cache' : 'built',
988
+ at: Date.now(),
989
+ });
990
+
991
+ const { rootIssues, capabilityNotes } = splitWarnings(warnings, rootWarning, cfg.id);
992
+
993
+ // "还剩多少容器没探" —— **纯附加字段**,只读快照,不触发任何扫描/解析。
994
+ // 为什么要它:将来面板要显示探测进度时,不必再改服务端(前端拿这一次请求就够了)。
995
+ // 口径:此刻**排队中**的条目数;不含本轮即将被取走的那一批(触发在下面,取值在之前)。
996
+ const probeRemaining = probeQueue.size;
997
+
998
+ // 喊一声"这一根扫完了"(§八 触发判据 a):同步路径只负责触发,不等待、不读包。
999
+ // 没有待探测项时探测器内部会立刻返回,等于零开销(S10)。
1000
+ // 触发来源一起传下去:catalog(清单重建)/ fingerprint(旧结论作废)/ manual(手动 POST)
1001
+ if (probeNotifier) {
1002
+ try { probeNotifier(cfg.id, probeTrigger || 'catalog'); } catch { /* 触发失败不影响列表本身 */ }
1003
+ }
1004
+
1005
+ return {
1006
+ status: 200,
1007
+ body: {
1008
+ root: { id: cfg.id, name: cfg.name },
1009
+ count: entries.length,
1010
+ // 这一根的清单来源:两根扫描器都命中缓存 ⇒ cache;任一重建 ⇒ built。
1011
+ // 给前端名册计时用(perRoot 要能看出"哪根慢在命中还是重建"),纯附加字段,
1012
+ // 不改变任何缓存行为。
1013
+ source: cachedAll ? 'cache' : 'built',
1014
+ entries,
1015
+ rootWarning,
1016
+ rootIssues,
1017
+ capabilityNotes,
1018
+ // 后台探测进度(本次不做 UI,数据先就绪):剩多少条待探。旧字段一个没动。
1019
+ probeRemaining,
1020
+ ...(skipped ? { skipped } : {}),
1021
+ // 兼容期已结束:能力说明去 capabilityNotes、根级问题去 rootIssues/rootWarning,
1022
+ // warnings 不再承载任何内容 —— 仍保留成空数组而不是删字段,
1023
+ // 是为了让"读 warnings 后直接 .map/.join"的旧消费方拿到 [] 而不是 undefined。
1024
+ warnings: [],
1025
+ },
1026
+ };
1027
+ }
1028
+
1029
+ /**
1030
+ * 根级诊断:**这根本身有没有问题**(与"这根确实没有模型"要能区分)。
1031
+ *
1032
+ * 为什么单开一个字段而不是塞进 warnings:warnings 里混着通用能力说明
1033
+ * (例如「有 N 个能解包资源包的插件…」),前端拿它拼用户提示会得出
1034
+ * 「因为有两个插件所以没模型」这种错误因果(前端实测踩过)。
1035
+ * 现状:拆分已完成 —— 对外 `warnings` 恒为空数组,根级问题只走本字段 / `rootIssues`。
1036
+ * 上面说的"混着"指**扫描汇总的中间数组**,不是响应体里的 warnings(别按旧行为读它)。
1037
+ * 文案一律**不带绝对路径**(那是排障信息,进日志),只带 rootId 便于对照配置。
1038
+ * 优先级:路径问题 > 扫描抛错 > 扫描成功但 0 条目。
1039
+ * @param {{cfg: {id: string}, pathProblem: string|null, scanErrors: Error[], entries: Array}} p
1040
+ * @returns {string|null}
1041
+ */
1042
+ function computeRootWarning({ cfg, pathProblem, scanErrors, entries }) {
1043
+ // 三种根级问题的文案都以它结尾:格式只在这里定义一次(改样式不用改三处)
1044
+ const rootTag = `(rootId=${cfg.id})`;
1045
+ if (pathProblem) return `${ROOT_PATH_PROBLEM_TEXT[pathProblem]}${rootTag}`;
1046
+ if (scanErrors.length) return `资源根扫描失败${rootTag}:具体原因见 warnings`;
1047
+ if (!entries.length) return `扫描完成但没有任何模型条目${rootTag}`;
1048
+ return null;
1049
+ }
1050
+
1051
+ /**
1052
+ * warnings 分轨(契约 §四):
1053
+ * rootIssues[] = "这一根怎么了"(可拼用户提示,带 rootId、不带绝对路径)
1054
+ * capabilityNotes[] = 通用能力说明("有几个插件能解包"这类),**不许**拿来拼因果
1055
+ *
1056
+ * 为什么必须拆:两者混在一起时,前端拿 warnings 拼提示会得出
1057
+ * "因为有两个插件所以没有模型" 这种错误因果(实测踩过)。
1058
+ * 现状:拆分已落地,响应里的 `warnings` 恒为空数组;本函数只负责把**中间数组**分到两轨。
1059
+ * @param {string[]} warnings 扫描汇总
1060
+ * @param {string|null} rootWarning 根级结论(可能为 null)
1061
+ * @param {string} rootId 资源根 id
1062
+ * @returns {{rootIssues: Array<{rootId: string, text: string}>, capabilityNotes: string[]}}
1063
+ */
1064
+ function splitWarnings(warnings, rootWarning, rootId) {
1065
+ const capabilityNotes = warnings.filter((w) => isCapabilityNote(w));
1066
+ const inlineRootIssues = warnings.filter((w) => !isCapabilityNote(w))
1067
+ .map((text) => ({ rootId, text }));
1068
+ return {
1069
+ rootIssues: [
1070
+ ...(rootWarning ? [{ rootId, text: rootWarning }] : []),
1071
+ ...inlineRootIssues,
1072
+ ],
1073
+ capabilityNotes,
1074
+ };
1075
+ }
1076
+
1077
+ /**
1078
+ * 只读快照:最近一次各根 catalog 构建的服务端耗时。
1079
+ * **零副作用** —— 绝不在请求时触发重建,只把内存里记下的数拷一份出来。
1080
+ * @returns {{at: number|null, totalMs: number, roots: Array<{rootId: string, ms: number, count: number, source: 'cache'|'built', at: number}>}}
1081
+ */
1082
+ function getCatalogStats() {
1083
+ return { at: stats.at, totalMs: stats.totalMs, roots: stats.roots.map((r) => ({ ...r })) };
1084
+ }
1085
+
1086
+ return {
1087
+ listCatalog, getCatalogStats, setUnpackStore, describeEntry,
1088
+ // §八 后台探测的取件口(只登记与取件,判定与写回在 data/shape-probe.mjs)
1089
+ takeProbeKeys, requeueProbeKey, probeQueueSize, setProbeNotifier,
1090
+ };
1091
+ }