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,222 @@
1
+ // app/anim-list.js —— 动作清单的唯一数据源 + 播放 + 循环推进
2
+ //
3
+ // 契约要求(docs/契约/渲染面.md):
4
+ // §一 动作清单只能有一处来源:右栏与底栏必须读同一份,不得各取一处;
5
+ // §二 每条 = 动作名 + 时长;取不到时长就不显示(禁止 0s 冒充);
6
+ // §三 两处入口行为一致(同列表/同顺序/同时长,点任一处都能播,高亮同步);
7
+ // §四 「单动循环」开启 = 按列表顺序依次播完全部并回到第一条;关闭 = 停留当前动作重复播;
8
+ // §五 Spine 与 Live2D 都要覆盖,Live2D 用 `group / motion` 区分。
9
+ //
10
+ // 为什么集中在这里:此前右栏读 bridge.readState().motions(该字段不存在)、底栏读装载返回,
11
+ // 两处数据源不一致,Live2D 下右栏永远是空的。现在两处都只调 buildAnimationList()。
12
+ //
13
+ // 当前播放项的唯一真相是底栏下拉(#transport-motion)的值(契约 §七),
14
+ // 这里只负责"给出清单 / 播某条 / 按顺序推进",不自己另存一份"当前动作"。
15
+
16
+ import { state, stage } from './context.js';
17
+ import { setRendererSetting } from './renderer-setting.js';
18
+
19
+ /** 面板与底栏共用的清单缓存(每次模型变化后重建) */
20
+ let cached = [];
21
+ /** 循环推进态:只记"从哪条开始、什么时候开始",当前项仍以下拉为准 */
22
+ const cycle = { on: false, timer: null, startedAt: 0, currentId: '' };
23
+
24
+ /** Live2D 桥(未装载时返回 null) */
25
+ function live2dBridge() {
26
+ const l2d = typeof window !== 'undefined' && window.__WEB__ ? window.__WEB__.live2d : null;
27
+ if (!l2d || typeof l2d.isActive !== 'function' || !l2d.isActive()) return null;
28
+ return l2d.bridge || null;
29
+ }
30
+
31
+ /** 时长格式:6.2s;没有时长返回空串(契约禁止显示 0s / —) */
32
+ export function durationText(duration) {
33
+ const d = Number(duration);
34
+ if (!Number.isFinite(d) || d <= 0) return '';
35
+ return `${d.toFixed(1)}s`;
36
+ }
37
+
38
+ /** 列表项的显示文案:`名字 · 6.2s`(无时长就只留名字) */
39
+ export function labelWithDuration(item) {
40
+ const t = durationText(item && item.duration);
41
+ return t ? `${item.label} · ${t}` : String((item && item.label) || '');
42
+ }
43
+
44
+ /**
45
+ * 建清单(单一来源)。
46
+ * Spine:舞台 stats().animations 的名称 + api.animationDetails() 的时长;
47
+ * Live2D:bridge.motionItems()(label 已是 `group / motion`)。
48
+ * @returns {Array<{id: string, label: string, duration: number|null}>}
49
+ */
50
+ export function buildAnimationList() {
51
+ const bridge = live2dBridge();
52
+ let list = [];
53
+ if (bridge && typeof bridge.motionItems === 'function') {
54
+ list = bridge.motionItems().map((m) => ({ id: m.label, label: m.label, duration: m.duration }));
55
+ } else {
56
+ let details = [];
57
+ try { details = stage && typeof stage.animationDetails === 'function' ? stage.animationDetails() : []; } catch { details = []; }
58
+ if (details.length) {
59
+ list = details.map((a) => ({ id: a.name, label: a.name, duration: a.duration }));
60
+ } else {
61
+ // 兜底:拿不到时长也要能列出来(名称必须显示)
62
+ let names = [];
63
+ try {
64
+ const snap = stage && typeof stage.stats === 'function' ? stage.stats() : null;
65
+ names = (snap && Array.isArray(snap.animations)) ? snap.animations : [];
66
+ } catch { names = []; }
67
+ list = names.map((n) => ({ id: n, label: String(n), duration: null }));
68
+ }
69
+ }
70
+ cached = list;
71
+ return list.slice();
72
+ }
73
+
74
+ /**
75
+ * 预热动作时长(目前只有 Live2D 需要:时长在动作文件里,不在运行时状态里)。
76
+ * 取到后回调刷新一次,面板与底栏一起更新;同一模型只尝试一次。
77
+ * @returns {Promise<number>} 本次新取到条数
78
+ */
79
+ export async function primeAnimationDurations() {
80
+ const bridge = live2dBridge();
81
+ if (!bridge || typeof bridge.ensureMotionDurations !== 'function') return 0;
82
+ if (primeAnimationDurations.busy) return 0;
83
+ primeAnimationDurations.busy = true;
84
+ try {
85
+ const filled = await bridge.ensureMotionDurations();
86
+ if (filled > 0) {
87
+ cached = [];
88
+ buildAnimationList();
89
+ if (typeof cycle.onAdvance === 'function') cycle.onAdvance('');
90
+ }
91
+ return filled;
92
+ } catch {
93
+ return 0;
94
+ } finally {
95
+ primeAnimationDurations.busy = false;
96
+ }
97
+ }
98
+
99
+ /** 最近一次构建的清单(不重新取数) */
100
+ export function currentList() {
101
+ return cached.slice();
102
+ }
103
+
104
+ /**
105
+ * 用同一份清单重填底栏下拉(同顺序、同文案、同时长)。
106
+ * 放在这里的原因:装载回调、模型切换、循环推进都可能改变清单,
107
+ * 让"填下拉"只有一个出口,就不会出现"右栏有、底栏没有"的错位。
108
+ * @returns {number} 填入条数
109
+ */
110
+ export function syncTransportOptions() {
111
+ const transport = state.transport;
112
+ if (!transport || typeof transport.setOptions !== 'function') return 0;
113
+ const list = cached.length ? cached : buildAnimationList();
114
+ if (!list.length) return 0;
115
+ const picker = transport.picker ? transport.picker('transport-motion') : null;
116
+ const curVal = picker && typeof picker.value === 'function' ? picker.value() : (document.getElementById('transport-motion') || {}).value;
117
+ const keep = curVal && list.some((x) => x.id === curVal) ? curVal : list[0].id;
118
+ transport.setOptions('transport-motion', list.map((x) => ({ text: labelWithDuration(x), value: x.id })), keep);
119
+ return list.length;
120
+ }
121
+
122
+ /**
123
+ * 清掉「当前动作」的所有内存副本(换模型时调用)。
124
+ *
125
+ * 为什么需要:动作是**模型属性**(设计 §1.9),但它的三个副本都是应用层状态,不会自己跟着模型走:
126
+ * · `cycle.currentId` —— 循环推进用,名字命中新清单就会继续按旧项推进
127
+ * · `state.transportMotion` —— 底栏下拉的副本,被 `currentAnimationId()` 优先读取
128
+ * · 底栏下拉自身的值 —— 新模型若有同名动作(碧蓝航线 live2d 全都叫 idle/home/…),
129
+ * "保留同名项"的规则会让它原样停在上一个模型选的那条 ⇒ 新模型**真的播它**
130
+ * 三者不清,就会出现"选了 home、切哪个模型都播 home"。
131
+ */
132
+ export function resetMotionState() {
133
+ cycle.currentId = '';
134
+ cycle.startedAt = 0;
135
+ if (state) state.transportMotion = '';
136
+ try { delete document.documentElement.dataset.transportMotion; } catch { /* 忽略 */ }
137
+ }
138
+
139
+ /** 当前播放项 id:唯一真相是底栏下拉的值(契约 §七) */
140
+ export function currentAnimationId() {
141
+ // 唯一真相 = state.transportMotion(底栏自绘下拉没有 <select>.value,句柄也可能刚被重建)
142
+ if (state && state.transportMotion) return String(state.transportMotion);
143
+ const transport = state && state.transport;
144
+ if (transport && typeof transport.picker === 'function') {
145
+ const pk = transport.picker('transport-motion');
146
+ if (pk && typeof pk.value === 'function') {
147
+ const v = pk.value();
148
+ if (v) return v;
149
+ }
150
+ }
151
+ const sel = typeof document !== 'undefined' ? document.querySelector('#transport-motion') : null;
152
+ return sel && sel.value ? sel.value : '';
153
+ }
154
+
155
+ /**
156
+ * 播一条动作。
157
+ * @param {string} id 清单里的 id(Live2D 为 `group / motion` 标签,Spine 为动作名)
158
+ * @returns {boolean} 是否被接受
159
+ */
160
+ export function playAnimation(id) {
161
+ if (!id) return false;
162
+ // 一律经渲染器下发(INV-01):Live2D 面走 bridge.playLabel、Spine 面走舞台的 setAnimation,
163
+ // 分流在各自渲染器的 setSetting('animation') 里,这里不再判断"哪一面"。
164
+ const { ok } = setRendererSetting('animation', id);
165
+ if (ok) notePlaying(id);
166
+ return ok;
167
+ }
168
+
169
+ /** 记录"从这条开始播",供循环推进计算时间 */
170
+ export function notePlaying(id) {
171
+ cycle.currentId = String(id || '');
172
+ cycle.startedAt = Date.now();
173
+ }
174
+
175
+ /** 循环开关(契约 §四):开 = 顺序播完全部并回到第一条;关 = 停留当前动作重复播 */
176
+ export function setCycleLoop(on) {
177
+ cycle.on = !!on;
178
+ if (cycle.timer) { clearInterval(cycle.timer); cycle.timer = null; }
179
+ if (!cycle.on) return false;
180
+ cycle.currentId = cycle.currentId || currentAnimationId();
181
+ cycle.startedAt = Date.now();
182
+ cycle.timer = setInterval(stepCycle, 200);
183
+ return true;
184
+ }
185
+
186
+ export function isCycleLoop() {
187
+ return cycle.on;
188
+ }
189
+
190
+ /** 时长未知时的推进间隔:契约要求"依次播完全部",不能因为没时长就卡住 */
191
+ const FALLBACK_ADVANCE_MS = 1200;
192
+
193
+ /**
194
+ * 推进一拍:当前动作放完就切下一条,末尾回到第一条。
195
+ * 有真实时长就按时长走;没有时长(如空轨道动画)用兜底间隔推进,
196
+ * 否则循环会在第一个"无时长"动作上停死。
197
+ */
198
+ function stepCycle() {
199
+ if (!cycle.on) return;
200
+ const list = cached.length ? cached : buildAnimationList();
201
+ if (list.length < 2) return;
202
+ const id = cycle.currentId || currentAnimationId();
203
+ const idx = list.findIndex((x) => x.id === id);
204
+ const cur = idx >= 0 ? list[idx] : null;
205
+ const dur = cur && Number(cur.duration);
206
+ const waitMs = Number.isFinite(dur) && dur > 0 ? dur * 1000 - 80 : FALLBACK_ADVANCE_MS;
207
+ if (Date.now() - cycle.startedAt < waitMs) return;
208
+ const next = list[(idx + 1) % list.length]; // 末尾回到第一条
209
+ if (!next) return;
210
+ const { ok } = setRendererSetting('animation', next.id);
211
+ if (!ok) return;
212
+ notePlaying(next.id);
213
+ if (state.transport) state.transport.setSelectValue('transport-motion', next.id);
214
+ document.documentElement.dataset.transportMotion = next.id;
215
+ // 面板高亮跟随(回调注入,避免面板↔应用双向依赖)
216
+ if (typeof cycle.onAdvance === 'function') cycle.onAdvance(next.id);
217
+ }
218
+
219
+ /** 面板注册"推进后重绘高亮"的回调 */
220
+ export function onCycleAdvance(fn) {
221
+ cycle.onAdvance = typeof fn === 'function' ? fn : null;
222
+ }
@@ -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
+ };