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,276 @@
1
+ // roster.js —— 名册数据层:资源根 → 模型列表 → 角色归并(M2)
2
+ //
3
+ // 做什么:
4
+ // 1. 按资源根取模型清单(三类来源,按根的 kind 与扫描结果选择);
5
+ // 2. 取该根的角色名表并与其它根合并(同一份名表只取一次);
6
+ // 3. 把模型归并成「角色 → 变体」,规则固定(不各自发明):
7
+ // · charKeyOf:去 cutscene_ 前缀 → 去 _hx 后缀 → 去尾部 _N(顺序不可换,见该文件注释)
8
+ // · assetCandidates:查表逐级放宽 原样 → 剥 _hx → 再剥 _N
9
+ // · 命中名表用英文名做分组键、标题显示「中文名 英文名」;未命中用 charKeyOf 结果兜底
10
+ // · 变体排序:立绘在前、技能(cutscene_)在后,再按资源名数字序
11
+ // · 角色排序:按显示名
12
+ // 4. 本模块只做数据,不碰 DOM(DOM 在 roster-view.js),便于单独验证归并结果。
13
+ //
14
+ // 数据源选择(实测依据):
15
+ // · 统一走 /api/catalog:它把 spine 模型、live2d 模型、容器单文件合并成一份清单,
16
+ // 所以发现阶段不再按"根的 kind"猜该问哪个扫描器(同一个根可以混合内容);
17
+ // · 容器单文件(资源包)由列举层用魔数标记 `container`,kind 取插件声明的产出类型;
18
+ // 改取 /api/live2d/models。
19
+
20
+ import { pickName } from '/shared/names.mjs';
21
+ import { getJson } from '../infra/api.js';
22
+ import { diagEvent } from '../app/status.js';
23
+ // 类型显示名来自面元数据表(label 字段)
24
+ import { byKind } from '../render/surfaces.js';
25
+
26
+ /** 资源名后缀归一化候选(与 char-tree.js 的 ASSET_SUFFIX_PATTERNS 一致) */
27
+ const ASSET_SUFFIX_PATTERNS = [/_hx$/i];
28
+
29
+ /**
30
+ * 基础角色键:去 cutscene_ 前缀 → 去 _hx → 去尾部 _N。
31
+ * 顺序不可换:`dafeng_2_hx` 先剥 _N 会因结尾非数字而剥不动,必须先剥 _hx。
32
+ * @param {string} name
33
+ * @returns {string}
34
+ */
35
+ export function charKeyOf(name) {
36
+ return String(name || '')
37
+ .replace(/^cutscene_/i, '')
38
+ .replace(/_hx$/i, '')
39
+ .replace(/_\d+$/, '');
40
+ }
41
+
42
+ /**
43
+ * 查表候选键(小写),逐级放宽:原样 → 剥 _hx → 再剥尾部 _N。
44
+ * @param {string} name
45
+ * @returns {string[]}
46
+ */
47
+ export function assetCandidates(name) {
48
+ // 键 = 真实资源文件名,**保留原始大小写**(设计:名表键原样,不做归一)
49
+ const raw = String(name || '');
50
+ const out = [raw];
51
+ for (const re of ASSET_SUFFIX_PATTERNS) {
52
+ const prev = out[out.length - 1];
53
+ const next = prev.replace(re, '');
54
+ if (next && next !== prev) out.push(next);
55
+ }
56
+ const prev = out[out.length - 1];
57
+ const noNum = prev.replace(/_\d+$/, '');
58
+ if (noNum && noNum !== prev) out.push(noNum);
59
+ return out;
60
+ }
61
+
62
+ /** 名表合并容器:byAsset(资源名 → 角色 id)+ characters(id → 角色信息) */
63
+ export function createNameIndex() {
64
+ return { byAsset: {}, characters: {}, cache: new Map() };
65
+ }
66
+
67
+ /**
68
+ * 把一份名表并进索引(多个资源根共用同一份名表时只并一次)。
69
+ * @param {{byAsset?: object, characters?: object}} index
70
+ * @param {{byAsset?: object, characters?: object}} table
71
+ */
72
+ export function mergeNameTable(index, table) {
73
+ if (!table) return;
74
+ Object.assign(index.byAsset, table.byAsset || {});
75
+ Object.assign(index.characters, table.characters || {});
76
+ index.cache.clear(); // 合并后旧查询结果作废
77
+ }
78
+
79
+ /**
80
+ * 查角色名(带缓存)。命中返回 {name, costume},未命中返回 null。
81
+ * @param {{byAsset: object, characters: object, cache: Map}} index
82
+ * @param {string} assetName
83
+ * @returns {{name: string, costume: string}|null}
84
+ */
85
+ export function lookupName(index, assetName) {
86
+ // 精确匹配:资产名与名表键都按**原样**比对,不做小写归一(名表键 = 真实文件名)。
87
+ // 归一化会让「只差大小写的两个资产」被错误合并,也会掩盖名表里写错的键。
88
+ const raw = String(assetName || '');
89
+ if (index.cache.has(raw)) return index.cache.get(raw);
90
+ let hit = null;
91
+ if (index.byAsset && index.characters) {
92
+ for (const key of assetCandidates(raw)) {
93
+ // byAsset 的值就只是角色 id 字符串,没有别的形态
94
+ const id = index.byAsset[key];
95
+ const c = id ? index.characters[id] : null;
96
+ const name = c ? pickName(c.name) : '';
97
+ if (name) {
98
+ hit = { name, costume: c ? pickName(c.costume) : '' };
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ index.cache.set(raw, hit);
104
+ return hit;
105
+ }
106
+
107
+ /**
108
+ * 取一个资源根的模型清单 —— **全仓唯一的 catalog 消费入口**。
109
+ *
110
+ * 为什么抽出来:此前有两处各写一遍、失败处理还不一致(url-params 静默吞 ⇒ 深链失效零提示;
111
+ * roster 直接抛 ⇒ 上层各自 catch)。现在统一成:失败一律
112
+ * ① 记一条 diag warn(带 root id 与原因)② 返回空清单 + 可读 reason(**不抛**),
113
+ * 由调用方按自己的语义决定"要不要把 reason 变成用户可见的失败状态"。
114
+ * 判据只看"有没有拿到 entries 数组",**不看 HTTP 状态码**
115
+ * (服务端对缺 root 的状态码正在从 404 改 400,按状态码写死会跟着坏)。
116
+ * `warnings` / `rootWarning` / `reason` 是**三档不同的东西**,别混:
117
+ * · reason = **拿不到清单**(rootId 不存在 / 5xx / 网络失败)—— 查询失败,可考虑重试
118
+ * · rootWarning = **清单拿到了,但这一根自己的扫描有问题**(服务端结构化字段,正常根为 null):
119
+ * 路径不存在 / 不是目录 / 不可读 / 扫描失败 / 扫完没有任何条目。
120
+ * **这是唯一可以拿去拼用户提示的一档**(文案由服务端给,带 rootId、不含绝对路径)
121
+ * · warnings = 通用说明(如"有 2 个插件能解包"),**混着与根无关的内容**。
122
+ * 拿它拼因果会得出"因为有两个插件所以没模型"这种错误结论 —— 只能进日志,不许进提示。
123
+ * @param {string} rootId
124
+ * @returns {Promise<{entries: object[], reason: string|null, warnings: string[], rootWarning: string|null}>}
125
+ */
126
+ export async function fetchRootEntries(rootId) {
127
+ const id = String(rootId || '');
128
+ if (!id) return { entries: [], reason: '资源根 id 为空', warnings: [], rootWarning: null };
129
+ let data = null;
130
+ try {
131
+ data = await getJson(`/api/catalog?root=${encodeURIComponent(id)}`);
132
+ } catch (e) {
133
+ const why = (e && e.message) || String(e);
134
+ diagEvent('catalog.fail', { root: id, reason: why }, { level: 'warn', src: 'app' });
135
+ return { entries: [], reason: `清单取不到:${why}`, warnings: [], rootWarning: null };
136
+ }
137
+ const warnings = Array.isArray(data && data.warnings) ? data.warnings.map((w) => String(w)) : [];
138
+ // 服务端的结构化"这一根怎么了"(正常根 null)。**只透传,不在这里编文案**
139
+ const rootWarning = (data && typeof data.rootWarning === 'string' && data.rootWarning) ? data.rootWarning : null;
140
+ const entries = Array.isArray(data && data.entries) ? data.entries : null;
141
+ if (!entries) {
142
+ diagEvent('catalog.fail', { root: id, reason: '响应里没有 entries 数组' }, { level: 'warn', src: 'app' });
143
+ return { entries: [], reason: '清单响应里没有 entries 数组', warnings, rootWarning };
144
+ }
145
+ // 成功但一条都没有、且服务端明确说了这一根有问题 ⇒ 记一条 warn:
146
+ // 否则 UI 只会显示"该游戏没有可加载模型",与"这个根真的没有模型"完全无法区分。
147
+ if (!entries.length && rootWarning) {
148
+ diagEvent('catalog.empty', { root: id, rootWarning }, { level: 'warn', src: 'app' });
149
+ }
150
+ return {
151
+ entries, reason: null, warnings, rootWarning,
152
+ // 服务端自报的清单来源(cache=命中清单缓存 / built=真扫盘重建);老服务端没有这个字段时为 null。
153
+ // 只透传、不在这里推断 —— 前端拿不到"服务端是命中还是重建"的任何本地证据。
154
+ serverSource: (data && (data.source === 'cache' || data.source === 'built')) ? data.source : null,
155
+ // 后台形态探测还剩多少条待探(服务端 §八 的附加字段)。
156
+ // **本次不做 UI**:这里只做透传,展示位由 view.js 挂到名册 state 上(见 state.probe 注释)。
157
+ // 老服务端没有这个字段 ⇒ null(不推断成 0,0 会被误读成"已探完")。
158
+ probeRemaining: (data && Number.isFinite(data.probeRemaining)) ? Number(data.probeRemaining) : null,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * 取一个资源根的模型清单。
164
+ * @param {{id: string, name: string, kind?: string}} root
165
+ * @returns {Promise<{models: object[], source: string}>}
166
+ * @throws 清单取不到时抛(上层 view.js 据此把该根标成 error 状态并显示原因,这条 UI 语义要保留)
167
+ */
168
+ export async function loadRootModels(root) {
169
+ // 单一发现路径:/api/catalog 一次给出该根下的**全部**模型条目
170
+ // (spine 模型 + live2d 模型 + 容器单文件),所以不必再"按根类型猜该问哪个扫描器",
171
+ // 同一个根里既能放普通模型目录、也能放容器文件。
172
+ // 容器条目的 kind 由插件声明(解包后是什么内容),并带 `container` 标记供装载层解包用。
173
+ const { entries: models, reason, serverSource, probeRemaining } = await fetchRootEntries(root.id);
174
+ // 抛给上层:view.js 捕获后把根标成 status='error' + 显示 error 文案。
175
+ // 若这里改成"返回空清单不抛",UI 会从"加载失败"退化成"这个根有 0 个模型"—— 信息反而更少。
176
+ if (reason) throw new Error(`${root.name || root.id}:${reason}`);
177
+ const containers = models.filter((m) => m.container).length;
178
+ diagEvent('bundle.parse', { root: encodeURIComponent(root.id), source: 'catalog', models: models.length, containers }, { level: 'info', src: 'app' });
179
+ // serverSource 只用于名册计时(perRoot.source),不参与渲染与缓存判断
180
+ // probeRemaining 同理只往上透传:名册把它挂在 state 上供将来面板读取(本次不做 UI)
181
+ return { models, source: 'catalog', serverSource: serverSource || null, probeRemaining: probeRemaining ?? null };
182
+ }
183
+
184
+ /**
185
+ * 取一个资源根的名表(未配置 names 时服务端返回 names:null,这里当作"没有名表")。
186
+ * @param {string} rootId
187
+ * @returns {Promise<object|null>}
188
+ */
189
+ export async function loadRootNames(rootId) {
190
+ try {
191
+ const r = await getJson(`/api/names?root=${encodeURIComponent(rootId)}`);
192
+ if (!r || !r.byAsset || !r.characters) return null;
193
+ return r;
194
+ } catch {
195
+ // 名表缺失/损坏不应阻断名册(无表时按资源名归组),但要把原因带给调用方显示
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /** 变体标签:Spine / Live2D / 资源包(与 char-tree.js 的 variantLabel 语义一致) */
201
+ export function variantLabel(m) {
202
+ if (!m) return 'Spine';
203
+ // 先判"是不是容器单文件":它的 kind 现在是内容类型(live2d),
204
+ // 若先判 kind 就会把资源包标成 Live2D,用户看不出它是包。
205
+ if (m.container) return '资源包';
206
+ // 类型名查面元数据表的 label(加新面时不必再来这里补一个三元式)
207
+ const meta = byKind(m.kind);
208
+ return meta ? meta.label : 'Spine';
209
+ }
210
+
211
+ /**
212
+ * 变体行的主文本:服装名(名表)优先,无名表时退回目录名/资源名。
213
+ * @param {object} m
214
+ * @param {object|null} nm 名表命中结果
215
+ * @returns {{text: string, origName: string}}
216
+ */
217
+ export function variantText(m, nm) {
218
+ const dirName = String(m.srcDir || '').split('/').slice(-1)[0] || '';
219
+ const origName = String(m.name || '');
220
+ const text = nm && nm.costume ? nm.costume : (dirName || origName);
221
+ return { text, origName };
222
+ }
223
+
224
+ /**
225
+ * 把一批模型归并成角色数组(照 char-tree.js 的 buildTree + finalizeTree 规则)。
226
+ * @param {object[]} models 同一游戏(可能来自多个资源根)的全部模型
227
+ * @param {object} nameIndex
228
+ * @param {string} [gameName] 游戏名;给了就拼进角色键(`char:游戏名|组名`,* 避免不同游戏下同名角色共用同一个展开键)
229
+ * @returns {{chars: object[], stats: {models: number, exact: number, suffix: number, miss: number}}}
230
+ */
231
+ export function buildChars(models, nameIndex, gameName = '') {
232
+ const stats = { models: models.length, exact: 0, suffix: 0, miss: 0 };
233
+ const charMap = new Map();
234
+
235
+ for (const m of models) {
236
+ const nm = lookupName(nameIndex, m.name);
237
+ if (nm) {
238
+ // via 只用于统计:原样命中 vs 剥后缀后才命中
239
+ const keys = assetCandidates(m.name);
240
+ const hitKey = keys.find((k) => nameIndex.byAsset[k]);
241
+ stats[hitKey === keys[0] ? 'exact' : 'suffix'] += 1;
242
+ } else {
243
+ stats.miss += 1;
244
+ }
245
+ const groupName = nm ? nm.name : charKeyOf(m.name);
246
+ const key = gameName ? `char:${gameName}|${groupName}` : `char:${groupName}`;
247
+ if (!charMap.has(key)) {
248
+ charMap.set(key, {
249
+ key,
250
+ groupName,
251
+ // 标题就是显示名(旧表时代 nameZh 几乎全空,等价于直接取 groupName)
252
+ title: groupName,
253
+ variants: [],
254
+ });
255
+ }
256
+ charMap.get(key).variants.push({ model: m, nm });
257
+ }
258
+
259
+ const chars = [...charMap.values()];
260
+ for (const c of chars) {
261
+ // 组内:立绘(非 cutscene_)在前,其余按资源名数字序
262
+ c.variants.sort((a, b) => rankVariant(a.model) - rankVariant(b.model)
263
+ || String(a.model.name).localeCompare(String(b.model.name), undefined, { numeric: true }));
264
+ c.baseName = c.variants.length ? charKeyOf(String(c.variants[0].model.name || '')) : '';
265
+ c.iconUrl = c.variants.map((v) => v.model.iconUrl).find(Boolean) || null;
266
+ }
267
+ // 角色排序:按显示名
268
+ chars.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }));
269
+
270
+ return { chars, stats };
271
+ }
272
+
273
+ /** 组内排序权重:技能/剧情变体(cutscene_)排后面 */
274
+ function rankVariant(model) {
275
+ return /^cutscene_/i.test(String(model.name || '')) ? 1 : 0;
276
+ }
@@ -0,0 +1,92 @@
1
+ // roster/persist.js —— 名册展开态与"上次选中"的持久化
2
+ //
3
+ // 键名与结构(modelstage.tree-state.v1):
4
+ // { games: [], chars: [], activeGame: '游戏名', lastByGame: { '游戏名': '变体id' }, lastModelId? }
5
+ // 单独成模块的原因:读写逻辑与渲染无关,且 view.js 需要守住单文件行数上限。
6
+
7
+ import * as uiState from '../app/ui-state.js';
8
+
9
+ /**
10
+ * 类型筛选的存储键。
11
+ *
12
+ * 为什么不并进 TREE_STATE_KEY:两者的生命周期不同——展开态是"临时视图状态",
13
+ * 类型筛选是跨会话保留的选择;分开存也让"读不懂的值直接忽略"只影响筛选,不牵动整棵树。
14
+ */
15
+ export const TYPE_FILTER_KEY = 'roster.typeFilter';
16
+
17
+ /**
18
+ * 名册状态存储键(**当前唯一键**,不是历史遗留)。
19
+ *
20
+ * 键名沿用项目早期命名,改名会让已有用户的"上次选中的模型/展开态"丢失,故保持不变;
21
+ * 读写统一经 app/ui-state 工厂代理,字段结构 `{games, chars, activeGame, lastByGame}` 不动。
22
+ */
23
+ export const LS_KEY = uiState.TREE_STATE_KEY;
24
+
25
+ /** 读展开态与上次选中(结构的 {games, chars, activeGame, lastByGame}) */
26
+ export function readSaved() {
27
+ try {
28
+ const s = uiState.getTreeState();
29
+ return {
30
+ games: new Set(Array.isArray(s.games) ? s.games : []),
31
+ chars: new Set(Array.isArray(s.chars) ? s.chars : []),
32
+ activeGame: typeof s.activeGame === 'string' ? s.activeGame : null,
33
+ lastByGame: s.lastByGame && typeof s.lastByGame === 'object' ? s.lastByGame : {},
34
+ // 旧格式:只有单个 lastModelId(更早版本写入的),读出来用于兼容恢复
35
+ lastModelId: typeof s.lastModelId === 'string' ? s.lastModelId : null,
36
+ };
37
+ } catch {
38
+ return { games: new Set(), chars: new Set(), activeGame: null, lastByGame: {}, lastModelId: null };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * 写展开态与每个游戏上次选中的变体(键名/结构沿用既有:activeGame 存游戏名)。
44
+ * @param {object} state 名册状态(读 expandedGames/expandedChars/activeGame/selected)
45
+ */
46
+ export function saveState(state) {
47
+ try {
48
+ const s = readSaved();
49
+ const lastByGame = { ...s.lastByGame };
50
+ if (state.activeGame && state.selected) lastByGame[state.activeGame] = state.selected.modelId;
51
+ uiState.setTreeState({
52
+ games: [...state.expandedGames],
53
+ chars: [...state.expandedChars],
54
+ activeGame: state.activeGame || s.activeGame,
55
+ lastByGame,
56
+ });
57
+ } catch {
58
+ // 隐私模式等写不了就忽略:持久化是增强,不影响当前会话
59
+ }
60
+ }
61
+
62
+ /**
63
+ * 读类型筛选的选中集合(数组;读不到/形状不对一律当空)。
64
+ * 注意:这里**不校验 key 是否仍然有效**——失效项的忽略发生在渲染期(见 roster/type-filter.js
65
+ * 的 usableTypeSelection),这样存储里的值不会被我们悄悄删掉。
66
+ * @returns {string[]}
67
+ */
68
+ export function readTypeFilter() {
69
+ try {
70
+ const raw = localStorage.getItem(TYPE_FILTER_KEY);
71
+ if (!raw) return [];
72
+ const parsed = JSON.parse(raw);
73
+ return Array.isArray(parsed) ? parsed.filter((key) => typeof key === 'string' && key) : [];
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ /**
80
+ * 写类型筛选(失败静默:筛选是增强,隐私模式/配额满都不该影响当前会话)。
81
+ * @param {Iterable<string>} keys
82
+ * @returns {boolean} 是否写入成功(调用方一般不需要它,测试用)
83
+ */
84
+ export function saveTypeFilter(keys) {
85
+ try {
86
+ const list = [...(keys || [])].map(String).filter(Boolean);
87
+ localStorage.setItem(TYPE_FILTER_KEY, JSON.stringify(list));
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
@@ -0,0 +1,77 @@
1
+ // roster/reveal.js —— 「定位当前角色」:把选中的那一行滚进可视区
2
+ //
3
+ // 三条纪律(F3):
4
+ // ① 行已可见就不动 —— 绝大多数选中都不需要滚,乱跳反而干扰阅读
5
+ // ② 用户正在滚 / 刚滚过 SCROLL_IDLE_MS 内不滚 —— 绝不跟用户抢滚动位置
6
+ // ③ 目标被折叠时不硬滚,先按"展开状态"把父级展开(渲染出这一行)再滚
7
+ //
8
+ // 为什么单独成模块:这段是纯"滚动策略",与树的渲染/数据加载无关,拆开后 view.js 也能守住行数上限。
9
+
10
+ /** 用户停手多久之后才允许自动滚动(毫秒) */
11
+ export const SCROLL_IDLE_MS = 800;
12
+
13
+ /**
14
+ * 创建定位控制器。
15
+ * @param {{listEl: HTMLElement, getState: () => object, render: () => void}} deps
16
+ * getState 返回名册状态(expandedGames / expandedChars / selected)
17
+ * render 重新渲染整棵树(展开父级后需要它把目标行画出来)
18
+ */
19
+ export function createReveal({ listEl, getState, render }) {
20
+ // 自己触发的 scrollIntoView 也会派发 scroll 事件:用它区分"用户的滚"与"我们的滚"
21
+ let autoScrollUntil = 0;
22
+ let lastUserScrollAt = 0;
23
+
24
+ /** 记录一次"用户滚动"(程序触发的滚动不计) */
25
+ function markUserScroll() {
26
+ if (Date.now() < autoScrollUntil) return;
27
+ lastUserScrollAt = Date.now();
28
+ }
29
+
30
+ /** 挂上用户滚动信号:滚轮 / 触摸 / 指针按下 / 滚动条拖动(scroll 事件同时覆盖键盘与拖滚动条) */
31
+ function attachUserScrollListeners() {
32
+ listEl.addEventListener('scroll', markUserScroll, { passive: true });
33
+ listEl.addEventListener('wheel', markUserScroll, { passive: true });
34
+ listEl.addEventListener('touchstart', markUserScroll, { passive: true });
35
+ listEl.addEventListener('pointerdown', markUserScroll, { passive: true });
36
+ }
37
+
38
+ /**
39
+ * 把当前选中的变体行滚进可视区。
40
+ * @returns {'visible'|'scrolled'|'skipped-user-scrolling'|'no-selection'|'no-row'}
41
+ * visible:本来就在可视区,没动;skipped-user-scrolling:用户正在滚,主动让位
42
+ */
43
+ function revealActive() {
44
+ const state = getState();
45
+ const selected = state && state.selected;
46
+ if (!selected || !selected.modelId) return 'no-selection';
47
+ // ③ 折叠时先展开父级:没渲染出这一行,后面也就无从滚起
48
+ let expanded = false;
49
+ if (selected.gameKey && !state.expandedGames.has(selected.gameKey)) {
50
+ state.expandedGames.add(selected.gameKey);
51
+ expanded = true;
52
+ }
53
+ if (selected.charKey && !state.expandedChars.has(selected.charKey)) {
54
+ state.expandedChars.add(selected.charKey);
55
+ expanded = true;
56
+ }
57
+ if (expanded) render();
58
+ // 按 dataset 值逐个比对:模型 id 里可能有引号等字符,拼选择器不可靠
59
+ const row = [...listEl.querySelectorAll('[data-model-id]')]
60
+ .find((node) => node.dataset.modelId === selected.modelId);
61
+ if (!row) return 'no-row';
62
+ const box = listEl.getBoundingClientRect();
63
+ const r = row.getBoundingClientRect();
64
+ if (r.top >= box.top - 1 && r.bottom <= box.bottom + 1) return 'visible'; // ① 已可见
65
+ if (Date.now() - lastUserScrollAt < SCROLL_IDLE_MS) return 'skipped-user-scrolling'; // ② 不抢
66
+ autoScrollUntil = Date.now() + 300;
67
+ row.scrollIntoView({ block: 'nearest' });
68
+ return 'scrolled';
69
+ }
70
+
71
+ /** 只读:最近一次用户滚动距今毫秒(断言用) */
72
+ function sinceUserScroll() {
73
+ return Date.now() - lastUserScrollAt;
74
+ }
75
+
76
+ return { revealActive, markUserScroll, attachUserScrollListeners, sinceUserScroll };
77
+ }