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,891 @@
1
+ // dsh-skip-quality: install() 是编排型容器函数(19 个具名子函数平均 25 行,本身无实现代码);
2
+ // 硬拆须把 PLUGIN_ID/profileCache/ulbLog 等闭包私有状态提升为模块级,浏览器与 node 两侧都受影响,风险大于收益。
3
+ // ============================================================================
4
+ // plugins/unity-live2d-bundle/index.js —— 「Unity Live2D 资源包」插件注册与解析入口
5
+ //
6
+ // 适用对象:**UnityFS 打包的单文件 Live2D 资源包**(无扩展名或任意扩展名),覆盖两种包型:
7
+ // ① 原生 Cubism 文件集:包内直接有 .moc3 / .model3.json / .motion3.json / .physics3.json
8
+ // → 直接抽取即可,不需要重建任何东西
9
+ // ② Unity 集成版:只有 moc3(MonoBehaviour 字节数组)+ physics3.json(TextAsset)+ 纹理
10
+ // + AnimationClip 曲线(动作)
11
+ // → 需要上层重建 model3.json、把 AnimationClip 转成 motion3.json(转换器在 lib/ 里,
12
+ // 本文件只负责"把素材取出来",不在此处做转换)
13
+ //
14
+ // 与游戏无关:所有解析走 lib/ 的通用实现;同族容器的数据性差异(动作循环规则、淡入淡出、
15
+ // 点击热区)放在 profiles/unityfs-live2d.json,由 file.profile 指定(缺省走通用规则)。
16
+ // 注意:该 profile 文件名按**容器类型**命名而非游戏名——插件只分辨文件类型(fileKinds=bundle),
17
+ // 按游戏名命名会让"碧蓝航线专用"的错觉传递下去,也会与资源根配置里的同名项撞名造成误读。
18
+ //
19
+ // 对外契约(宿主 plugin-host.js):
20
+ // register({ id, name, label, fileKinds, kind, canHandle, resolve })
21
+ // label —— 角色库里变体标签用的文字(宿主按类型决定配色,插件不自创配色)
22
+ // canHandle(file) → boolean 同步纯判定(契约 §3.1):是不是本插件能读的容器
23
+ // resolve(file) → { prefix, files, entry } 契约 §1.2:容器 → 文件表 + 入口
24
+ // ============================================================================
25
+
26
+ 'use strict';
27
+
28
+ // 脚本顶层用「带标签的块 + break」代替 IIFE 里的 return:
29
+ // 插件文件是以 <script> 形式注入的,裸块内 return 属非法语法(IIFE 展开后必须改写早退路径)。
30
+ //
31
+ // node 侧可 import(服务端解包 17a):本文件既是浏览器里的注入式经典脚本,也要能在 node 里被 import。
32
+ // 因此顶层**绝不直接引用 window/document**——node 里没有这两个全局,import 阶段就会
33
+ // ReferenceError 直接炸掉(没有 catch 的机会,与 collectParameterTable 同理:必须先判断再使用)。
34
+ // 宿主改由 getHost() 懒获取:浏览器里等价于原来的 window.SpineViewerPlugins,
35
+ // node 里没有宿主就返回 null,本文件转为「纯解析库」模式(只暴露 resolve/canHandle,不注册)。
36
+ //
37
+ // 实现方式:把整套安装逻辑收进 install(),浏览器与 node 都调它。
38
+ // 为什么不拆成 ESM export:浏览器以 <script> 注入,export 语句在经典脚本里是语法错误,
39
+ // 整个插件会加载失败。所以 node 侧用「条件挂到 globalThis」的方式暴露,文件仍保持经典脚本形态。
40
+
41
+ // ============================================================================
42
+ // 装配工厂(S10 拆分):原 626 行的 install() 里所有 helper 都困在同一个闭包里,
43
+ // 靠闭包捕获 host / NS / PLUGIN_ID / profileCache / paramTableCache / MIME_*。
44
+ // 这里按「闭包依赖」把它们提到模块级工厂,依赖改为显式参数注入 —— 函数体逐字未改。
45
+ // 两个缓存(profileCache / paramTableCache)仍在工厂内创建,即每次 install() 各一份。
46
+ // ============================================================================
47
+
48
+ /** 统一日志出口:host.log 优先,node 侧(无 host)降级 console。 */
49
+ function createLog({ host, PLUGIN_ID }) {
50
+
51
+ /**
52
+ * 统一日志出口(契约 §3.4 + §3.6 降级口径):
53
+ * 优先 host.log(level, msg, scope);宿主没提供 log(node 侧必然没有)时降级为 console[level],
54
+ * 但**不得静默丢弃**——降级也要让人看得见。
55
+ * 为什么收成一处:早期各处自己写 `host.log || console.xxx` 兜底,写法分散且容易被静态检查判成裸 console;
56
+ * 统一走这里,node 侧(无 host)自动落到 console,浏览器侧(有 host)自动走宿主日志。
57
+ */
58
+ const ulbLog = (level, msg) => {
59
+ const hostLog = (typeof window !== 'undefined' && window.SpineViewerPlugins && window.SpineViewerPlugins.log)
60
+ || (host && typeof host.log === 'function' ? host.log : null);
61
+ if (hostLog) { hostLog(level, msg, PLUGIN_ID); return; }
62
+ const c = console[level] || console.log;
63
+ if (c) c.call(console, `[${PLUGIN_ID}] ${msg}`);
64
+ };
65
+ return ulbLog;
66
+ }
67
+
68
+ /** 游戏适配数据(profiles/<id>.json)读取:含带超时的 fetch 与 profile 缓存。 */
69
+ function createProfileLoader({ host, NS, PLUGIN_ID, ulbLog }) {
70
+ // profile 缓存随每次 install 新建(不是模块级单例)
71
+ const profileCache = new Map();
72
+
73
+ /**
74
+ * 读游戏适配数据(profiles/<id>.json)。
75
+ * 路径由 profile 参数给出,不从名字猜;读不到就返回空对象(通用逻辑仍可工作)。
76
+ */
77
+ /** 外部请求超时上限(毫秒):profile 与 Blob 资源读取都必须有界,避免网络挂起时装载流程悬空 */
78
+ const FETCH_TIMEOUT_MS = 15000;
79
+
80
+ /**
81
+ * 带超时的 fetch:超时即 abort 并抛**带码**错误(ULB_ERR_IO),调用方按码决定重试/降级。
82
+ * 为什么不用裸 fetch:网络挂起时 fetch 可能长时间不返回,整个装载会一直悬着没有反馈。
83
+ */
84
+ async function fetchWithTimeout(url, opts, timeoutMs) {
85
+ const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
86
+ const limit = timeoutMs || FETCH_TIMEOUT_MS;
87
+ const timer = ctrl ? setTimeout(() => ctrl.abort(), limit) : null;
88
+ try {
89
+ return await fetch(url, Object.assign({}, opts || {}, ctrl ? { signal: ctrl.signal } : {}));
90
+ } catch (e) {
91
+ if (ctrl && ctrl.signal.aborted) {
92
+ throw NS.errors.fail('ULB_ERR_IO', `请求超时(${limit}ms):${String(url).slice(0, 48)}`);
93
+ }
94
+ throw e;
95
+ } finally {
96
+ if (timer) clearTimeout(timer);
97
+ }
98
+ }
99
+
100
+ async function loadProfile(name) {
101
+ if (!name) return {};
102
+ if (profileCache.has(name)) return profileCache.get(name);
103
+ let profileData = {}; // 不用泛用的 data:这里是「profile 适配数据」,写清楚用途
104
+ try {
105
+ const r = await fetchWithTimeout(`/plugins/${PLUGIN_ID}/profiles/${encodeURIComponent(name)}.json`, { cache: 'no-store' }, FETCH_TIMEOUT_MS);
106
+ if (r.ok) profileData = await r.json();
107
+ else ulbLog('info', `没有 profile「${name}」,按通用规则处理`);
108
+ } catch (e) {
109
+ ulbLog('warn', `读取 profile「${name}」失败:${e.message}`);
110
+ }
111
+ profileCache.set(name, profileData);
112
+ return profileData;
113
+ }
114
+ return loadProfile;
115
+ }
116
+
117
+ /** Cubism Core 元信息探针:画布尺寸 / 包围盒 / 遮罩计数 / 参数表。 */
118
+ function createCoreMetaProbes({ host, NS, ulbLog }) {
119
+
120
+
121
+ // 参数表缓存:同一份 moc3 反复 open 时不必重复创建 Cubism Core 的 Model
122
+ // (Core 的 Model 会占 WASM 堆内存且不随 JS 垃圾回收立即释放,连开多次会明显涨内存)。
123
+ // 键用 moc3 的指纹(长度 + 首尾各 8 字节)—— 同包内容相同即命中。
124
+ const paramTableCache = new Map();
125
+ function moc3Fingerprint(b) {
126
+ const head = Array.from(b.slice(0, 8)).map((x) => x.toString(16).padStart(2, '0')).join('');
127
+ const tail = Array.from(b.slice(b.length - 8)).map((x) => x.toString(16).padStart(2, '0')).join('');
128
+ return `${b.length}-${head}-${tail}`;
129
+ }
130
+
131
+ /**
132
+ * 取 moc3 **参数表**(Id + 类型 + 范围)—— 动画曲线能不能输出,以它为准。
133
+ *
134
+ * 为什么不用字符串表:moc3 的字符串池里还混着部件名、绘制名等"不是参数"的名字,
135
+ * 拿它当白名单会产出运行时不认识的 Id(Cubism 解析 motion3 时查不到参数对象直接抛错)。
136
+ * 参数表是 Cubism Core 自己的 API(Model.fromMoc → parameters),最权威,故直接用它。
137
+ *
138
+ * 取不到(精简环境没加载 Core 且脚本加载失败)时返回 null,调用方退化为"不校验"并留日志。
139
+ */
140
+ /** 读 moc3 CanvasInfo → designSize(画布像素尺寸 + 原点 + 单位换算)。 */
141
+ function readCanvasMeta(model) {
142
+ const ci = model.canvasinfo;
143
+ if (!ci || !(ci.CanvasWidth > 0) || !(ci.CanvasHeight > 0)) return null;
144
+ return {
145
+ width: ci.CanvasWidth,
146
+ height: ci.CanvasHeight,
147
+ originX: ci.CanvasOriginX,
148
+ originY: ci.CanvasOriginY,
149
+ pixelsPerUnit: ci.PixelsPerUnit || null,
150
+ source: 'moc3.CanvasInfo',
151
+ };
152
+ }
153
+
154
+ /** 扫描全部绘制对象的顶点,返回 min/max 与有效点数(无有效点返回 null)。 */
155
+ function scanVertexBounds(vertexPositions) {
156
+ if (!vertexPositions) return null;
157
+ let mnx = Infinity, mny = Infinity, mxx = -Infinity, mxy = -Infinity, n = 0;
158
+ for (let i = 0; i < vertexPositions.length; i++) {
159
+ const arr = vertexPositions[i];
160
+ if (!arr || !arr.length) continue;
161
+ for (let k = 0; k + 1 < arr.length; k += 2) {
162
+ const x = arr[k], y = arr[k + 1];
163
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
164
+ n++;
165
+ if (x < mnx) mnx = x;
166
+ if (x > mxx) mxx = x;
167
+ if (y < mny) mny = y;
168
+ if (y > mxy) mxy = y;
169
+ }
170
+ }
171
+ return n ? { mnx, mny, mxx, mxy, n } : null;
172
+ }
173
+
174
+ /** 读 rest pose 下全部绘制对象顶点的 min/max(模型单位),并按 ppu 附带像素尺寸。 */
175
+ function readRestBbox(model, designSize) {
176
+ try { model.update(); } catch (e) { /* 无参可调或无需 update 时忽略 */ }
177
+ const b = scanVertexBounds(model.drawables && model.drawables.vertexPositions);
178
+ if (!b) return null;
179
+ const ppu = designSize && designSize.pixelsPerUnit ? designSize.pixelsPerUnit : null;
180
+ return {
181
+ x: +b.mnx.toFixed(3),
182
+ y: +b.mny.toFixed(3),
183
+ width: +(b.mxx - b.mnx).toFixed(3),
184
+ height: +(b.mxy - b.mny).toFixed(3),
185
+ unit: 'model',
186
+ pose: 'rest',
187
+ vertexCount: b.n,
188
+ pixels: ppu ? { width: +((b.mxx - b.mnx) * ppu).toFixed(1), height: +((b.mxy - b.mny) * ppu).toFixed(1) } : null,
189
+ };
190
+ }
191
+
192
+ /** 遮罩组数 = 被引用的**不同**遮罩绘制对象数(同一遮罩可被多个对象引用)。 */
193
+ function countMaskGroups(model) {
194
+ const masks = model.drawables && model.drawables.masks;
195
+ if (!masks) return null;
196
+ const groups = new Set();
197
+ for (const refs of masks) {
198
+ if (!refs) continue;
199
+ for (const r of refs) groups.add(r);
200
+ }
201
+ return groups.size;
202
+ }
203
+
204
+ /**
205
+ * 从已构造的 Cubism Core 模型读「画布尺寸 + 顶点包围盒 + 结构计数」。
206
+ *
207
+ * 口径与限制(会随产出一起给前端,前端据此判断,不要自己重算):
208
+ * · designSize = moc3 的 CanvasInfo(CanvasWidth/CanvasHeight,Cubism 原始画布像素尺寸),
209
+ * 另给 originX/originY(画布原点,Cubism 在画布中心)与 pixelsPerUnit(模型单位 → 像素)。
210
+ * · bbox = **rest pose(默认参数)**下全部绘制对象顶点的 min/max,单位是**模型单位**;
211
+ * 不含物理、动作、表情造成的变形 —— 需要"动作全程"包围盒的调用方要自己采样。
212
+ */
213
+ function collectCoreModelMeta(core, model) {
214
+ const meta = {
215
+ drawableCount: null, partCount: null, parameterCount: null, maskGroupCount: null,
216
+ designSize: null, bbox: null,
217
+ };
218
+ try {
219
+ meta.drawableCount = model.drawables ? model.drawables.count : null;
220
+ meta.partCount = model.parts ? model.parts.count : null;
221
+ meta.parameterCount = model.parameters ? model.parameters.count : null;
222
+ meta.maskGroupCount = countMaskGroups(model);
223
+ meta.designSize = readCanvasMeta(model);
224
+ meta.bbox = readRestBbox(model, meta.designSize);
225
+ } catch (e) {
226
+ meta.error = e.message;
227
+ }
228
+ return meta;
229
+ }
230
+
231
+ async function collectParameterTable(moc3Bytes, timeoutMs) {
232
+ const limit = timeoutMs || 8000;
233
+ // node 侧兜底(服务端解包 17a):没有 DOM 时不能动态加载 Cubism Core,直接跳过参数表提取。
234
+ // 调用方(convertClipMotions)对 null 已有降级——只 warn「曲线 Id 不做白名单校验」,
235
+ // 产出形状与 files 条数都不变(白名单少一道校验,曲线仍按候选名 + CRC32 匹配输出)。
236
+ // 浏览器侧不受影响:有 document/window 时照旧走下面的动态加载分支。
237
+ // 这里必须**早返回**:node 里 document.createElement 会直接抛 ReferenceError,
238
+ // 而该异常会被外层 catch 吞掉只留一行 warn,看起来像"参数表读取失败"而非"无 DOM"。
239
+ if (typeof document === 'undefined' || typeof window === 'undefined') {
240
+ // 降级必须**可观察**(不能静默):走 host.log 优先、console 兜底的既有通道,
241
+ // 不用裸 console(契约 §3.4 要求用户可见输出统一走 host.log)。
242
+ ulbLog('info', 'node 侧无 DOM,跳过 moc3 参数表提取(曲线 Id 不做白名单校验)');
243
+ return null;
244
+ }
245
+ if (!window.Live2DCubismCore) {
246
+ // Core 通常由舞台在首次渲染时懒加载;插件自己按需补一次(已存在则跳过)
247
+ await new Promise((resolve) => {
248
+ const s = document.createElement('script');
249
+ s.src = '/live2d-lib/live2dcubismcore.min.js';
250
+ const timer = setTimeout(() => resolve('timeout'), limit);
251
+ s.onload = () => { clearTimeout(timer); resolve('ok'); };
252
+ s.onerror = () => { clearTimeout(timer); resolve('error'); };
253
+ document.head.appendChild(s);
254
+ });
255
+ }
256
+ const core = window.Live2DCubismCore;
257
+ if (!core || !core.Moc || !core.Model || typeof core.Model.fromMoc !== 'function') return null;
258
+ const cacheKey = moc3Fingerprint(moc3Bytes);
259
+ if (paramTableCache.has(cacheKey)) return paramTableCache.get(cacheKey);
260
+ try {
261
+ const ab = moc3Bytes.buffer
262
+ ? moc3Bytes.buffer.slice(moc3Bytes.byteOffset, moc3Bytes.byteOffset + moc3Bytes.byteLength)
263
+ : moc3Bytes;
264
+ const moc = core.Moc.fromArrayBuffer(ab);
265
+ const model = core.Model.fromMoc(moc);
266
+ const p = model.parameters;
267
+ if (!p || !p.ids) return null;
268
+ const table = {
269
+ ids: new Set(Array.from(p.ids)),
270
+ count: p.count,
271
+ types: p.types ? Array.from(p.types) : null,
272
+ minimumValues: p.minimumValues ? Array.from(p.minimumValues) : null,
273
+ maximumValues: p.maximumValues ? Array.from(p.maximumValues) : null,
274
+ defaultValues: p.defaultValues ? Array.from(p.defaultValues) : null,
275
+ partCount: model.parts ? model.parts.count : null,
276
+ // 模型元数据(画布尺寸 / rest pose 包围盒 / 结构计数):同一趟 Core 读取里一起拿,避免二次解析 moc3
277
+ meta: collectCoreModelMeta(core, model),
278
+ };
279
+ paramTableCache.set(cacheKey, table);
280
+ return table;
281
+ } catch (e) {
282
+ ulbLog('warn', `读取 moc3 参数表失败:${e.message}`);
283
+ return null;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * 从 physics3.json 里收集参数名(作为 CRC32 反查动画绑定的候选)。
289
+ * 任何解析失败都只是少一点候选,不影响主流程。
290
+ */
291
+ function collectParameterNames(physics3Bytes) {
292
+ const out = [];
293
+ if (!physics3Bytes) return out;
294
+ try {
295
+ const json = JSON.parse(NS.extract.decodeUtf8(physics3Bytes));
296
+ const walk = (o) => {
297
+ if (!o || typeof o !== 'object') return;
298
+ if (typeof o.Id === 'string') out.push(o.Id);
299
+ for (const k of Object.keys(o)) walk(o[k]);
300
+ };
301
+ walk(json);
302
+ } catch { /* 物理文件缺失/损坏都能接受 */ }
303
+ return out;
304
+ }
305
+ return { collectCoreModelMeta, collectParameterTable, collectParameterNames };
306
+ }
307
+
308
+ /** 容器工具:魔数判定 / 取字节 / 解析 CAB+resS / 纹理与动作转换 / 文件表构建。 */
309
+ function createContainerTools({ NS, host, ulbLog, MIME_OCTET, MIME_JSON, MIME_PNG, coreMeta }) {
310
+ // convertClipMotions 要用参数表白名单:容器组依赖核心元信息组(不是反过来)
311
+ const { collectParameterNames, collectParameterTable } = coreMeta;
312
+
313
+ /** 容器魔数判定(只读前 8 字节即可) */
314
+ function looksLikeUnityFs(bytes) {
315
+ if (!bytes || bytes.length < 8) return false;
316
+ return NS.bytes.latin1(bytes, 0, 7) === 'UnityFS' && bytes[7] === 0;
317
+ }
318
+
319
+ /**
320
+ * 取资源包字节(契约 §1.1 取字节两条通道 + §3.6 宿主能力面):
321
+ * ① file.bytes 存在 → 直接用(不产生 I/O)
322
+ * ② 不存在 → host.fetchFile(rootId, rel) → Promise<Uint8Array>(§3.6 规定签名,直接返回 Uint8Array)
323
+ * host 不可用(且无 bytes)→ 抛 ULB_ERR_NO_HOST(可退化,§3.6 缺省/降级列)。
324
+ */
325
+ async function readBundleBytes(ctx, hostApi) {
326
+ const given = ctx && ctx.bytes;
327
+ if (given) return given;
328
+ if (!ctx || !ctx.rootId || !ctx.rel) throw NS.errors.fail('ULB_ERR_BAD_ARGS', '缺少 rootId/rel,无法读取资源包');
329
+ // §3.6:host 缺失且 bytes 缺失时,不能降级出字节 → 抛带码错误(可退化)
330
+ const fetchFile = hostApi && hostApi.fetchFile;
331
+ if (typeof fetchFile !== 'function') {
332
+ throw NS.errors.fail('ULB_ERR_NO_HOST', '宿主未提供 fetchFile(rootId, rel) 且 file.bytes 缺失,无法读取资源包');
333
+ }
334
+ const bytes = await fetchFile(ctx.rootId, ctx.rel);
335
+ return bytes; // §3.6 签名:fetchFile 直接返回 Promise<Uint8Array>
336
+ }
337
+
338
+ /** 校验容器并取出 CAB(必需)与 resS(可选)两段字节。 */
339
+ function parseContainerNodes(bytes, displayName) {
340
+ if (!looksLikeUnityFs(bytes)) {
341
+ throw NS.errors.fail('ULB_ERR_NOT_UNITYFS', `不是 UnityFS 资源包:${displayName || '(未命名)'}`);
342
+ }
343
+ const parsed = NS.unityfs.parse(bytes);
344
+ const cabNode = parsed.nodes.find((n) => /^CAB-/.test(n.path));
345
+ if (!cabNode) throw NS.errors.fail('ULB_ERR_NO_CAB', '资源包里没有 CAB-* 节点(不是资产包?)');
346
+ const resSNode = parsed.nodes.find((n) => /\.resS$/.test(n.path));
347
+ return {
348
+ parsed,
349
+ cabNode,
350
+ cabBytes: NS.unityfs.nodeBytes(parsed, cabNode.path),
351
+ resSBytes: resSNode ? NS.unityfs.nodeBytes(parsed, resSNode.path) : null,
352
+ };
353
+ }
354
+
355
+ /**
356
+ * 逐张纹理**只收集压缩字节**(**不在这里解码**);单张失败只记录 error,不整体失败(报告里能看出是哪张坏)。
357
+ *
358
+ * 为什么把解码推迟到编码那一刻:一张 4096² 的 RGBA 是 64MB,
359
+ * 旧做法"全部解完 → 再逐张编码"会让峰值 = Σ(所有纹理 RGBA)(两张 4096² 就是 128MB 级);
360
+ * 改成"编码时才解、编完随作用域释放"后,常驻的只有压缩字节
361
+ * (`textureCompressedBytes` 返回的是 resS 的 subarray 视图,不拷贝)。
362
+ * 注意:这一步**只降常驻**,不改变产物;峰值的大头是 PNG 编码(见 docs/已知问题.md)。
363
+ */
364
+ function collectTextures(got, resSBytes) {
365
+ const textures = [];
366
+ for (const t of got.textures) {
367
+ const rec = {
368
+ name: t.name, width: t.width, height: t.height,
369
+ format: t.format, formatName: t.formatName, source: t.stream ? 'resS' : 'inline',
370
+ };
371
+ try {
372
+ rec.comp = NS.extract.textureCompressedBytes(t, resSBytes);
373
+ rec.bytes = rec.comp.length;
374
+ } catch (e) {
375
+ rec.error = e.message;
376
+ }
377
+ textures.push(rec);
378
+ }
379
+ return textures;
380
+ }
381
+
382
+ /** 动画曲线 → motion3(原生包已带 motion3 时跳过转换),同时给出候选名与参数表白名单。 */
383
+ async function convertClipMotions(got, sf, profile) {
384
+ const candidates = [...new Set([
385
+ ...NS.extract.moc3NameCandidates(got.moc3),
386
+ ...collectParameterNames(got.physics3),
387
+ ])];
388
+ const paramTable = await collectParameterTable(got.moc3);
389
+ if (!paramTable) ulbLog('warn', '未取到 moc3 参数表,曲线 Id 将不做白名单校验');
390
+ const converted = got.nativeFiles.some((f) => /motion3/i.test(f.name))
391
+ ? { motions: [], hashLookup: new Map(), dropped: [] }
392
+ : NS.animToMotion.clipsToMotions(sf, candidates, {
393
+ paramWhitelist: paramTable ? paramTable.ids : null,
394
+ isLoop: (n) => NS.model3.motionLoops(n, profile),
395
+ fadeIn: profile.motion && profile.motion.fadeInTime,
396
+ fadeOut: profile.motion && profile.motion.fadeOutTime,
397
+ });
398
+ return { converted, candidates, paramTable };
399
+ }
400
+
401
+ /**
402
+ * 把 Unity 集成版抽取出的素材整理成**契约形态的「文件表 + 入口」**:
403
+ * moc3 / 纹理 PNG / physics3 / motion3 / 重建的 model3.json 全部按「相对键」进 files,
404
+ * model3.json 的 FileReferences 引用 files 内的键(不写 Blob URL)。
405
+ *
406
+ * 为什么用相对键而不是 Blob URL(契约 §1.2):files 的值必须是文件内容(Blob/string),
407
+ * URL 不是内容、也没法被 VirtualFS 注册;核心按 prefix 注册后由渲染层解析相对引用。
408
+ * 旧实现曾把 Blob URL 写进 model3 并给页面加载器装直通中间件,那属于契约 §3.8 点名的问题形态
409
+ * ("想给加载器装中间件"= 产物形态设计有问题),现已按契约收敛为纯文件表。
410
+ *
411
+ * 键名规则:moc3 → 'moc3.moc3';纹理 → 'textures/<原纹理名>.png';
412
+ * physics3 → 'physics3.json';动作 → 'motions/<动作名>.motion3.json';入口 → 'model3.json'。
413
+ * 键只按素材角色命名,不含游戏名/资源根路径(契约 §五.6 禁止写死)。
414
+ *
415
+ * @returns {Promise<{files: Map<string,Blob>, entry: string}>}
416
+ */
417
+ async function buildFileSet(extracted, profile) {
418
+ const files = new Map(); // 相对键 → Blob(契约 §1.2:Blob|string;二进制一律 Blob)
419
+ const put = (key, bytes, mime) => { files.set(key, new Blob([bytes], { type: mime || MIME_OCTET })); return key; };
420
+
421
+ const KEY_MOC3 = 'moc3.moc3';
422
+ const KEY_PHYSICS = 'physics3.json';
423
+ const keyTexture = (n) => `textures/${n}.png`;
424
+ const keyMotion = (n) => `motions/${n}.motion3.json`;
425
+
426
+ const mocRef = put(KEY_MOC3, extracted.moc3, MIME_OCTET);
427
+ // 纹理:逐张压缩编码(不并发),编完立刻释放 RGBA 缓冲 —— 峰值内存比"两张 67MB 级 PNG 同时生成"低得多。
428
+ const textureRefs = [];
429
+ for (const t of extracted.textures) {
430
+ if (!t.comp) continue;
431
+ // 解一张 → 编一张:rgba 是**局部变量**,编完随作用域释放(不要挂到 t 上,挂上去就等于常驻)
432
+ const rgba = NS.texdecode.decode(t, t.comp);
433
+ const png = await NS.png.encodePngAsync(rgba, t.width, t.height);
434
+ t.pngBytes = png.length;
435
+ textureRefs.push(put(keyTexture(t.name), png, MIME_PNG));
436
+ }
437
+ const physicsRef = extracted.physics3 ? put(KEY_PHYSICS, extracted.physics3, MIME_JSON) : null;
438
+
439
+ // 动作:每个 motion3.json 一个键,并按 profile 规则分组(组内顺序 = 名字序,稳定可复现)
440
+ const motionDefs = [];
441
+ for (const m of extracted.motions) {
442
+ const json = m.json;
443
+ const ref = put(keyMotion(m.name), new TextEncoder().encode(JSON.stringify(json)), MIME_JSON);
444
+ motionDefs.push({ name: m.name, url: ref, group: NS.model3.motionGroupOf(m.name, profile), loop: !!json.Meta.Loop, meta: json.Meta });
445
+ }
446
+ motionDefs.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
447
+
448
+ const paramNames = extracted.paramNames || [];
449
+ const model3 = NS.model3.buildModel3({
450
+ mocUrl: mocRef,
451
+ textureUrls: textureRefs,
452
+ physicsUrl: physicsRef,
453
+ motions: motionDefs,
454
+ paramNames,
455
+ profile,
456
+ });
457
+ // 入口:重建的 model3.json 也进 files(契约 §1.2:entry 必须是 files 的键)
458
+ put('model3.json', new TextEncoder().encode(JSON.stringify(model3)), MIME_JSON);
459
+ return { files, entry: 'model3.json' };
460
+ }
461
+ return { looksLikeUnityFs, readBundleBytes, parseContainerNodes, collectTextures, convertClipMotions, buildFileSet };
462
+ }
463
+
464
+ /** 契约接口(资产提供者契约 §一):canHandle / resolve。 */
465
+ function createProvider({ NS, host, PLUGIN_ID, ulbLog, MIME_OCTET, MIME_JSON, MIME_PNG, loadProfile, container, coreMeta }) {
466
+ const { looksLikeUnityFs, readBundleBytes, parseContainerNodes, collectTextures, convertClipMotions, buildFileSet } = container;
467
+ const { collectCoreModelMeta, collectParameterTable, collectParameterNames } = coreMeta;
468
+
469
+
470
+ // ------------------------------------------------------------------
471
+ // 契约接口(资产提供者契约 §一):canHandle(file) → boolean / resolve(file) → { prefix, files, entry }
472
+ //
473
+ // resolve 把两种包型都整理成「文件表 + 入口」:
474
+ // · 原生 Cubism 文件集(包内已有 *.model3.json)→ 包内文件原样进表,entry 指包内那份 model3
475
+ // · Unity 集成版(只有 moc3 + 纹理 + 曲线)→ 重建一份「相对路径版」model3,entry 指它
476
+ // files 的值是真实的 Blob/字符串(契约 §1.2 要求 Map<relPath, Blob|string>,不得放 Blob URL),
477
+ // 核心拿到后按 prefix 注册 VirtualFS,渲染层按 entry 装载——插件不自行 register/revoke(§1.2)。
478
+ //
479
+ // prefix 规则(§1.2 写死):`vfs://<providerId>/<rootId>/<相对目录>/`,全局唯一、以 / 结尾。
480
+ // ------------------------------------------------------------------
481
+
482
+ /** 把 file(宿主传入的上下文)规整成旧 ctx 形状,缺字段给保守默认(不改语义)。 */
483
+ function asCtx(file) {
484
+ if (!file || typeof file !== 'object') return null;
485
+ return file;
486
+ }
487
+
488
+ /**
489
+ * 最近一次「不认领」的原因(模块级槽位,只对紧接着问上来的同一个文件有效)。
490
+ *
491
+ * 为什么用槽位而不是返回值:canHandle 的契约签名是 `(file) => boolean`,不能改(改了所有调用方都要动);
492
+ * 而宿主确实是"先 canHandle 再 explainReject"同步连续调用同一个 file 对象,
493
+ * 所以一个只认 fileKey 的槽位足够,且不会把原因张冠李戴到别的文件上。
494
+ */
495
+ let rejectNote = null;
496
+
497
+ /** 文件标识:只要 rootId|rel 即可区分(bytes 不参与,避免大包做哈希) */
498
+ function fileKey(ctx) {
499
+ if (!ctx || typeof ctx !== 'object') return '';
500
+ return `${ctx.rootId || ''}|${ctx.rel || ctx.name || ''}`;
501
+ }
502
+
503
+ /**
504
+ * 记下/清除不认领原因。
505
+ * @param {object} ctx 当前文件上下文
506
+ * @param {{code:string,message:string}|null} reason 插件给出的结构化原因;null 表示"没有可解释的原因"
507
+ */
508
+ function noteReject(ctx, reason) {
509
+ if (!reason) { rejectNote = null; return; }
510
+ rejectNote = { key: fileKey(ctx), code: reason.code, message: reason.message };
511
+ // 级别用 warn:这不是 bug(不抛错、不影响其它包),但**用户需要看见**——
512
+ // debug 用户看不到,error 会误导成故障。走统一出口 ulbLog,不裸 console。
513
+ ulbLog('warn', reason.message);
514
+ }
515
+
516
+ // 契约:canHandle 必须是纯判定(无副作用、返回布尔)。
517
+ // 契约 §3.1 判定边界 + §3.7 异常处理(文档已写死,无需再猜):
518
+ // 允许:读 file.bytes 做容器魔数判定(复用 bundle-probe,与 /api/bundle/probe 同口径)
519
+ // 禁止:任何 I/O、修改外部状态、把异常抛给宿主
520
+ // canHandle 内部 try/catch,捕获后 return false(§3.7 明许),catch 分支至少记一条 debug(§3.7)
521
+ // 三种情形(§3.1 表格):
522
+ // ① 有 bytes 且「UnityFS + CAB-* + 有 moc3」→ true
523
+ // ② 有 bytes 且上述不成立 → false
524
+ // ③ 没有 bytes → true(保守放行:判 false 会让无扩展名的包永远无法被选中)
525
+ // 注意:必须同步返回 boolean(§3.1 签名 canHandle(file) → boolean);
526
+ // 不能用 async——async 会返回 Promise,而 Promise 恒为真值,宿主按 if (canHandle(file)) 判断时所有文件都被放行,判定失效。
527
+ function canHandle(file) {
528
+ const ctx = asCtx(file);
529
+ const bytes = ctx && ctx.bytes ? ctx.bytes : null;
530
+ if (!bytes) return true;
531
+ try {
532
+ const probe = NS.bundleProbe.run(bytes);
533
+ // 收紧判定:只有「UnityFS + CAB-* + 包内有 moc3」才算本插件的包。
534
+ // 为什么不能只判容器头:同族容器还有 spine 资源包(实测 NIKKE = UnityFS v8 + .skel/.atlas/PNG,
535
+ // 包内无 moc3)。两个 asset-provider 插件若都按"容器头匹配"回答 true,宿主只取第一个
536
+ // (plugin-runner 的 pickPlugin 不重试下一个),被错认领的一方会在 resolve 阶段报
537
+ // 「找不到 moc3」——现场看起来是"这个包坏了",实际是判定太宽。
538
+ // 成本不增加:bundleProbe 本来就已经解出 moc3 存在性(bundle-probe.js:121)。
539
+ const accepted = probe.ok && !!probe.moc3;
540
+ // 不认领时把"为什么"记下来:canHandle 的契约只有 boolean,说不清原因,
541
+ // 于是用户在界面上只看到宿主那句固定的"插件判定不处理该文件"。
542
+ // 记在模块级槽位里,供 explainReject() 紧接着取出(宿主是 canHandle → explainReject 同步连续调用)。
543
+ noteReject(ctx, accepted ? null : probe.ok ? probe.reject : null);
544
+ return accepted;
545
+ } catch (e) {
546
+ // §3.7:catch 分支不得静默。这里**升到 warn**(不是 debug):
547
+ // canHandle 抛错通常不是"文件读不了"(那属于数据问题,返回 false 即可),
548
+ // 而是**调用方用法问题**——最典型的就是 bytes 传了 ArrayBuffer 而不是 Uint8Array(契约 §1.1),
549
+ // 此时表现为"这个文件没有提供者",只有 warn 才查得动。
550
+ ulbLog('warn', `canHandle 内部异常,按不可读处理:${e.message}`
551
+ + '(若 bytes 类型不符契约 §1.1 的 Uint8Array,请修宿主侧归一)');
552
+ noteReject(ctx, null);
553
+ return false;
554
+ }
555
+ }
556
+
557
+ /**
558
+ * 契约**可选**钩子:解释"上一次为什么没认领这个文件"。
559
+ *
560
+ * 为什么需要它:canHandle 的签名固定为 `(file) => boolean`(契约 §3.1),无法附带原因;
561
+ * 而"这个包是 Cubism 2.1 的 .moc"这种结论只有插件内部看得见(宿主那层只拿到一个 false),
562
+ * 结果就是界面上统一显示"插件判定不处理该文件"——既说不出这是什么,也给不出出路。
563
+ * 宿主在 not-handled 时若发现插件有此方法,就用它给出的文案替换固定文案。
564
+ *
565
+ * 只对**刚刚判定过的同一个文件**生效(按 rootId|rel 比对),避免把上一次的陈旧原因张冠李戴。
566
+ * @param {object} file 与 canHandle 收到的同一形状
567
+ * @returns {{code:string, message:string}|null} 没有可解释的原因时返回 null(宿主回落到原固定文案)
568
+ */
569
+ function explainReject(file) {
570
+ const ctx = asCtx(file);
571
+ if (!rejectNote || rejectNote.key !== fileKey(ctx)) return null;
572
+ return { code: rejectNote.code, message: rejectNote.message };
573
+ }
574
+
575
+
576
+ // 契约:resolve 产出 { prefix, files, entry };失败抛带 code 的错误(§3.4),不返回缺字段半成品(§5.8)。
577
+ // §1.2:prefix 格式 = `vfs://<providerId>/<rootId>/<相对目录>/`(全局唯一、以 / 结尾、目录不含文件名)。
578
+ // “相对目录”取 rel 的 dirname 部分(rel 指向容器文件本身,prefix 以该文件所在目录为命名空间)。
579
+ async function resolve(file) {
580
+ const ctx = asCtx(file);
581
+ if (!ctx || !ctx.rootId || !ctx.rel) {
582
+ // §1.1:rootId/rel 为必选;bytes 可选(缺了走宿主取资源通道,见 readBundleBytes)
583
+ throw NS.errors.fail('ULB_ERR_BAD_ARGS', 'resolve() 缺少必选字段:需要 file.rootId 与 file.rel');
584
+ }
585
+ // §3.6 宿主能力面:取字节通道② 的唯一合法形态是 host.fetchFile(rootId, rel) → Promise<Uint8Array>。
586
+ // hostApi 优先取 ctx.host(旧宿主把能力面挂在 ctx 上),否则取宿主注入面 host 本身
587
+ // (新契约下 host 由核心在调用插件前注入,能力面挂在 host 顶层);缺失时 readBundleBytes 抛 ULB_ERR_NO_HOST。
588
+ const hostApi = (ctx.host) || host;
589
+ const bytes = await readBundleBytes(ctx, hostApi);
590
+ const { parsed, cabNode, cabBytes, resSBytes } = parseContainerNodes(bytes, ctx && ctx.name);
591
+ const sf = NS.serialized.parse(cabBytes);
592
+ const got = NS.extract.extractAll({ cabBytes, resSBytes, sf });
593
+ const profile = await loadProfile(ctx && ctx.profile);
594
+
595
+ // 相对目录 = rel 去掉末尾文件名后的目录段(如 'a/b/c' → 'a/b';纯文件名 'c' → '')。
596
+ // prefix 恒以 / 结尾(§1.2 格式);rootId 与 relDir 之间保留单斜杠,relDir 为空时避免出现双斜杠。
597
+ const relDir = String(ctx.rel).replace(/\/+$/, '').split('/').slice(0, -1).join('/');
598
+ const dirSeg = relDir ? `${relDir}/` : '';
599
+ const prefix = `vfs://${PLUGIN_ID}/${String(ctx.rootId)}/${dirSeg}`;
600
+ // files 必须用 let:Unity 集成版分支会整体替换成 built.files(第 885 行附近),
601
+ // 用 const 会在那条路径上抛 "Assignment to constant variable"——
602
+ // 这个 bug 一直没暴露是因为 resolve() 此前从未被核心调用过(只有 open() 在跑)。
603
+ let files = new Map();
604
+ let entry = null;
605
+
606
+ if (got.nativeFiles.some((f) => /\.model3\.json$/i.test(f.name))) {
607
+ // 包型① 原生 Cubism 文件集:包内自带 model3/motion3/physics3,直接以原文件成表,不重建。
608
+ // entry = 包内 model3;纹理与 mocs 由包内 model3 的 FileReferences 相对引用,
609
+ // 这些相对路径与包内文件同根,核心注册 prefix 后可直接解析。
610
+ const textures = collectTextures(got, resSBytes);
611
+ for (const f of got.nativeFiles) {
612
+ files.set(f.name, new Blob([f.bytes], { type: /\.json$/i.test(f.name) ? MIME_JSON : MIME_OCTET }));
613
+ if (/\.model3\.json$/i.test(f.name)) entry = f.name;
614
+ }
615
+ // 原生 Cubism 文件集:包内可能自带 .png/.moc3 等原始文件(TextAsset 形态),直接进表;
616
+ // 若包内没有 PNG 纹理、但有 Unity 压缩纹理(Texture2D 对象),才走解码→PNG 编码路径。
617
+ // 键名与原文件名一致,保证包内 model3.json 的 FileReferences 相对引用能直接解析到。
618
+ let hasPngTexture = false;
619
+ for (const f of got.nativeFiles) {
620
+ if (/\.png$/i.test(f.name)) {
621
+ files.set(f.name, new Blob([f.bytes], { type: MIME_PNG }));
622
+ hasPngTexture = true;
623
+ }
624
+ }
625
+ if (!hasPngTexture) {
626
+ for (const t of textures) {
627
+ if (!t.comp) continue;
628
+ const rgba = NS.texdecode.decode(t, t.comp); // 局部变量,编完即释放
629
+ const png = await NS.png.encodePngAsync(rgba, t.width, t.height);
630
+ files.set(`${t.name}.png`, new Blob([png], { type: MIME_PNG }));
631
+ }
632
+ }
633
+ if (!entry) throw NS.errors.fail('ULB_ERR_MODEL_BUILD', '原生文件集里找不到 model3.json 入口');
634
+ return { prefix, files, entry };
635
+ }
636
+
637
+ // 包型② Unity 集成版:没有 model3,重建「相对路径版」(引用 files 内的键,不写 Blob URL)。
638
+ const textures = collectTextures(got, resSBytes);
639
+ const { converted, candidates, paramTable } = await convertClipMotions(got, sf, profile);
640
+ const extracted = {
641
+ moc3: got.moc3,
642
+ physics3: got.physics3,
643
+ textures,
644
+ motions: converted.motions,
645
+ paramNames: candidates,
646
+ meta: paramTable ? paramTable.meta : null,
647
+ };
648
+ const built = await buildFileSet(extracted, profile);
649
+ if (!built.files || built.files.size === 0) throw NS.errors.fail('ULB_ERR_EXTRACT', 'Unity 集成版没有可产出的素材文件');
650
+ // built.files 已含 moc3/纹理/physics3/motions/model3.json;entry 就是重建出的 model3.json 键
651
+ files = built.files;
652
+ entry = built.entry;
653
+ return { prefix, files, entry };
654
+ }
655
+ // explainReject 是**可选**扩展(契约未强制):宿主有则用、没有就回落到固定文案,向后兼容。
656
+ return { canHandle, resolve, explainReject };
657
+ }
658
+
659
+ /** 内存代价声明:分段峰值表 + 容器解压后大小(走 blocksSummary,不解数据块)。 */
660
+ function createMemoryEstimator({ NS }) {
661
+
662
+ // node 侧返回解析入口:服务端解包(17a)直接从返回值取 resolve/canHandle,不经过宿主注册。
663
+ /**
664
+ * 内存代价声明(契约 插件.md §2.2 的第②段:decompressedBytes)。
665
+ *
666
+ * 为什么由插件来算:只有解析容器的这一方知道"解压后到底多大"。
667
+ * 代价控制:走 `unityfs.blocksSummary`(**只读头 + blocksInfo,不解压数据块**),
668
+ * 实测 19 个真实包 P50 0.13ms / P90 0.91ms / max 2.43ms(对比整包 probe 20/56/173ms)。
669
+ * rgbaBytes 需要纹理尺寸(要解 CAB),准入路径不付这个代价 —— 由 resolve 完成后回填缓存。
670
+ * @param {{bytes?: Uint8Array}} file
671
+ * @returns {{decompressedBytes:number, compressedBytes:number}|null} 不是本插件能处理的容器时返回 null
672
+ */
673
+ /**
674
+ * 本插件容器(UnityFS + Live2D)的**峰值模型**——分段表在这里,不在核心。
675
+ *
676
+ * 为什么搬进插件:不同插件的容器结构完全不同,核心一张通用表对谁都不准,
677
+ * 而且每加一个插件都要重新标定。准不准由本插件负责;核心拿不到声明时才用源包大小粗估兜底。
678
+ *
679
+ * 数据来源:19 个真实 UnityFS Live2D 包实测(每档 = 该档实测 max × 1.15 安全垫),
680
+ * 变量取"解压后容器大小"(R²=0.971 / MAPE 2.5%)而不是源包大小(R²=0.695)。
681
+ * 11~12MB 这一小段同时存在 169MB 与 208MB 两种包,所以段划得比较密
682
+ * (边界贴着样本,属已知过拟合;缓存里的 measuredPeakBytes 会把它修正成真值)。
683
+ * @param {number} decompressedMb 解压后容器大小(MB)
684
+ * @returns {number} 预估峰值(MB,已含安全垫)
685
+ */
686
+ /**
687
+ * LZMA 峰值落点:解压后 MB → **峰值**(MB,未乘余量)。
688
+ *
689
+ * 基线:`6bcbb22` 换成"写入预分配 Uint8Array"的解码器之后实测的三个点
690
+ * `2.8MB → 97.8 | 12.86MB → 131.0 | 33.4MB → 218.1`
691
+ * ⇒ 新解码器下峰值几乎是平的(不再随解压量近似线性暴涨),与旧记录的 46 倍膨胀不是一回事。
692
+ *
693
+ * 取值原则:**宽松的整数**,不再按密集实测点插值。
694
+ * 旧表是旧解码器的实测点(2.8→171 … 33.4→995),换解码器后高估 1.7~4.6 倍 ⇒ 准入过严,
695
+ * 会把本来解得动的包挡掉;而"按点线性插值 + 累进上包络"这套复杂度,正是
696
+ * 「表点稀疏 ⇒ 局部低估」与「整体高估」两类问题反复出现的根源。
697
+ * 现在只留五个整数落点、严格递增(包络因此恒为空操作),每档都覆盖上面三点实测:
698
+ * 2MB→128 | 8MB→192 | 16MB→224 | 32MB→288 | 64MB→416
699
+ * 下限 128 表示"小包也至少按 128MB 判";末段按 16→32MB 的斜率外推,给更大的包留余量。
700
+ *
701
+ * 测量口径(与准入判断同一个量):
702
+ * `峰值 = 起始 RSS + 本次 VmHWM 增量` —— 与 src/server/unpack/plugin-store.mjs 的
703
+ * rssNow / recordMeasuredPeak 逐字同式,也就是说:**这个数就是服务端解这个世界包时会记录的峰值**。
704
+ *
705
+ * ⚠️ 将来换机器/升 V8 重标时:不要让"进程内连续解压"的结果混进来(VmHWM 只增不减,
706
+ * 后测的包会被前测的峰值带偏);标定工具 tool/fit-memory-peak.mjs 是按"每包独立干净进程"测的。
707
+ */
708
+ const LZMA_PEAK_POINTS = [[2, 128], [8, 192], [16, 224], [32, 288], [64, 416]];
709
+ /** 峰值安全垫:1.0 —— 表中已是宽松整数上界,不再叠乘系数 */
710
+ const PEAK_MARGIN = 1.0;
711
+
712
+ /**
713
+ * LZMA 路径的峰值(MB):落点之间线性插值,超出最大落点按末段斜率外推。
714
+ * 落点已按整数宽松取值且严格递增,所以这里的查表结果天然单调(包络是空操作,留着只为兜底)。
715
+ * @param {number} decompressedMb
716
+ * @returns {number}
717
+ */
718
+ function peakFromLzma(decompressedMb) {
719
+ if (!(decompressedMb > 0)) return 0;
720
+ // 累进上包络:即使将来有人手工往表里加了下降的点,也保证"更大的包不会估得更少"。
721
+ // 现值表严格递增,这一步因此是空操作 —— 留着是因为它比"约定不许写下降点"更可靠。
722
+ const pts = [];
723
+ let env = 0;
724
+ for (const [mb, peak] of LZMA_PEAK_POINTS) {
725
+ if (peak > env) env = peak;
726
+ pts.push([mb, env]);
727
+ }
728
+ // 比最小落点还小:按最小落点算 —— LZMA 的字典/窗口开销是固定的,小包不会更省
729
+ if (decompressedMb <= pts[0][0]) return Math.ceil(pts[0][1] * PEAK_MARGIN);
730
+ for (let i = 1; i < pts.length; i += 1) {
731
+ if (decompressedMb <= pts[i][0]) {
732
+ const [m0, v0] = pts[i - 1];
733
+ const [m1, v1] = pts[i];
734
+ const t = (decompressedMb - m0) / (m1 - m0);
735
+ // 线性插值,并钳到不低于下界值(防御:表若被手工改成下降,也不让结果低于更小的包)。
736
+ return Math.ceil(Math.max(v0, v0 + (v1 - v0) * t) * PEAK_MARGIN);
737
+ }
738
+ }
739
+ const [ma, va] = pts[pts.length - 2];
740
+ const [mb, vb] = pts[pts.length - 1];
741
+ // 斜率不小于 0:数据抖动导致末段下降时,不能外推出"越大越省"
742
+ const slope = Math.max(0, (vb - va) / (mb - ma));
743
+ return Math.ceil((vb + (decompressedMb - mb) * slope) * PEAK_MARGIN);
744
+ }
745
+
746
+ /**
747
+ * 按**压缩类型**取预估峰值(MB,已含安全垫)。
748
+ *
749
+ * 压缩类型不给时的取舍(这是刻意的,别改成"按 LZ4"):
750
+ * 认不出(老调用方没传 / 块类型混合 -1 / 未来新增类型)⇒ **按 LZMA(更保守)**。
751
+ * 低估 = 放行 → 真冲高 → OOM 或被守护中止(用户看到的是"崩了");
752
+ * 高估 = 这一次被拒或排队(用户看到的是"内存不足,请稍后重试",行为可预期)。
753
+ * 两者代价不对等,所以不确定时选更保守的那条。
754
+ *
755
+ * @param {number} decompressedMb 解压后容器大小(MB)
756
+ * @param {number} [compressionType] Unity CompressionType(0 NONE / 1 LZMA / 2 LZ4 / 3 LZ4HC;-1 混合)
757
+ * @returns {number} 预估峰值(MB)
758
+ */
759
+ function peakFromDecompressed(decompressedMb, compressionType) {
760
+ if (!(decompressedMb > 0)) return 0;
761
+ // 白名单式判断:只有**明确已知的非 LZMA 类型**(NONE=0 / LZ4=2 / LZ4HC=3)才走老表;
762
+ // 其余(LZMA=1、未传、混合 -1、未来新增类型)一律按 LZMA —— 见上面的取舍说明。
763
+ const knownNonLzma = compressionType === 0 || compressionType === 2 || compressionType === 3;
764
+ if (!knownNonLzma) return peakFromLzma(decompressedMb);
765
+ // 非 LZMA(LZ4 / LZ4HC / NONE)沿用原表 —— 它是按 LZ4 路径实测拟合的,别动
766
+ if (decompressedMb <= 5) return 147;
767
+ if (decompressedMb <= 8) return 177;
768
+ if (decompressedMb <= 11) return 192;
769
+ if (decompressedMb <= 11.5) return 239;
770
+ if (decompressedMb <= 11.95) return 194;
771
+ if (decompressedMb <= 12.5) return 214;
772
+ if (decompressedMb <= 16) return 216;
773
+ if (decompressedMb <= 25) return 267;
774
+ if (decompressedMb <= 45) return 423;
775
+ return Math.ceil(decompressedMb * 7.0 + 118); // >45MB 外推(与 45MB 处连续:7×45+118=433)
776
+ }
777
+
778
+ function estimateMemory(file) {
779
+ try {
780
+ // 不调 canHandle:那会跑整包 probe(解析 + 找纹理),而这里只需要块表。
781
+ // 传进来的 bytes 可能只是文件头部(宿主为了省读盘),越界时 blocksSummary 会抛错 ⇒ 返回 null ⇒ 宿主退回全量读。
782
+ const s = NS.unityfs.blocksSummary(file.bytes);
783
+ const decompressedMb = s.decompressedBytes / (1024 * 1024);
784
+ return {
785
+ decompressedBytes: s.decompressedBytes,
786
+ compressedBytes: s.compressedBytes,
787
+ // 压缩类型取自块表(blocksSummary 顺手带出,不额外扫包):
788
+ // LZMA 与 LZ4 的真实峰值差 ~1.8~2 倍,不区分就等于对 LZMA 系统性低估。
789
+ compressionType: s.compression,
790
+ // 本插件自己的模型给出峰值;核心直接采信、不再叠加安全垫(这个数里已含 15%)
791
+ peakBytes: Math.ceil(peakFromDecompressed(decompressedMb, s.compression) * 1024 * 1024),
792
+ };
793
+ } catch {
794
+ return null; // 判不出来就让核心走回落,绝不因此阻断加载
795
+ }
796
+ }
797
+ return { peakFromDecompressed, estimateMemory };
798
+ }
799
+
800
+ /** 浏览器侧:把本插件登记进宿主的资产提供者注册表(node 侧 host 为 null,整块跳过)。 */
801
+ function registerWithHost({ host, PLUGIN_ID, api }) {
802
+ const { canHandle, resolve, estimateMemory, explainReject } = api;
803
+
804
+ // 契约 §一:plugin.json 的 provides=["asset-provider"] 让宿主把本插件登记进资产提供者注册表。
805
+ // 注册对象上 kind 是产出内容类型(live2d),与 fileKinds 的容器类型 bundle 正交;
806
+ // kind 的唯一权威来源是 plugin.json 的 produces.kind(插件契约 §3.2),这里带上只为便于调试。
807
+ if (host) {
808
+ host.register({
809
+ id: PLUGIN_ID,
810
+ name: 'Unity Live2D 资源包',
811
+ label: 'Live2D包',
812
+ // 契约 §三 的注册面:只留新契约成员 canHandle/resolve。
813
+ canHandle,
814
+ resolve,
815
+ // 可选扩展:宿主在 not-handled 时用它拿到"为什么不认领"(浏览器侧与 node 侧同一份实现)
816
+ explainReject,
817
+ // 契约 §2.2:让宿主与检查器都能发现"本插件能自己算内存代价"(估得准不准由本插件负责)
818
+ estimateMemory,
819
+ kind: 'live2d',
820
+ fileKinds: ['bundle'],
821
+ });
822
+ }
823
+ }
824
+
825
+ /**
826
+ * 插件安装:环境探测 → 装配各职责块 → 宿主注册 → 返回解析入口。
827
+ * 只做编排;具体实现在上面的 createXxx 工厂里。
828
+ */
829
+ function install() {
830
+ const hasDom = typeof window !== 'undefined' && typeof document !== 'undefined';
831
+
832
+ // 宿主注入面懒获取。浏览器:window.SpineViewerPlugins(由 plugin-host.js 在注入本脚本前挂好)。
833
+ // node:没有宿主 → null(不注册,改走 globalThis 导出,见文件末尾)。
834
+ const host = (typeof window !== 'undefined' && window.SpineViewerPlugins) || null;
835
+ if (!host && hasDom) {
836
+ // node 侧无宿主不是错误:服务端解包只需要 resolve(),不需要注册。
837
+ // 浏览器侧无宿主才是异常(插件脚本被注入了但宿主没就绪),要留可见告警。
838
+ console.warn('[unity-live2d-bundle] SpineViewerPlugins 未就绪(plugin-host.js 未加载?),插件未注册');
839
+ return null;
840
+ }
841
+ // lib 挂载点:各 lib 文件自己往 globalThis.__ULB__ 挂能力,浏览器里 window 即 globalThis,
842
+ // 所以统一从 globalThis 取即可(node 侧同样落在 globalThis)。
843
+ const NS = (typeof globalThis !== 'undefined' && globalThis.__ULB__) || null;
844
+ if (!NS || !NS.unityfs || !NS.serialized || !NS.extract) {
845
+ if (hasDom) {
846
+ console.warn('[unity-live2d-bundle] lib 未加载完整(需要 bytes/common-strings/unityfs/serialized/typetree/extract),插件未注册');
847
+ return null;
848
+ }
849
+ // node 侧 lib 未加载也返回 null:调用方从 globalThis 上拿不到 resolve,会在自己那边报可读错误
850
+ }
851
+
852
+ const PLUGIN_ID = 'unity-live2d-bundle';
853
+
854
+ // Blob 的 MIME 常量集中一处:这三类在 buildFileSet 里各出现一次,
855
+ // 散着写字面量既难统一改,也让"这个键到底是什么类型"要跳到调用点才看得见。
856
+ const MIME_OCTET = 'application/octet-stream'; // moc3(Cubism 模型二进制)
857
+ const MIME_JSON = 'application/json'; // physics3 / motion3 / model3.json
858
+ const MIME_PNG = 'image/png'; // 纹理(ETC2/DXT 解码后编码的 PNG)
859
+ const ulbLog = createLog({ host, PLUGIN_ID });
860
+ const loadProfile = createProfileLoader({ host, NS, PLUGIN_ID, ulbLog });
861
+ const coreMeta = createCoreMetaProbes({ host, NS, ulbLog });
862
+ const container = createContainerTools({ NS, host, ulbLog, MIME_OCTET, MIME_JSON, MIME_PNG, coreMeta });
863
+ const provider = createProvider({ NS, host, PLUGIN_ID, ulbLog, MIME_OCTET, MIME_JSON, MIME_PNG, loadProfile, container, coreMeta });
864
+ const mem = createMemoryEstimator({ NS });
865
+
866
+ // 键顺序与原 return 字面量完全一致
867
+ const api = {
868
+ canHandle: provider.canHandle,
869
+ resolve: provider.resolve,
870
+ // 可选扩展:服务端 plugin-runner 在 not-handled 时用它给出"为什么不认领",取不到则回落固定文案
871
+ explainReject: provider.explainReject,
872
+ estimateMemory: mem.estimateMemory,
873
+ peakFromDecompressed: mem.peakFromDecompressed,
874
+ PLUGIN_ID,
875
+ };
876
+ registerWithHost({ host, PLUGIN_ID, api });
877
+ return api;
878
+ }
879
+
880
+ // 执行安装:浏览器里走宿主注册;node 里(无 window)拿到 { canHandle, resolve } 后挂到
881
+ // globalThis.__ULB_NODE__,供服务端 require/import 后取用。
882
+ // 为什么不写成 `export { resolve }`:那会把文件变成 ESM,浏览器以 <script> 注入时
883
+ // export 是语法错误 → 整个插件加载失败。保持经典脚本形态,node 侧用全局挂载暴露。
884
+ // 顶层常量用插件前缀命名:多个插件脚本注入**同一个全局作用域**,
885
+ // 通用名(ulb / api / ns)会与别的插件撞车 ⇒ 后加载的那个报
886
+ // Uncaught SyntaxError: Identifier 'x' has already been declared,插件整个不执行。
887
+ const ulbLive2dApi = install();
888
+ if (ulbLive2dApi && typeof window === 'undefined' && typeof globalThis !== 'undefined') {
889
+ globalThis.__PLUGIN_API__ = ulbLive2dApi;
890
+ globalThis.__ULB_NODE__ = ulbLive2dApi;
891
+ }