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,272 @@
1
+ // live2d.mjs —— Live2D 模型扫描、清单缓存与运行时库探测
2
+
3
+ //
4
+ // 逻辑逐字搬自 server.mjs(行为等价由 sha256 逐字节比对验证)。
5
+ // 与旧实现的差别只在**结构**:把原先套在工厂里的步骤按职责拆开
6
+ // (引用构造 / 定义归一 / 表情 / 动作 / 组装),并把清单路径改成显式入参。
7
+ //
8
+ // 与 Spine 的关键差异:Live2D 定义文件内部用**相对路径**引用同目录文件,
9
+ // 因此对外必须给**路径式** URL(/files/<rootId>/<相对路径>)——查询串式会在相对解析时丢掉。
10
+ import fs from 'node:fs';
11
+ import { createLogger } from '../core/log.mjs';
12
+ import { dirSignature, signatureMatches, generatorFingerprint } from './manifest-stamp.mjs';
13
+ import { live2dManifestFile, ensureManifestDir } from './manifest-cache.mjs';
14
+ import path from 'node:path';
15
+ import { encodePath } from '../assets/probe.mjs';
16
+ import { resolveLive2DRuntimes } from '../../../tool/resolve-live2d-runtimes.mjs';
17
+
18
+ /** 本模块日志(统一形状 {at,level,tag,msg,detail}) */
19
+ const log = createLogger('live2d');
20
+ /** tool/ 层探测消息单独一个 tag:一眼看出是"运行时库探测"而不是"模型清单" */
21
+ const rtLog = createLogger('live2d-runtimes');
22
+
23
+ /** Live2D 清单缓存的格式版本(结构变更时 +1,旧缓存自动失效重扫) */
24
+ const LIVE2D_MANIFEST_VERSION = 1;
25
+ /** 运行时库文件清单缓存(探测一次即复用) */
26
+ let live2dRuntimeFilesCache = null;
27
+
28
+ /** Live2D 清单文件路径(与 Spine 的 models.<id>.json 并列,互不干扰)。 */
29
+ export function live2dModelsFile(configFile, rootId) {
30
+ return live2dManifestFile(configFile, rootId);
31
+ }
32
+
33
+ /** 构造两个引用工具:对外 URL 与「磁盘上存在才返回」的文件对象。 */
34
+ export function makeLive2DRefs(root, relPath) {
35
+ const fileUrl = (name) => `/files/${encodeURIComponent(root.id)}/${encodePath(relPath ? `${relPath}/${name}` : name)}`;
36
+ // 引用可能带子目录(Cubism 4 实测 Textures 为 textures/texture_00.png),
37
+ // 故直接按磁盘相对模型目录判断存在性,而不是只在本目录直接子文件里找
38
+ const fileObj = (name) => {
39
+ if (!name) return null;
40
+ const abs = path.join(root.path, relPath, name);
41
+ let st;
42
+ try {
43
+ st = fs.statSync(abs);
44
+ } catch {
45
+ return null;
46
+ }
47
+ if (!st.isFile()) return null;
48
+ return {
49
+ name: path.basename(name),
50
+ path: relPath ? `${relPath}/${name}` : name,
51
+ size: st.size,
52
+ url: fileUrl(name),
53
+ };
54
+ };
55
+ return { fileUrl, fileObj };
56
+ }
57
+
58
+ /**
59
+ * 归一引用字段:**Cubism 4 在 FileReferences 下,Cubism 2 在顶层**,
60
+ * 且两者字段名大小写不同(4 首字母大写、2 小写),这里统一成一套内部表示。
61
+ */
62
+ export function normalizeLive2DRefs(cubism, def) {
63
+ const fr = (cubism === 4 && def.FileReferences) ? def.FileReferences : def;
64
+ return {
65
+ rawMoc: cubism === 4 ? fr.Moc : def.model,
66
+ rawTextures: (cubism === 4 ? fr.Textures : def.textures) || [],
67
+ rawExpressions: (cubism === 4 ? fr.Expressions : def.expressions) || [],
68
+ rawMotions: (cubism === 4 ? fr.Motions : def.motions) || {},
69
+ };
70
+ }
71
+
72
+ /** 表情:Cubism 2 用小写 file/name,Cubism 4 用大写 File/Name,两者都要兼容。 */
73
+ export function buildExpressions(rawExpressions, fileObj, fileUrl) {
74
+ return (Array.isArray(rawExpressions) ? rawExpressions : [])
75
+ .map((e) => (e ? { name: e.Name || e.name, file: e.File || e.file } : null))
76
+ .filter((e) => e && e.file && fileObj(e.file))
77
+ .map((e) => ({
78
+ name: e.name || path.basename(String(e.file)).replace(/\.[^.]+$/, ''),
79
+ file: e.file,
80
+ url: fileUrl(e.file),
81
+ }));
82
+ }
83
+
84
+ /** 动作:按分组聚合,只保留磁盘上确实存在的文件。 */
85
+ export function buildMotions(rawMotions, fileObj, fileUrl) {
86
+ const motions = {};
87
+ for (const [group, list] of Object.entries(rawMotions)) {
88
+ const arr = (Array.isArray(list) ? list : [])
89
+ .map((mo) => (mo ? {
90
+ file: mo.File || mo.file,
91
+ fadeIn: mo.FadeIn !== undefined ? mo.FadeIn : mo.fade_in,
92
+ fadeOut: mo.FadeOut !== undefined ? mo.FadeOut : mo.fade_out,
93
+ } : null))
94
+ .filter((mo) => mo && mo.file && fileObj(mo.file))
95
+ .map((mo) => ({
96
+ name: path.basename(String(mo.file)).replace(/\.[^.]+$/, ''),
97
+ file: mo.file,
98
+ url: fileUrl(mo.file),
99
+ fadeIn: mo.fadeIn,
100
+ fadeOut: mo.fadeOut,
101
+ }));
102
+ if (arr.length) motions[group] = arr;
103
+ }
104
+ return motions;
105
+ }
106
+
107
+ /** 构造 Live2D 模型对象(一个定义文件 = 一个模型)。 */
108
+ export function buildLive2DModel(root, relPath, defName, cubism) {
109
+ const absDef = path.join(root.path, relPath, defName);
110
+ let def;
111
+ try {
112
+ def = JSON.parse(fs.readFileSync(absDef, 'utf8'));
113
+ } catch (e) {
114
+ log.warn('定义文件解析失败', { file: `${relPath}/${defName}`, err: e.message });
115
+ return null;
116
+ }
117
+
118
+ const { rawMoc, rawTextures, rawExpressions, rawMotions } = normalizeLive2DRefs(cubism, def);
119
+ const { fileUrl, fileObj } = makeLive2DRefs(root, relPath);
120
+ const mocName = rawMoc ? String(rawMoc) : '';
121
+ const textures = (Array.isArray(rawTextures) ? rawTextures : []).filter((t) => typeof t === 'string' && fileObj(t));
122
+ const expressions = buildExpressions(rawExpressions, fileObj, fileUrl);
123
+ const motions = buildMotions(rawMotions, fileObj, fileUrl);
124
+ const name = (relPath || '').split('/').pop() || relPath || root.id;
125
+
126
+ return {
127
+ id: `live2d-${root.id}-${(relPath || '.').replace(/\//g, '_')}`,
128
+ kind: 'live2d',
129
+ cubism,
130
+ name,
131
+ srcDir: relPath,
132
+ rootId: root.id,
133
+ rootName: root.name,
134
+ // 前端加载入口:**路径式** URL,定义文件里的相对引用才能正确解析
135
+ modelUrl: fileUrl(defName),
136
+ defFile: fileObj(defName),
137
+ mocFile: fileObj(mocName),
138
+ textureFiles: textures.map(fileObj).filter(Boolean),
139
+ expressions,
140
+ motions,
141
+ // 列表缩略图:模型目录里的 icon.png(有则用)
142
+ iconUrl: fileObj('icon.png') ? fileUrl('icon.png') : null,
143
+ fileSize: [defName, mocName, ...textures, ...expressions.map((e) => e.file),
144
+ ...Object.values(motions).flat().map((mo) => mo.file)]
145
+ .map(fileObj).filter(Boolean).reduce((sum, f) => sum + f.size, 0),
146
+ };
147
+ }
148
+
149
+ /** 扫一个目录:识别 Cubism 4(*.model3.json)优先于 Cubism 2(model.json)。 */
150
+ function modelsInDir(root, absDir, relPath, entries) {
151
+ const dirs = [];
152
+ const files = new Map(); // 文件名 -> { name, size }
153
+ for (const e of entries) {
154
+ if (e.name.startsWith('.')) continue;
155
+ const full = path.join(absDir, e.name);
156
+ let st;
157
+ try {
158
+ st = fs.statSync(full);
159
+ } catch {
160
+ continue;
161
+ }
162
+ if (e.isDirectory()) {
163
+ dirs.push({ name: e.name, full });
164
+ continue;
165
+ }
166
+ files.set(e.name, { name: e.name, size: st.size });
167
+ }
168
+ const out = [];
169
+ const def4 = [...files.keys()].find((n) => /\.model3\.json$/i.test(n)) || null;
170
+ const defName = def4 || (files.has('model.json') ? 'model.json' : null);
171
+ if (defName) {
172
+ const m = buildLive2DModel(root, relPath, defName, def4 ? 4 : 2);
173
+ if (m) out.push(m);
174
+ }
175
+ return { models: out, dirs };
176
+ }
177
+
178
+ /** 扫描一个资源根里的全部 Live2D 模型。 */
179
+ export function scanLive2DRootServer(root) {
180
+ const models = [];
181
+
182
+ function walk(absDir, relPath) {
183
+ let entries;
184
+ try {
185
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
186
+ } catch {
187
+ return;
188
+ }
189
+ const found = modelsInDir(root, absDir, relPath, entries);
190
+ models.push(...found.models);
191
+ for (const d of found.dirs) walk(d.full, relPath ? `${relPath}/${d.name}` : d.name);
192
+ }
193
+
194
+ walk(root.path, '');
195
+ return models;
196
+ }
197
+
198
+ /**
199
+ * 取 Live2D 模型列表(带落盘缓存,逻辑与 Spine 的 getModels 一致)。
200
+ * 为什么缓存:天命之子有 2000+ 角色目录,每次刷新全量扫描会明显变慢。
201
+ */
202
+ export function getLive2DModels(configFile, cfg, force) {
203
+ const file = live2dModelsFile(configFile, cfg.id);
204
+ if (!force && fs.existsSync(file)) {
205
+ try {
206
+ const payload = JSON.parse(fs.readFileSync(file, 'utf8'));
207
+ if (payload.version === LIVE2D_MANIFEST_VERSION && Array.isArray(payload.models)) {
208
+ // 与 spine 清单同口径:两道判据 —— 生成者指纹(解析代码变)+ 目录签名(资源增删)
209
+ const gen = generatorFingerprint();
210
+ if (payload.generator !== gen) {
211
+ log.info('解析器已变化,清单失效重建', { id: cfg.id, from: payload.generator || null, to: gen });
212
+ } else if (signatureMatches(payload.dirStamps, cfg.path)) {
213
+ return { models: payload.models, cached: true, ts: payload.ts };
214
+ } else {
215
+ log.info('目录已变化,清单失效重建', { id: cfg.id });
216
+ }
217
+ }
218
+ } catch {
219
+ /* 缓存损坏则重扫 */
220
+ }
221
+ }
222
+ const models = scanLive2DRootServer(cfg);
223
+ const ts = Date.now();
224
+ try {
225
+ // 目录建一次就够(ensureManifestDir 内部就是 mkdir -p)。原先它前面还有一句
226
+ // fs.mkdirSync(path.dirname(file)) —— 建的是同一个目录,重复调用,已删。
227
+ ensureManifestDir(configFile);
228
+ const body = JSON.stringify({
229
+ version: LIVE2D_MANIFEST_VERSION,
230
+ rootId: cfg.id,
231
+ ts,
232
+ dirStamps: dirSignature(cfg.path), // 同上:目录签名供下次读清单时比对
233
+ generator: generatorFingerprint(), // 解析器指纹:解析代码一改,清单自动失效
234
+ models,
235
+ });
236
+ // 与 spine 清单同口径:先写临时文件再 rename,避免并发读拿到半份 JSON
237
+ fs.writeFileSync(`${file}.tmp`, body, 'utf8');
238
+ fs.renameSync(`${file}.tmp`, file);
239
+ } catch (e) {
240
+ log.warn('清单写入失败(不影响使用)', { err: e.message });
241
+ }
242
+ return { models, cached: false, ts };
243
+ }
244
+
245
+ /** 探测 Live2D 运行时库(Cubism Core 受专有许可约束不入库,从本地已有副本取)。 */
246
+ export function getLive2DRuntimeFiles() {
247
+ if (!live2dRuntimeFilesCache) {
248
+ // 注入本模块的 logger:tool 层只认「有 info/warn 的对象」,不 import server 层
249
+ const r = resolveLive2DRuntimes({ log: rtLog });
250
+ log.info('运行时来源', { origin: r.origin });
251
+ log.info('运行时目录', { dir: r.dir });
252
+ if (r.missingOptional.length) {
253
+ log.warn('缺少可选文件(仅影响 Cubism 4 的 .moc3 模型)', { missing: r.missingOptional });
254
+ }
255
+ live2dRuntimeFilesCache = r.files;
256
+ }
257
+ return live2dRuntimeFilesCache;
258
+ }
259
+
260
+ /**
261
+ * 绑定型适配器:把清单路径固定,调用方只需 (cfg, force)。
262
+ * @param {{configFile: string}} p
263
+ */
264
+ export function createLive2d(p) {
265
+ return {
266
+ live2dModelsFile: (rootId) => live2dModelsFile(p.configFile, rootId),
267
+ buildLive2DModel,
268
+ scanLive2DRootServer,
269
+ getLive2DModels: (cfg, force) => getLive2DModels(p.configFile, cfg, force),
270
+ getLive2DRuntimeFiles,
271
+ };
272
+ }
@@ -0,0 +1,58 @@
1
+ // data/manifest-cache.mjs —— 模型索引清单的路径、目录与失效(Spine 与 Live2D 共用)
2
+ //
3
+ // 为什么单独一层:两套清单(spine.<id>.json / live2d.<id>.json)落在同一个目录 config/models/,
4
+ // 而「路径怎么拼」「目录不存在先建」「失效某个根 / 失效全部」这三件事与具体扫描器无关。
5
+ // 早期实现把它们塞在 Spine 模块里,导致一个「只管 Spine」的模块同时定义 Live2D 的清单路径、
6
+ // 还负责删两套清单 —— 职责与名字对不上,也没法把文件改成 spine.mjs。
7
+ //
8
+ // 失效只扫 config/models/ 目录、只认 `^(spine|live2d).<id>.json$`:
9
+ // 手工配置(如 config/live2d.json 的渲染开关)不在这个目录里,
10
+ // 所以不存在"按前缀匹配误删用户配置"的风险(旧实现按 config/ 前缀匹配时真出过一次)。
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ /** 清单目录:<config>/models/ —— 扫描产物与手工配置分开存放 */
16
+ export function manifestDir(configFile) {
17
+ return path.join(path.dirname(configFile), 'models');
18
+ }
19
+
20
+ /** Spine 清单路径:config/models/spine.<rootId>.json */
21
+ export function spineManifestFile(configFile, rootId) {
22
+ return path.join(manifestDir(configFile), `spine.${rootId}.json`);
23
+ }
24
+
25
+ /** Live2D 清单路径:config/models/live2d.<rootId>.json */
26
+ export function live2dManifestFile(configFile, rootId) {
27
+ return path.join(manifestDir(configFile), `live2d.${rootId}.json`);
28
+ }
29
+
30
+ /** 写清单前确保目录存在(首次运行或目录被清空时,不能因为缺目录写失败) */
31
+ export function ensureManifestDir(configFile) {
32
+ fs.mkdirSync(manifestDir(configFile), { recursive: true });
33
+ }
34
+
35
+ /**
36
+ * 失效清单。传 rootId 只删这个根的两套;不传删目录下全部。
37
+ * 文件不存在、目录不存在都不算错(本来就没有清单可删)。
38
+ */
39
+ export function invalidateManifests(configFile, rootId) {
40
+ if (rootId) {
41
+ for (const f of [spineManifestFile(configFile, rootId), live2dManifestFile(configFile, rootId)]) {
42
+ try {
43
+ if (fs.existsSync(f)) fs.unlinkSync(f);
44
+ } catch {
45
+ /* 删除失败不影响调用方:清单不可用会被后续扫描覆盖 */
46
+ }
47
+ }
48
+ return;
49
+ }
50
+ try {
51
+ const dir = manifestDir(configFile);
52
+ for (const n of fs.readdirSync(dir)) {
53
+ if (/^(?:spine|live2d)\..+\.json$/.test(n)) fs.unlinkSync(path.join(dir, n));
54
+ }
55
+ } catch {
56
+ /* 目录不存在 = 没有清单要删 */
57
+ }
58
+ }
@@ -0,0 +1,135 @@
1
+ // data/manifest-stamp.mjs —— 落盘清单「还跟不跟得上」的两套判据都在这里:
2
+ // ① dirSignature / signatureMatches —— 目录签名:资源目录增删(只 stat 目录,不扫文件)
3
+ // ② generatorFingerprint —— 生成者指纹:解析代码变化(文件内容哈希)
4
+ // 背景:清单一旦落盘就无条件复用,加/删模型目录后不带 ?refresh=1 会一直读旧清单;
5
+ // 而"只改解析代码"连目录 mtime 都不动 ⇒ 必须另有一条"代码变了"的判据,否则改完代码界面还是旧数据。
6
+ // 边界:目录 mtime 只反映**直接子项**增删/改名;记录根 + 一级子目录,
7
+ // 可覆盖"模型目录内增删"这一类最常见的变动。**模型文件内容**变了仍不触发重建(那要 ?refresh=1),
8
+ // 而**解析代码**变了由②兜住 —— 两条判据各管一段,别互相替代。
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ /** mtime 取整毫秒(不同文件系统精度不同,浮点尾差会造成假失效) */
16
+ function msOf(p) {
17
+ try {
18
+ return Math.round(fs.statSync(p).mtimeMs);
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * 计算一个资源根的目录签名。
26
+ * @param {string} rootPath 资源根本机路径
27
+ * @returns {{root: number|null, dirs: Record<string, number>}} 根目录 mtime + 一级子目录 mtime 表
28
+ */
29
+ export function dirSignature(rootPath) {
30
+ const sig = { root: msOf(rootPath), dirs: {} };
31
+ if (sig.root === null) return sig;
32
+ try {
33
+ for (const e of fs.readdirSync(rootPath, { withFileTypes: true })) {
34
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
35
+ const m = msOf(path.join(rootPath, e.name));
36
+ if (m !== null) sig.dirs[e.name] = m;
37
+ }
38
+ } catch {
39
+ /* 目录读不了:签名只保留根,比对时自然判为不一致 */
40
+ }
41
+ return sig;
42
+ }
43
+
44
+ /**
45
+ * 清单里的签名是否仍与磁盘一致。
46
+ * @param {{root: number|null, dirs: Record<string, number>}|undefined} sig 清单里存的 dirStamps
47
+ * @param {string} rootPath 资源根本机路径
48
+ * @returns {boolean} true = 可用缓存;false = 需重建
49
+ */
50
+ export function signatureMatches(sig, rootPath) {
51
+ if (!sig || typeof sig !== 'object' || !sig.dirs || typeof sig.dirs !== 'object') return false;
52
+ const now = dirSignature(rootPath);
53
+ // 「根目录不存在」也是**稳定状态**,不是"变了":
54
+ // 配置里可能存在一个路径写错/尚未创建的根(实例:live2d 根指向 …/DestinyChildLive2D/assets/live2d,
55
+ // 该目录不存在)。若把 null 一律判为不一致,就会**每次请求都重建清单**(日志刷
56
+ // 「目录已变化,清单失效重建」),白白消耗 CPU 与磁盘写。
57
+ // 正确语义:上次也不存在(sig.root === null)⇒ 没变化 ⇒ 命中缓存。
58
+ if (now.root === null) return sig.root === null;
59
+ if (now.root !== sig.root) return false;
60
+ const before = Object.keys(sig.dirs);
61
+ const after = Object.keys(now.dirs);
62
+ if (before.length !== after.length) return false; // 出现新目录 / 目录被删
63
+ for (const name of after) {
64
+ if (sig.dirs[name] !== now.dirs[name]) return false; // 目录内增删(mtime 变了)
65
+ }
66
+ return true;
67
+ }
68
+
69
+ // ============================================================================
70
+ // 生成者指纹(generator)
71
+ //
72
+ // 为什么需要它:dirStamps 只感知**目录增删**,改解析代码它一点都察觉不到 ⇒
73
+ // 修好「atlas 首行 BOM 丢第一页」后,界面仍读旧清单(textureCount 还是 1),
74
+ // 必须手工删 config/models/spine.<root>.json 才生效 —— 白验证一轮。
75
+ // 做法:清单里存一份「解析器文件内容哈希」,读清单时比对;不等就重建。
76
+ // 用内容哈希而不是人工版本号:人工号一定会忘(本项目已多次踩"改了判据忘升版本")。
77
+ // 写法照 src/server/plugins/registry.mjs 的 builtinVersionHash:缓存键用 (mtime, size),
78
+ // 文件没变不重算哈希。
79
+ // ============================================================================
80
+
81
+ /** 项目根(本文件在 src/server/data/ ⇒ 往上三层) */
82
+ const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
83
+
84
+ /**
85
+ * 参与「生成者指纹」的文件。
86
+ * 选它们的理由:这三处共同决定清单里的元数据长什么样 ——
87
+ * · spine.mjs —— spine 扫描与清单字段
88
+ * · live2d.mjs —— live2d 扫描与清单字段
89
+ * · probe.mjs —— 两者共用的资产探测(atlas 页名、纹理尺寸、版本解析都在这里,BOM 那个 bug 就在此)
90
+ * 缺文件就跳过(不炸):某个模块被挪走,也不该让清单读不出来。
91
+ */
92
+ const GENERATOR_FILES = [
93
+ 'src/server/data/spine.mjs',
94
+ 'src/server/data/live2d.mjs',
95
+ 'src/server/assets/probe.mjs',
96
+ ];
97
+
98
+ /** 生成者指纹的进程内缓存(签名 = 参与文件的 mtime/size 串) */
99
+ let generatorCache = { sig: null, value: '0' };
100
+
101
+ /**
102
+ * 生成者指纹:解析器文件内容哈希的前 8 位。
103
+ * @param {string} [root] 项目根(缺省按本文件位置推,不写死绝对路径)
104
+ * @returns {string} 8 位十六进制(一个文件都读不到时为 '0')
105
+ */
106
+ export function generatorFingerprint(root = PROJECT_ROOT) {
107
+ // 缓存键用 (mtime, size):文件内容一变就重算;没变则直接返回(probe.mjs 有几百行,别每次读盘+sha256)
108
+ let sig = '';
109
+ const stats = [];
110
+ for (const rel of GENERATOR_FILES) {
111
+ try {
112
+ const st = fs.statSync(path.join(root, rel));
113
+ stats.push({ rel, ok: true });
114
+ sig += `${rel}:${st.mtimeMs}:${st.size};`;
115
+ } catch {
116
+ stats.push({ rel, ok: false });
117
+ sig += `${rel}:-;`;
118
+ }
119
+ }
120
+ if (generatorCache.sig === sig) return generatorCache.value;
121
+
122
+ const h = crypto.createHash('sha256');
123
+ let used = 0;
124
+ for (const { rel, ok } of stats) {
125
+ if (!ok) continue;
126
+ try {
127
+ h.update(rel);
128
+ h.update(fs.readFileSync(path.join(root, rel)));
129
+ used += 1;
130
+ } catch { /* 单个文件读不了不影响其它文件 */ }
131
+ }
132
+ const value = used ? h.digest('hex').slice(0, 8) : '0';
133
+ generatorCache = { sig, value };
134
+ return value;
135
+ }
@@ -0,0 +1,114 @@
1
+ // model-meta.mjs —— Spine 模型元数据(/api/model-meta 的数据层)
2
+
3
+ const log = createLogger('model-meta');
4
+ //
5
+ // 产出字段与 Cubism 的 modelMeta 同名:kind / designSize / bbox / bones / slots / animations / skins / version
6
+ // 解析器按需 import(首次请求才加载几 MB 源码,不在启动时加载);
7
+ // 动作分组规则来自 tool/spine-motion-rules.json,读一次缓存(改规则后重启生效)。
8
+ import fs from 'node:fs';
9
+ import { createLogger } from '../core/log.mjs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ /** 仓库根(本文件位于 <root>/src/server/) */
14
+ const MODULE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
15
+
16
+ /** 动作规则缓存:null = 尚未加载(改规则后重启生效) */
17
+ let motionRules = null;
18
+
19
+ /** 懒加载动作分组/循环规则;失败只告警(按"未知名"处理) */
20
+ async function loadMotionRules(rulesPath) {
21
+ if (motionRules !== null) return motionRules;
22
+ try {
23
+ const { loadRules } = await import('../../tool/lib/spine-motions.mjs');
24
+ motionRules = loadRules(rulesPath);
25
+ } catch (e) {
26
+ log.warn('动作规则加载失败,按"未知名"处理', { err: e.message });
27
+ motionRules = null;
28
+ }
29
+ return motionRules;
30
+ }
31
+
32
+ /**
33
+ * 定位骨架文件:path 允许给模型目录,此时按稳定顺序取第一个 .skel/.json。
34
+ * @returns {{ok: true, resolvedFile: string, absFile: string} | {ok: false, status: number, body: object}}
35
+ */
36
+ export function resolveSkeletonTarget(abs, rel) {
37
+ let resolvedFile = null;
38
+ const isDir = fs.statSync(abs).isDirectory();
39
+ const baseDir = isDir ? abs : path.dirname(abs);
40
+ if (isDir) {
41
+ const cands = fs.readdirSync(abs)
42
+ .filter((n) => /\.(skel|json)$/i.test(n) && !n.startsWith('.'))
43
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
44
+ if (!cands.length) {
45
+ return { ok: false, status: 404, body: { ok: false, error: '目录里没有 .skel/.json 骨架', path: rel, code: 'SPINE_ERR_NO_FILE' } };
46
+ }
47
+ resolvedFile = cands[0];
48
+ } else {
49
+ resolvedFile = path.basename(abs);
50
+ }
51
+ const absFile = path.join(baseDir, resolvedFile);
52
+ if (!fs.existsSync(absFile) || !fs.statSync(absFile).isFile()) {
53
+ return { ok: false, status: 404, body: { ok: false, error: '骨架文件不存在', path: rel, code: 'SPINE_ERR_NO_FILE' } };
54
+ }
55
+ return { ok: true, resolvedFile, absFile };
56
+ }
57
+
58
+ /** 读同名 .atlas 文本(读不到只影响 bbox,不影响计数) */
59
+ export function readAtlasText(absFile) {
60
+ try {
61
+ const atlasPath = path.join(path.dirname(absFile), `${path.basename(absFile, path.extname(absFile))}.atlas`);
62
+ if (fs.existsSync(atlasPath) && fs.statSync(atlasPath).isFile()) return fs.readFileSync(atlasPath, 'utf8');
63
+ } catch {
64
+ /* 忽略 */
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * @param {{config: object, probe: object, motionRulesFile?: string}} deps
71
+ */
72
+ export function createModelMeta({ config, probe, motionRulesFile }) {
73
+ const { readConfig } = config;
74
+ const { resolveSafe } = probe;
75
+ const rulesPath = motionRulesFile || path.join(MODULE_ROOT, 'tool', 'spine-motion-rules.json');
76
+
77
+ /**
78
+ * 读模型元数据。
79
+ * @returns {Promise<{status: number, body: object}>}
80
+ */
81
+ async function readModelMeta(rootId, rel) {
82
+ const cfg = readConfig().find((r) => r.id === rootId);
83
+ if (!cfg) {
84
+ return { status: 400, body: { ok: false, error: `未找到资源根: ${rootId}`, code: 'SPINE_ERR_BAD_ARGS' } };
85
+ }
86
+ const abs = resolveSafe(cfg.path, rel);
87
+ if (!abs) return { status: 403, body: { ok: false, error: '路径越界', code: 'SPINE_ERR_BAD_ARGS' } };
88
+ if (!fs.existsSync(abs)) {
89
+ return { status: 404, body: { ok: false, error: '路径不存在', path: rel, code: 'SPINE_ERR_NO_FILE' } };
90
+ }
91
+
92
+ const target = resolveSkeletonTarget(abs, rel);
93
+ if (!target.ok) return { status: target.status, body: target.body };
94
+ const atlasText = readAtlasText(target.absFile);
95
+
96
+ let readSpineMeta;
97
+ try {
98
+ ({ readSpineMeta } = await import('../../tool/lib/spine-meta.mjs'));
99
+ } catch (e) {
100
+ return { status: 500, body: { ok: false, error: `加载元数据模块失败: ${e.message}`, code: 'SPINE_ERR_NO_RUNTIME' } };
101
+ }
102
+
103
+ try {
104
+ const bytes = new Uint8Array(fs.readFileSync(target.absFile));
105
+ const rules = await loadMotionRules(rulesPath);
106
+ const meta = readSpineMeta({ bytes, fileName: target.resolvedFile, atlasText, rules });
107
+ return { status: 200, body: Object.assign({ ok: true, resolvedFile: target.resolvedFile }, meta) };
108
+ } catch (e) {
109
+ return { status: 200, body: { ok: false, resolvedFile: target.resolvedFile, error: e.message, code: e.code || 'SPINE_ERR_PARSE' } };
110
+ }
111
+ }
112
+
113
+ return { readModelMeta };
114
+ }