modelstage 0.0.0-stage → 0.5.24

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 (286) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +101 -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 +58 -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 +647 -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 +334 -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/web/README.md +193 -0
  126. package/src/web/app/anim-list.js +222 -0
  127. package/src/web/app/background-solids.js +150 -0
  128. package/src/web/app/backgrounds.js +152 -0
  129. package/src/web/app/camera-log.js +28 -0
  130. package/src/web/app/context.js +72 -0
  131. package/src/web/app/dir-picker.js +242 -0
  132. package/src/web/app/entry-report.js +71 -0
  133. package/src/web/app/memory-bars.js +91 -0
  134. package/src/web/app/memory-budget.js +228 -0
  135. package/src/web/app/memory-guard.js +204 -0
  136. package/src/web/app/model-actions.js +682 -0
  137. package/src/web/app/renderer-setting.js +70 -0
  138. package/src/web/app/root-manager.js +254 -0
  139. package/src/web/app/status.js +138 -0
  140. package/src/web/app/topbar-actions.js +357 -0
  141. package/src/web/app/transport-actions.js +297 -0
  142. package/src/web/app/ui-state.js +559 -0
  143. package/src/web/app/url-params.js +171 -0
  144. package/src/web/app/view-settings.js +186 -0
  145. package/src/web/components.css +465 -0
  146. package/src/web/debug/diag-hud.js +95 -0
  147. package/src/web/debug/drag.js +369 -0
  148. package/src/web/debug/log.js +320 -0
  149. package/src/web/debug/model-cmp.js +292 -0
  150. package/src/web/debug/panel.css +474 -0
  151. package/src/web/debug/panel.js +712 -0
  152. package/src/web/debug/race-probe.js +82 -0
  153. package/src/web/debug/upload.js +130 -0
  154. package/src/web/engine/adapters/atlas.js +47 -0
  155. package/src/web/engine/adapters/index.js +22 -0
  156. package/src/web/engine/adapters/renderer.js +223 -0
  157. package/src/web/engine/adapters/skeleton.js +105 -0
  158. package/src/web/engine/adapters/texture.js +64 -0
  159. package/src/web/engine/animation.js +181 -0
  160. package/src/web/engine/appearance.js +136 -0
  161. package/src/web/engine/camera-adapter.js +212 -0
  162. package/src/web/engine/effect-chain.js +138 -0
  163. package/src/web/engine/engine-state.js +120 -0
  164. package/src/web/engine/filters.js +224 -0
  165. package/src/web/engine/fit.js +197 -0
  166. package/src/web/engine/frame-gate.js +33 -0
  167. package/src/web/engine/gl-driver.js +56 -0
  168. package/src/web/engine/gl.js +77 -0
  169. package/src/web/engine/index.js +20 -0
  170. package/src/web/engine/loader.js +55 -0
  171. package/src/web/engine/model-info.js +26 -0
  172. package/src/web/engine/pipeline.js +200 -0
  173. package/src/web/engine/player.js +427 -0
  174. package/src/web/engine/pma/blend.js +76 -0
  175. package/src/web/engine/pma/detect.js +118 -0
  176. package/src/web/engine/pma/index.js +8 -0
  177. package/src/web/engine/pma/resolve.js +112 -0
  178. package/src/web/engine/pma/upload.js +127 -0
  179. package/src/web/engine/pma.js +5 -0
  180. package/src/web/engine/render-frame.js +93 -0
  181. package/src/web/engine/runtime-capabilities.js +209 -0
  182. package/src/web/engine/runtime.js +37 -0
  183. package/src/web/engine/skins.js +165 -0
  184. package/src/web/engine/textures.js +174 -0
  185. package/src/web/index.html +115 -0
  186. package/src/web/infra/api.js +531 -0
  187. package/src/web/infra/diag.js +509 -0
  188. package/src/web/infra/document.js +269 -0
  189. package/src/web/infra/dom.js +31 -0
  190. package/src/web/infra/events.mjs +27 -0
  191. package/src/web/infra/log.js +153 -0
  192. package/src/web/infra/mem-stats.js +112 -0
  193. package/src/web/infra/plugin-host-registration.mjs +285 -0
  194. package/src/web/infra/plugin-host-report.mjs +32 -0
  195. package/src/web/infra/plugin-host-unload.mjs +79 -0
  196. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  197. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  198. package/src/web/infra/plugin-host.js +628 -0
  199. package/src/web/infra/ready.js +461 -0
  200. package/src/web/infra/schedule.js +51 -0
  201. package/src/web/infra/shortcuts.js +129 -0
  202. package/src/web/infra/slot-registry.js +402 -0
  203. package/src/web/infra/vfs.js +247 -0
  204. package/src/web/live2d/bridge.js +400 -0
  205. package/src/web/live2d/model.js +321 -0
  206. package/src/web/live2d/overlay.js +142 -0
  207. package/src/web/live2d/pixi-app.js +111 -0
  208. package/src/web/live2d/playback-probe.js +92 -0
  209. package/src/web/live2d/probe-geometry.js +329 -0
  210. package/src/web/live2d/probe.js +119 -0
  211. package/src/web/live2d/release.js +66 -0
  212. package/src/web/live2d/runtime-adapter.js +289 -0
  213. package/src/web/live2d/stage-ctx.js +152 -0
  214. package/src/web/live2d/stage.js +204 -0
  215. package/src/web/live2d/vfs-settings.js +174 -0
  216. package/src/web/live2d/view.js +89 -0
  217. package/src/web/main.js +629 -0
  218. package/src/web/panel/background-list.js +373 -0
  219. package/src/web/panel/bg-source.js +291 -0
  220. package/src/web/panel/color-picker.css +199 -0
  221. package/src/web/panel/color-picker.js +278 -0
  222. package/src/web/panel/dialog.css +116 -0
  223. package/src/web/panel/dialog.js +173 -0
  224. package/src/web/panel/dom.js +35 -0
  225. package/src/web/panel/icons.js +58 -0
  226. package/src/web/panel/memory-bar.js +195 -0
  227. package/src/web/panel/panel-settings.js +1263 -0
  228. package/src/web/panel/panel.css +1304 -0
  229. package/src/web/panel/render-choice.js +104 -0
  230. package/src/web/panel/schema.js +784 -0
  231. package/src/web/panel/settings-io.js +291 -0
  232. package/src/web/panel/shell-controls.js +274 -0
  233. package/src/web/panel/slider.js +210 -0
  234. package/src/web/panel/toolbar-schema.js +111 -0
  235. package/src/web/panel/toolbar.js +195 -0
  236. package/src/web/panel/transport-picker.js +375 -0
  237. package/src/web/panel/transport.js +321 -0
  238. package/src/web/panel/view.js +553 -0
  239. package/src/web/plugin/unpack-worker.js +185 -0
  240. package/src/web/render/camera/constants.js +60 -0
  241. package/src/web/render/camera/index.js +123 -0
  242. package/src/web/render/camera/spine.js +124 -0
  243. package/src/web/render/capture.js +30 -0
  244. package/src/web/render/interaction.js +527 -0
  245. package/src/web/render/live2d.js +627 -0
  246. package/src/web/render/playback.js +47 -0
  247. package/src/web/render/reset-view.js +29 -0
  248. package/src/web/render/spine.js +386 -0
  249. package/src/web/render/surface-switch.js +176 -0
  250. package/src/web/render/surface.js +571 -0
  251. package/src/web/render/surfaces.js +70 -0
  252. package/src/web/roster/filter.js +31 -0
  253. package/src/web/roster/list.js +276 -0
  254. package/src/web/roster/persist.js +52 -0
  255. package/src/web/roster/reveal.js +77 -0
  256. package/src/web/roster/rows.js +248 -0
  257. package/src/web/roster/snapshot.js +37 -0
  258. package/src/web/roster/view.js +670 -0
  259. package/src/web/spine/canvas-api-groups.js +267 -0
  260. package/src/web/spine/canvas-api.js +97 -0
  261. package/src/web/spine/canvas-document.js +80 -0
  262. package/src/web/spine/canvas-engine.js +193 -0
  263. package/src/web/spine/canvas-entry.js +28 -0
  264. package/src/web/spine/canvas-pixels.js +36 -0
  265. package/src/web/spine/canvas-runtime.js +127 -0
  266. package/src/web/spine/canvas-view.js +108 -0
  267. package/src/web/spine/canvas.js +7 -0
  268. package/src/web/spine/runtime-hint.js +73 -0
  269. package/src/web/spine/runtimes.js +212 -0
  270. package/src/web/style.css +493 -0
  271. package/tool/lib/bundle-probe-node.mjs +121 -0
  272. package/tool/lib/check-perms.mjs +114 -0
  273. package/tool/lib/contract-checks.mjs +582 -0
  274. package/tool/lib/cubism-core.mjs +122 -0
  275. package/tool/lib/export-bundle.mjs +328 -0
  276. package/tool/lib/paths.mjs +20 -0
  277. package/tool/lib/runtime-guides.mjs +65 -0
  278. package/tool/lib/runtime-package.mjs +403 -0
  279. package/tool/lib/spine-meta.mjs +227 -0
  280. package/tool/lib/spine-motions.mjs +104 -0
  281. package/tool/lib/spine-runtime.mjs +131 -0
  282. package/tool/lib/test-lock.mjs +178 -0
  283. package/tool/lib/test-report.mjs +133 -0
  284. package/tool/parse-jsonc.mjs +86 -0
  285. package/tool/resolve-live2d-runtimes.mjs +276 -0
  286. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,150 @@
1
+ // app/background-solids.js —— 「纯色预设」的前端数据层(只管跟服务端 5 个接口打交道)
2
+ //
3
+ // 为什么不存本地:预设要能列表管理、要跨设备带走(清前端偏好也不该丢),
4
+ // 所以权威数据在服务端;localStorage 只保存"当前选中的颜色值"(那属于界面偏好)。
5
+ //
6
+ // 与 panel/color-picker.js 的分工:
7
+ // 调色盘只回一个 '#rrggbb';命名、增删改、列表刷新**全在这里**,调色盘不认识"预设"这件事。
8
+
9
+ import { getJson, postJson, formatError } from '../infra/api.js';
10
+
11
+ /** 接口根(同源) */
12
+ const API = '/api/backgrounds/solids';
13
+
14
+ /**
15
+ * 最近一次成功取到的列表(缓存)。
16
+ * 用途:给"背景色默认值"提供一个**同步可读**的兜底(view-settings 里取默认色时不能 await)。
17
+ * 只读语义:这里不做任何本地持久化,进程内缓存而已。
18
+ */
19
+ let cache = [];
20
+
21
+ /** 内置兜底色:接口没就绪/取不到时,纯色模式仍然有一个合法颜色可用 */
22
+ export const FALLBACK_COLOR = '#2a2a2a';
23
+
24
+ /**
25
+ * 读取缓存中的预设列表(可能为空)。
26
+ * @returns {Array<{id:string,name:string,color:string,createdAt?:string}>}
27
+ */
28
+ export function cachedSolids() {
29
+ return cache.slice();
30
+ }
31
+
32
+ /**
33
+ * 默认纯色:预设列表第一条的颜色;没有预设时用内置兜底。
34
+ * @returns {string} '#rrggbb'
35
+ */
36
+ export function defaultSolidColor() {
37
+ const first = cache[0];
38
+ return first && first.color ? first.color : FALLBACK_COLOR;
39
+ }
40
+
41
+ /**
42
+ * 归一化一条记录(服务端字段名固定:id / name / color / createdAt)。
43
+ * @param {object} it
44
+ * @returns {{id:string,name:string,color:string,createdAt?:string}}
45
+ */
46
+ function normalize(it) {
47
+ const o = it && typeof it === 'object' ? it : {};
48
+ return {
49
+ id: String(o.id || ''),
50
+ name: String(o.name || ''),
51
+ color: String(o.color || '').toLowerCase(),
52
+ createdAt: o.createdAt,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * 把失败响应翻成人话(优先用服务端 error,它带具体原因)。
58
+ * @param {object|null} body
59
+ * @param {number} status
60
+ * @param {string} [fallback]
61
+ * @returns {string}
62
+ */
63
+ function failureText(body, status, fallback) {
64
+ if (body && typeof body.error === 'string' && body.error) return body.error;
65
+ return fallback || `请求失败(HTTP ${status})`;
66
+ }
67
+
68
+ /**
69
+ * 取纯色预设列表。
70
+ * @returns {Promise<{ok:boolean, items:object[], message?:string}>}
71
+ */
72
+ export async function listSolids() {
73
+ try {
74
+ const body = await getJson(API);
75
+ const items = (body && Array.isArray(body.items) ? body.items : []).map(normalize);
76
+ cache = items;
77
+ return { ok: true, items };
78
+ } catch (err) {
79
+ const status = (err && err.status) || 0;
80
+ return { ok: false, items: [], message: failureText(err && err.body, status, formatError(err)) };
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 新增一条预设。
86
+ * @param {string} name ≤30 字符(服务端去重/校验,前端不重复实现规则)
87
+ * @param {string} color '#rrggbb'
88
+ * @returns {Promise<{ok:boolean, item?:object, message?:string}>}
89
+ */
90
+ export async function addSolid(name, color) {
91
+ try {
92
+ const body = await postJson(API, { name, color });
93
+ const item = body && body.item ? normalize(body.item) : null;
94
+ if (item) cache = [...cache, item];
95
+ return { ok: true, item: item || undefined };
96
+ } catch (err) {
97
+ const status = (err && err.status) || 0;
98
+ return { ok: false, message: failureText(err && err.body, status, formatError(err)) };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 改名(id 与 color 不变)。
104
+ * @param {string} id
105
+ * @param {string} name
106
+ * @returns {Promise<{ok:boolean, item?:object, message?:string}>}
107
+ */
108
+ export async function renameSolid(id, name) {
109
+ try {
110
+ const body = await postJson(`${API}/rename`, { id, name });
111
+ const item = body && body.item ? normalize(body.item) : null;
112
+ if (item) cache = cache.map((x) => (x.id === item.id ? item : x));
113
+ return { ok: true, item: item || undefined };
114
+ } catch (err) {
115
+ const status = (err && err.status) || 0;
116
+ return { ok: false, message: failureText(err && err.body, status, formatError(err)) };
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 删除一条。
122
+ * @param {string} id
123
+ * @returns {Promise<{ok:boolean, removed?:number, id?:string, message?:string}>}
124
+ */
125
+ export async function removeSolid(id) {
126
+ try {
127
+ const body = await postJson(`${API}/remove`, { id });
128
+ cache = cache.filter((x) => x.id !== id);
129
+ return { ok: true, removed: body && body.removed, id: (body && body.id) || id };
130
+ } catch (err) {
131
+ const status = (err && err.status) || 0;
132
+ return { ok: false, message: failureText(err && err.body, status, formatError(err)) };
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 恢复默认 5 色(服务端权威:它知道默认是哪 5 条)。
138
+ * @returns {Promise<{ok:boolean, items:object[], message?:string}>}
139
+ */
140
+ export async function resetSolids() {
141
+ try {
142
+ const body = await postJson(`${API}/reset`, {});
143
+ const items = (body && Array.isArray(body.items) ? body.items : []).map(normalize);
144
+ cache = items;
145
+ return { ok: true, items };
146
+ } catch (err) {
147
+ const status = (err && err.status) || 0;
148
+ return { ok: false, items: [], message: failureText(err && err.body, status, formatError(err)) };
149
+ }
150
+ }
@@ -0,0 +1,152 @@
1
+ // app/backgrounds.js —— 自定义背景图的前端数据层(只跟服务端 5 个接口打交道)
2
+ //
3
+ // 为什么不存 localStorage:背景图要能列表管理、要刷新后仍在、要能换机带走,
4
+ // dataURL 写 localStorage 是"假上传"(换浏览器就丢、单张 4MB 就写爆存储)。
5
+ // 服务端落盘在 data/backgrounds/,这里只管取列表 / 传 / 改名 / 删。
6
+ //
7
+ // 失败原因码(后端 reason)到用户可见文案的映射也放在这里,面板与底栏共用同一套说法。
8
+
9
+ /** 后端接口根(同源,随页面部署) */
10
+ const API = '/api/backgrounds';
11
+
12
+ /** 允许的图片类型(与服务端白名单一致;前端只用于 file 选择框过滤) */
13
+ export const ACCEPT = 'image/png,image/jpeg,image/webp';
14
+
15
+ /** 单张上限(5MB):服务端权威判定,这里只用于提前给出人话提示 */
16
+ export const MAX_BYTES = 5 * 1024 * 1024;
17
+
18
+ /** reason → 用户可见文案(后端 error 更具体时优先用后端文案) */
19
+ const REASON_TEXT = {
20
+ 'too-large': `图片超过 ${MAX_BYTES / 1048576}MB 上限,请先压缩再上传`,
21
+ 'bad-type': '只接受 png / jpeg / webp 图片',
22
+ 'bad-name': '名称不能为空',
23
+ 'not-found': '这张图已经不在了,请刷新列表',
24
+ 'empty': '没有收到文件内容',
25
+ 'aborted': '上传被中断了,请重试',
26
+ 'disk-error': '服务端写盘失败,请稍后重试',
27
+ 'server-error': '服务端内部错误',
28
+ };
29
+
30
+ /**
31
+ * 把一次失败响应翻成人话(优先用服务端给的 error,它带具体数值)。
32
+ * @param {object|null} body 响应体(可能是 null)
33
+ * @param {number} status HTTP 状态码
34
+ * @returns {{reason: string, message: string}} 原因码与提示文案
35
+ */
36
+ export function describeFailure(body, status) {
37
+ const reason = (body && body.reason) || (status === 413 ? 'too-large' : 'server-error');
38
+ const message = (body && body.error) || REASON_TEXT[reason] || `请求失败(HTTP ${status})`;
39
+ return { reason, message };
40
+ }
41
+
42
+ /**
43
+ * 统一请求封装:解析 JSON、把失败翻成 {ok:false, reason, message}。
44
+ * @param {string} url 目标地址
45
+ * @param {object} [init] fetch 参数(传 signal 可覆盖兜底超时)
46
+ * @returns {Promise<object>} 成功时为响应体(含 ok:true),失败时 {ok:false, reason, message}
47
+ */
48
+ async function request(url, init) {
49
+ let res;
50
+ try {
51
+ // 兜底超时 8s:本文件四个调用点(列表/改名/删除/重置)都是读改写一份小 JSON,
52
+ // 同源同类接口实测 10~58ms(/api/plugins 10ms、/api/config/export 58ms),留 100 倍余量。
53
+ // 上传那处 body 最大 4MB(dataURL)⇒ 由调用方传更长的 signal 覆盖(见 uploadBackground)。
54
+ res = await fetch(url, { ...(init || {}), signal: (init && init.signal) || AbortSignal.timeout(8000) });
55
+ } catch (e) {
56
+ return { ok: false, reason: 'network', message: `网络请求失败:${(e && e.message) || e}` };
57
+ }
58
+ let body = null;
59
+ try { body = await res.json(); } catch { body = null; }
60
+ if (!res.ok || !body || body.ok !== true) return { ok: false, ...describeFailure(body, res.status) };
61
+ return body;
62
+ }
63
+
64
+ /**
65
+ * 取列表。
66
+ * @returns {Promise<{ok:boolean, items?:Array, message?:string}>} items 里每项有 {id,name,url,bytes}
67
+ */
68
+ export function listBackgrounds() {
69
+ return request(API, { cache: 'no-store' });
70
+ }
71
+
72
+ /**
73
+ * 上传一张图(原始二进制 body + x-filename 头)。
74
+ *
75
+ * 为什么不用 FormData:服务端按原始字节流边读边计上限(multipart 要先落临时文件再解析),
76
+ * 而且这里不需要多字段——文件名走 x-filename(百分号编码,避免中文乱码)。
77
+ * @param {File} file 用户选的文件
78
+ * @returns {Promise<object>} 成功 {ok:true, item},失败 {ok:false, reason, message}
79
+ */
80
+ export function uploadBackground(file) {
81
+ if (!file) return Promise.resolve({ ok: false, reason: 'empty', message: '没有选择文件' });
82
+ if (file.size > MAX_BYTES) {
83
+ // 前端先挡一层:省掉一次 5MB 的无效上传(服务端仍会再判一次)
84
+ return Promise.resolve({
85
+ ok: false, reason: 'too-large',
86
+ message: `图片 ${(file.size / 1048576).toFixed(2)}MB 超过 ${MAX_BYTES / 1048576}MB 上限,请先压缩`,
87
+ });
88
+ }
89
+ return request(API, {
90
+ method: 'POST',
91
+ headers: {
92
+ 'Content-Type': file.type || 'application/octet-stream',
93
+ 'x-filename': encodeURIComponent(file.name || ''),
94
+ },
95
+ body: file,
96
+ // 上传单独覆盖成 15s:body 最大 4MB(MAX_BYTES),服务端要收满 + 落盘(CIFS 上写几 MB 可能数秒),
97
+ // 兜底的 8s 对这一步太紧;其余三个调用点都是小 JSON,用兜底值即可。
98
+ signal: AbortSignal.timeout(15000),
99
+ });
100
+ }
101
+
102
+ /**
103
+ * 重命名(只改显示名,id 与 url 不变)。
104
+ * @param {string} id 目标 id
105
+ * @param {string} name 新名字
106
+ * @returns {Promise<object>} 成功 {ok:true, item, adjusted}
107
+ */
108
+ export function renameBackground(id, name) {
109
+ return request(`${API}/rename`, {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({ id, name }),
113
+ });
114
+ }
115
+
116
+ /**
117
+ * 删除(服务端把文件移进 .trash/,可恢复)。
118
+ * @param {string} id 目标 id
119
+ * @returns {Promise<object>} 成功 {ok:true, removed}
120
+ */
121
+ export function removeBackground(id) {
122
+ return request(`${API}/delete`, {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({ id }),
126
+ });
127
+ }
128
+
129
+ /** 把索引 id 变成可直接用的图片地址 */
130
+ export function urlOf(item) {
131
+ return item && item.url ? item.url : '';
132
+ }
133
+
134
+ /**
135
+ * 选择本地图片:弹系统文件选择框,返回用户选的 File(取消返回 null)。
136
+ * @returns {Promise<File|null>} 选中的文件
137
+ */
138
+ export function pickFile() {
139
+ return new Promise((resolve) => {
140
+ const input = document.createElement('input');
141
+ input.type = 'file';
142
+ input.accept = ACCEPT;
143
+ input.classList.add('file-picker-hidden');
144
+ document.body.append(input);
145
+ input.addEventListener('change', () => {
146
+ const file = (input.files && input.files[0]) || null;
147
+ input.remove();
148
+ resolve(file);
149
+ }, { once: true });
150
+ input.click();
151
+ });
152
+ }
@@ -0,0 +1,28 @@
1
+ // app/camera-log.js —— 相机操作日志的单一出口(控制台 / 断言缓冲 / 调试面板)
2
+ //
3
+ // 为什么独立成模块:main.js 有行数上限,且这段逻辑与"相机怎么动"无关,
4
+ // 只负责把相机侧发出的日志分发给三个消费者。
5
+
6
+ /**
7
+ * 把相机日志接到三路输出。
8
+ * @param {object|null} cameraEntry 当前渲染器的相机入口(含 setLogger)
9
+ * @returns {boolean} 是否成功接上(相机入口缺失时返回 false)
10
+ */
11
+ export function installCameraLogger(cameraEntry) {
12
+ if (!cameraEntry || typeof cameraEntry.setLogger !== 'function') return false;
13
+ cameraEntry.setLogger((line, data) => {
14
+ const bag = (window.__WEB__ = window.__WEB__ || {});
15
+ bag.cameraLog = bag.cameraLog || [];
16
+ bag.cameraLog.push({ at: Date.now(), line, data });
17
+ if (bag.cameraLog.length > 200) bag.cameraLog.shift(); // 只留最近 200 条供断言
18
+ console.info('%c[CAMERA]', 'color:#1fc0c5', line);
19
+ if (bag.debugLog) {
20
+ try {
21
+ const opts = { level: 'info', src: 'camera' };
22
+ if (data !== undefined && data !== null) opts.data = data; // 结构化字段独立,不拼进正文
23
+ bag.debugLog.log('CAMERA', line, opts);
24
+ } catch { /* 日志失败不影响业务 */ }
25
+ }
26
+ });
27
+ return true;
28
+ }
@@ -0,0 +1,72 @@
1
+ // app/context.js —— 页面级共享上下文(DOM 引用、状态容器、舞台实例)
2
+ //
3
+ // 为什么要单独成模块:main.js 只做装配,各功能模块(状态行、视图设置、部件筛除、外壳控件…)
4
+ // 都要读同一份 state / dom / stage。用一个共享上下文模块把它们接起来,避免层层传参。
5
+ //
6
+ // 关键点:stage 是**可变绑定**(boot 时创建、WebGL 不可用时保持 null)。ESM 的 import 是活绑定,
7
+ // 所以其它模块 `import { stage }` 能实时看到这里的新值;main.js 通过 setStage() 赋值
8
+ // (模块外不能直接给导入的绑定赋值)。
9
+
10
+ /**
11
+ * 自动化断言与手工排查的唯一读取口。必须**尽早**建好:boot 早期就有状态日志要落盘、
12
+ * 有 hangGuard/panelSelfCheck 等计数要暴露,晚建会让这些记录静默丢失(实测踩过)。
13
+ */
14
+ window.__WEB__ = window.__WEB__ || {};
15
+
16
+ /** 各区域 DOM 引用:集中取一次,避免函数里反复 querySelector */
17
+ export const dom = {
18
+ leftPanel: document.getElementById('left-panel'),
19
+ rosterList: document.getElementById('roster-list'),
20
+ rosterCount: document.getElementById('roster-count'),
21
+ rosterSearch: document.getElementById('roster-search'),
22
+ btnCollapse: document.getElementById('btn-collapse'),
23
+ brandTitle: document.getElementById('brand-title'),
24
+ brandIcon: document.getElementById('brand-icon'),
25
+ btnLibrary: document.getElementById('btn-library'),
26
+ btnMenu: document.getElementById('btn-menu'),
27
+ btnShot: document.getElementById('btn-shot'),
28
+ btnCinema: document.getElementById('btn-cinema'),
29
+ btnFullscreen: document.getElementById('btn-fullscreen'),
30
+ stageHint: document.getElementById('stage-hint'),
31
+ stageCanvas: document.getElementById('stage-canvas'),
32
+ panelHead: document.getElementById('panel-head'),
33
+ panelBody: document.getElementById('panel-body'),
34
+ footer: document.getElementById('app-footer'),
35
+ // 资源根管理(F4)
36
+ btnAddRoot: document.getElementById('btn-add-root'),
37
+ rootList: document.getElementById('root-list'),
38
+ rootMsg: document.getElementById('root-msg'),
39
+ rootCount: document.getElementById('root-count'),
40
+ btnRootToggle: document.getElementById('btn-root-toggle'),
41
+ rootManagerBody: document.getElementById('root-manager-body'),
42
+ };
43
+
44
+ /** Spine 渲染舞台:boot 时创建(WebGL 不可用时保持 null,UI 只提示不报错) */
45
+ export let stage = null;
46
+
47
+ /** 设置舞台实例(唯一写入口;外部模块通过 import 的活绑定读到新值) */
48
+ export function setStage(next) {
49
+ stage = next;
50
+ return stage;
51
+ }
52
+
53
+ /** 最小状态容器(M7 会扩展成带持久化的 store) */
54
+ export const state = {
55
+ // 表现侧开关(与渲染器无关,由外壳自己维护):状态行显隐 / 调试日志浮层 / 防卡死保护
56
+ showStatus: true,
57
+ debugLog: true,
58
+ hangGuard: true,
59
+ debugLogHandle: null,
60
+ debugPanel: null,
61
+ debugPanelHost: null,
62
+ hangGuardTimer: null,
63
+ hangGuardStats: null,
64
+ roots: [],
65
+ rootsError: null,
66
+ leftPanelVisible: true,
67
+ selectedModel: null,
68
+ // 当前渲染器:核心不再直接持有相机,统一从它取(现阶段只有 2D 一个实现)
69
+ currentRenderer: null,
70
+ /** 播放状态唯一真相(true=播放中):UI 与引擎都从它派生,不许各存一份 */
71
+ playing: true,
72
+ };
@@ -0,0 +1,242 @@
1
+ // app/dir-picker.js —— 资源根「选择目录」的服务端目录浏览器
2
+ //
3
+ // 为什么必须有它:模型在**服务端**磁盘上,浏览器拿不到绝对路径(File System Access API 给的是
4
+ // 客户端沙箱句柄),只能让服务端列目录。只能走服务端列目录(`/api/browse`):浏览器拿不到任意路径的目录列表。
5
+ //
6
+ // 交互// 点「选择目录」→ 弹出面板
7
+ // 顶部:标题 + 关闭
8
+ // 中部:当前路径(含子目录数 / 含多少 .skel)、「↑ 上级目录」、「选择此目录」、📁 子目录列表
9
+ // 底部:显示名称(可空,同名会合并成一个游戏)+「添加」
10
+ //
11
+ // 面板关掉就销毁,不常驻 DOM(不留隐藏节点)。
12
+
13
+ /** 打开时的默认起点:上次用过的目录优先,否则从根开始让用户自己走 */
14
+ const LAST_DIR_KEY = 'modelstage.dirpicker.last';
15
+
16
+ // 目录列举走统一 HTTP 层(api.js):拿重试 + 去重 + 统一的 TimeoutError 归类,别裸 fetch
17
+ import { getJson } from '../infra/api.js';
18
+
19
+ /**
20
+ * 创建目录选择器。
21
+ * @param {{onPick: (payload: {path: string, name: string}) => Promise<boolean>|boolean, formatError?: Function}} deps
22
+ * onPick 返回 true 表示添加成功(面板会自行关闭)
23
+ */
24
+ export function createDirPicker({ onPick, formatError, getLastError } = {}) {
25
+ let panel = null; // 遮罩层(含面板)
26
+ let currentPath = ''; // 当前浏览到的目录
27
+ let pickedPath = ''; // 用户点过"选择此目录"的目录(空 = 还没选)
28
+ let busy = false;
29
+
30
+ const q = (sel) => (panel ? panel.querySelector(sel) : null);
31
+
32
+ /** 读上次目录(浏览器存储不可用时回落空串) */
33
+ function lastDir() {
34
+ try { return localStorage.getItem(LAST_DIR_KEY) || ''; } catch { return ''; }
35
+ }
36
+
37
+ function rememberDir(path) {
38
+ try { localStorage.setItem(LAST_DIR_KEY, path); } catch { /* 存不了就算了 */ }
39
+ }
40
+
41
+ function ensureShell() {
42
+ if (panel) return panel;
43
+ panel = document.createElement('div');
44
+ panel.id = 'dir-picker';
45
+ panel.className = 'dir-picker';
46
+ panel.innerHTML = `
47
+ <div class="dir-picker-box" role="dialog" aria-modal="true" aria-label="添加资源根">
48
+ <div class="dir-picker-head">
49
+ <strong>添加资源根</strong>
50
+ <span class="dir-picker-spacer"></span>
51
+ <button type="button" class="mini-btn" data-role="close">关闭</button>
52
+ </div>
53
+ <div class="dir-picker-body" data-role="body"></div>
54
+ <!-- 失败原因显示在对话框**自己身上**:它弹在屏幕中央,左栏那行提示在用户背后等于没有 -->
55
+ <div class="dir-picker-alert" data-role="alert" role="alert" hidden></div>
56
+ <div class="dir-picker-foot">
57
+ <span class="muted-hint">显示名称(同名会合并成一个游戏,用于归类原版/Mod)</span>
58
+ <input type="text" data-role="name" placeholder="如:棕色尘埃2" aria-label="资源根显示名称" />
59
+ <button type="button" class="mini-btn is-accent" data-role="add">添加</button>
60
+ </div>
61
+ </div>`;
62
+ document.body.append(panel);
63
+ // 点遮罩关闭(点面板内部不关)
64
+ panel.addEventListener('click', (e) => { if (e.target === panel) close(); });
65
+ q('[data-role="close"]').addEventListener('click', close);
66
+ q('[data-role="add"]').addEventListener('click', doAdd);
67
+ return panel;
68
+ }
69
+
70
+ /** 渲染当前目录:路径信息 / 上级 / 选择此目录 / 子目录列表 */
71
+ async function renderBody() {
72
+ const body = q('[data-role="body"]');
73
+ if (!body) return;
74
+ body.replaceChildren();
75
+ body.append(text('div', 'dir-picker-hint', '加载中…'));
76
+ let data = null;
77
+ try {
78
+ // 幂等 GET(连点同一目录会被 api.js 去重)⇒ 走统一层;超时 30s 的依据:
79
+ // 本机 CIFS(vers=3.02)实测 75 条目 5.5ms、241 条目 19ms,**未实测到慢例**,
80
+ // 按项目既有「catalog 冷启动最坏 31s」(同属目录扫描链路)**类推**取 30s。
81
+ data = await getJson(`/api/browse?path=${encodeURIComponent(currentPath)}`, { timeoutMs: 30000 });
82
+ if (!data || data.error) throw new Error((data && data.error) || '服务端返回空目录数据');
83
+ } catch (err) {
84
+ body.replaceChildren(text('div', 'dir-picker-error',
85
+ `读取失败:${typeof formatError === 'function' ? formatError(err) : (err && err.message) || err}`));
86
+ return;
87
+ }
88
+ currentPath = data.path || currentPath;
89
+ const hint = data.hint || {};
90
+ const info = text('div', 'dir-picker-hint', '');
91
+ info.append('当前:');
92
+ const code = document.createElement('code');
93
+ code.textContent = currentPath;
94
+ info.append(code);
95
+ info.append(` 子目录 ${hint.subDirCount || 0} 个${hint.skelHere ? `,含 ${hint.skelHere} 个 .skel` : ''}`);
96
+ body.replaceChildren(info);
97
+
98
+ // ↑ 上级目录
99
+ if (data.parent) body.append(navBtn(`↑ 上级目录`, data.parent));
100
+
101
+ // 选择此目录(已选过就变成"✓ 已选择",再点仍是选中当前)
102
+ const pick = document.createElement('button');
103
+ pick.type = 'button';
104
+ // 已选中态用描边强调(与「选择目录」「添加」同一套语言),不再用实心高饱和
105
+ pick.className = pickedPath === currentPath ? 'mini-btn is-accent' : 'mini-btn';
106
+ pick.dataset.pick = currentPath;
107
+ pick.textContent = pickedPath === currentPath ? '✓ 已选择此目录' : '选择此目录';
108
+ pick.addEventListener('click', () => { pickedPath = currentPath; renderBody(); });
109
+ const pickRow = text('div', 'dir-picker-pick', '');
110
+ pickRow.append(pick);
111
+ body.append(pickRow);
112
+
113
+ // 子目录列表
114
+ const dirs = Array.isArray(data.dirs) ? data.dirs : [];
115
+ if (!dirs.length) {
116
+ body.append(text('div', 'dir-picker-hint', '(无子目录)'));
117
+ return;
118
+ }
119
+ const list = text('div', 'dir-picker-list', '');
120
+ for (const d of dirs) {
121
+ const btn = navBtn(`📁 ${d.name}`, d.path);
122
+ btn.dataset.dirName = d.name;
123
+ list.append(btn);
124
+ }
125
+ body.append(list);
126
+ }
127
+
128
+ function text(tag, className, content) {
129
+ const n = document.createElement(tag);
130
+ if (className) n.className = className;
131
+ if (content) n.textContent = content;
132
+ return n;
133
+ }
134
+
135
+ function navBtn(label, target) {
136
+ const btn = document.createElement('button');
137
+ btn.type = 'button';
138
+ btn.className = 'mini-btn dir-picker-nav';
139
+ btn.dataset.nav = target;
140
+ btn.textContent = label;
141
+ btn.addEventListener('click', () => {
142
+ // 换目标 ⇒ 之前的「选择此目录」作废。
143
+ // 不清的后果(实测过的 bug):在 A 点过选择、再导航到 B 点添加,提交的却是 A,
144
+ // 服务端按路径判重正确返回 409「该目录已登记为资源根」——
145
+ // 用户看到的是"我明明没登记 B,凭什么说已登记",且「✓ 已选择」还挂在 A 上。
146
+ pickedPath = '';
147
+ currentPath = target;
148
+ renderBody();
149
+ });
150
+ return btn;
151
+ }
152
+
153
+ /**
154
+ * 添加:**只提交当前浏览的目录**。
155
+ *
156
+ * 为什么不再用 `pickedPath || currentPath`:pickedPath 是"点过选择此目录"留下的,
157
+ * 一旦导航没清(历史 bug),它就会是**上一次选过的目录** ⇒ 提交一个与当前视图无关的目标
158
+ * ⇒ 服务端按路径判重正确返回 409,而用户完全看不出为什么。
159
+ * 现在 navBtn 已经"导航即清除",pickedPath 只可能是空或等于 currentPath;
160
+ * 这里再兜一层:提交值永远来自 currentPath,两者不一致时不可能发生错位。
161
+ */
162
+ async function doAdd() {
163
+ if (busy) return;
164
+ const path = currentPath;
165
+ const nameInput = q('[data-role="name"]');
166
+ const name = nameInput ? nameInput.value.trim() : '';
167
+ if (!path) return;
168
+ clearAlert();
169
+ busy = true;
170
+ try {
171
+ const res = typeof onPick === 'function' ? await onPick({ path, name }) : false;
172
+ // onPick 可能返回布尔(既有实现)或 { ok, error }(更细的实现)——两种都认
173
+ const ok = res && typeof res === 'object' ? res.ok === true : res === true;
174
+ if (ok) { rememberDir(path); close(); return; }
175
+ // 失败:**对话框不关**,把原因写在底部输入行上方,用户改完就能重试
176
+ showAlert(pickErrorText(res));
177
+ } finally {
178
+ busy = false;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * 取失败原因文本。
184
+ * 优先用 onPick 直接给回来的 error({ ok:false, error });
185
+ * 否则问 getLastError(root-manager 会把上一次失败文案记下来)。
186
+ * @param {unknown} res onPick 的返回值
187
+ * @returns {string}
188
+ */
189
+ function pickErrorText(res) {
190
+ if (res && typeof res === 'object' && typeof res.error === 'string' && res.error) return res.error;
191
+ try {
192
+ if (typeof getLastError === 'function') {
193
+ const t = getLastError();
194
+ if (typeof t === 'string' && t) return t;
195
+ }
196
+ } catch { /* 取不到就退回通用文案 */ }
197
+ return '添加失败,请检查目录路径与名称后重试';
198
+ }
199
+
200
+ /** 把失败原因显示在对话框内 */
201
+ function showAlert(text) {
202
+ const box = q('[data-role="alert"]');
203
+ if (!box) return;
204
+ box.textContent = String(text || '');
205
+ box.hidden = !box.textContent;
206
+ }
207
+
208
+ /** 收起错误行(重试时先清掉,避免旧错误杵在那儿) */
209
+ function clearAlert() {
210
+ const box = q('[data-role="alert"]');
211
+ if (!box) return;
212
+ box.textContent = '';
213
+ box.hidden = true;
214
+ }
215
+
216
+ /** 打开面板(默认从上次目录或 / 开始) */
217
+ function open(startPath) {
218
+ ensureShell();
219
+ panel.hidden = false;
220
+ pickedPath = '';
221
+ currentPath = startPath || lastDir() || '/';
222
+ const nameInput = q('[data-role="name"]');
223
+ if (nameInput) nameInput.value = '';
224
+ renderBody();
225
+ return panel;
226
+ }
227
+
228
+ /** 关闭并销毁(不留隐藏容器) */
229
+ function close() {
230
+ if (!panel) return;
231
+ panel.remove();
232
+ panel = null;
233
+ }
234
+
235
+ return {
236
+ open, close,
237
+ /** 只读:当前浏览路径 / 已选路径(断言用) */
238
+ get state() { return { open: !!panel, currentPath, pickedPath }; },
239
+ /** 断言用:直接设定路径后重绘(等价于用户一路点进来) */
240
+ goto(path) { currentPath = path; return panel ? renderBody() : null; },
241
+ };
242
+ }