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,461 @@
1
+ // infra/ready.js —— 就绪信号(页面侧):阶段状态上报 + 页面内直读
2
+ //
3
+ // 契约:docs/契约/交互与实现约束.md。与 diag 完全分离:
4
+ // · diag 是给人看的追加流水;这里是给机器读的**状态机**(每阶段只留最新值);
5
+ // · 因此**绝不进调试面板**(不发 diag 事件),也不在渲染循环里每帧写(契约 §六-4)。
6
+ //
7
+ // 上报策略:变更时**去抖合并**(默认 120ms 内的多次 markStage 合成一条 POST),
8
+ // 既保证"最后一个状态不丢",也不会因为状态机抖动把请求打爆。
9
+
10
+ /** 阶段清单(与 server/http/ready.mjs 的 STAGES 一致;契约 §三 要求先登记) */
11
+ export const STAGES = [
12
+ 'shell', 'topbar', 'sidebarLeft', 'sidebarRight', 'panel',
13
+ 'bottombar', 'debugPanel', 'renderer', 'model',
14
+ ];
15
+
16
+ /** 合法状态(契约 §二) */
17
+ // painted 是模型专属的中间级(契约 §2.2),也是合法 state,必须进白名单
18
+ // —— 漏了它会让 painted 上报被当成非法状态静默丢弃(实测踩过)。
19
+ // pending = 「还没上报」(契约 §四 的合法 state);rank 0,任何真实状态都能覆盖它。
20
+ // 不用 unknown:契约 §四 没有这个值,且测试要同时处理两个"未就绪"值容易漏判。
21
+ export const STATES = ['pending', 'mounted', 'painted', 'settled', 'interactive', 'failed'];
22
+
23
+ /** 前进顺序:同阶段不后退,failed 例外。painted 是**模型专属**的中间级(契约 §2.2),
24
+ * 必须与 settled 同档,否则会被当成"未前进"静默丢弃(实测踩过:painted 报不上去)。 */
25
+ const RANK = { pending: 0, mounted: 1, painted: 2, settled: 2, interactive: 3 };
26
+
27
+ /** 模型阶段轮询间隔(ms):只读帧计数/动作数,代价可忽略,且远低频于渲染帧 */
28
+ const MODEL_POLL_MS = 80;
29
+ /** DOM 阶段轮询间隔(ms):读包围盒/动画状态,120ms 足够判断"稳定" */
30
+ const ELEMENT_POLL_MS = 120;
31
+ /** 上报去抖窗口(ms):同一窗口内的多次变更合成一条请求 */
32
+ // 复用模型文件那条重试实现(用户实测:同一时刻模型文件因有重试而成功、/api/ready 因裸 fetch 而失败)
33
+ import { fetchWithRetry } from './api.js';
34
+
35
+ // 上报合并窗口:模型装载完成会**连续**标记 model/panel/bottombar 三个阶段,
36
+ // 窗口太短就会各发一条(现场:模型加载成功后 41ms 内连发 2~3 条 POST,全部 Failed to fetch,
37
+ // 因为那一刻模型文件的几十个请求正占满同源连接槽)。400ms 足以把它们合成一条,
38
+ // 又不会让就绪信号明显滞后。
39
+ const FLUSH_DEBOUNCE_MS = 400;
40
+
41
+ const st = {
42
+ stages: {}, // stage → { state, at, detail?, reason? }
43
+ rev: 0, // 本地 rev(服务端才是权威;这里供 __WEB__.ready() 立即可读)
44
+ pending: new Map(), // stage → update[](待上报,**队列**:同阶段的中间态不能互相覆盖)
45
+ timer: null,
46
+ installed: false,
47
+ flushes: 0, // 实际上报次数(每次真正发出 POST 计 1;供"批量重置只报一次"的断言用)
48
+ inflight: false, // 上一次上报还没回来(并发保护:跳过一次,避免请求堆积)
49
+ dropped: 0, // 因 in-flight 被跳过的次数(可观测,便于判断"是不是报得太密")
50
+ fails: 0, // 上报失败次数(限频记日志,不刷屏)
51
+ lastFailAt: 0,
52
+ lastFail: null, // {at,name,msg,attempts}:一次失败就能看出是网络层还是超时(排障唯一途径)
53
+ };
54
+
55
+ /**
56
+ * 标记某阶段的状态(**只在上报点调用**,不要放进渲染循环)。
57
+ * @param {string} name 阶段名(必须在 STAGES 内)
58
+ * @param {'mounted'|'settled'|'interactive'|'failed'} state
59
+ * @param {object} [detail] 关键计数(动作数、尺寸、帧数…)
60
+ * @param {string} [reason] state==='failed' 时的可读原因
61
+ * @returns {boolean} 是否实际推进了状态
62
+ */
63
+ export function markStage(name, state, detail, reason) {
64
+ if (!STAGES.includes(name) || !STATES.includes(state)) return false;
65
+ const prev = st.stages[name];
66
+ if (state !== 'failed' && prev && prev.state !== 'failed'
67
+ && (RANK[state] || 0) <= (RANK[prev.state] || 1)) {
68
+ return false; // 同级/退步:忽略(重复上报是常态)
69
+ }
70
+ const rec = { state, at: Date.now() };
71
+ if (detail && typeof detail === 'object') rec.detail = detail;
72
+ if (state === 'failed') rec.reason = String(reason || '未提供原因');
73
+ st.stages[name] = rec;
74
+ st.rev += 1;
75
+ // 追加而不是覆盖:mounted → painted → interactive 可能都落在同一个去抖窗口内,
76
+ // 若只留最新值,painted 就永远不会出现在流里(测试等 painted 会白等),这是实测踩过的坑。
77
+ const q = st.pending.get(name) || [];
78
+ q.push({ stage: name, ...rec });
79
+ st.pending.set(name, q);
80
+ schedule();
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * 重置某阶段(换内容时用:新模型/新面板实例要能从 mounted 重新走三级)。
86
+ * 与 markStage 的区别:这会**清掉**已有等级,并显式告知服务端 reset。
87
+ * @param {string} name 阶段名
88
+ * @returns {boolean} 是否执行了重置
89
+ */
90
+ /**
91
+ * 重置的内部实现(**不触发上报**):清掉已有等级、塞一条 reset 记录进待发队列。
92
+ * @param {string} name 阶段名
93
+ * @returns {boolean} 是否是合法阶段
94
+ */
95
+ function resetStageInternal(name) {
96
+ if (!STAGES.includes(name)) return false;
97
+ delete st.stages[name];
98
+ st.rev += 1;
99
+ st.pending.set(name, [{ stage: name, state: 'mounted', at: Date.now(), reset: true }]);
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ * 重置单个阶段并立刻上报。
105
+ * **为什么要立刻上报**:reset 若被去抖窗口里的后续 mark 覆盖,服务端就只见到"最终状态",
106
+ * 而它相对旧记录是同级/退步 ⇒ 整轮推进被静默丢弃(实测踩过:换模型后 SSE 一条 update 都收不到)。
107
+ * @param {string} name 阶段名
108
+ * @returns {boolean} 是否执行了重置
109
+ */
110
+ export function resetStage(name) {
111
+ const ok = resetStageInternal(name);
112
+ if (ok) scheduleFlush();
113
+ return ok;
114
+ }
115
+
116
+ /**
117
+ * 批量重置多个阶段,**整批只上报一次**。
118
+ *
119
+ * 为什么需要:一次交互(如切影院模式)会同时影响顶栏与两侧栏;逐个 resetStage 会各发一次 POST,
120
+ * 在慢机器上这些请求容易在页面关闭/负载高时被 AbortSignal 超时中止,表现为一串
121
+ * `net::ERR_ABORTED (Fetch)` 噪音(实测:f2-topbar 的 problems 从 10 涨到 47)。
122
+ * @param {string[]} names 阶段名数组(非法项自动跳过)
123
+ * @returns {string[]} 实际被重置的阶段名
124
+ */
125
+ export function resetStages(names, opts) {
126
+ const flushNowAfter = !(opts && opts.flush === false);
127
+ const done = [];
128
+ for (const name of Array.isArray(names) ? names : []) {
129
+ if (resetStageInternal(name)) done.push(name);
130
+ }
131
+ // flush:false 时不在这里发:调用方紧接着会重新跟踪,跟踪的第一拍 markStage('mounted')
132
+ // 会把 reset + mounted 一起进同一个去抖窗口 ⇒ 整次交互只发 1 个 POST。
133
+ if (done.length && flushNowAfter) scheduleFlush();
134
+ return done;
135
+ }
136
+
137
+ /**
138
+ * 上报统计(诊断/断言用)。
139
+ * @returns {{flushes: number, pending: number, rev: number, dropped: number, fails: number, inflight: boolean}}
140
+ * 上报次数 / 待发条数 / 本地 rev / 因 in-flight 被跳过的次数 / 失败次数 / 是否在飞
141
+ */
142
+ export function stats() {
143
+ return { flushes: st.flushes, pending: st.pending.size, rev: st.rev, dropped: st.dropped, fails: st.fails, inflight: st.inflight, lastFail: st.lastFail };
144
+ }
145
+
146
+ /** 页面内直读快照(契约 §5.1):ready() 全量,ready('model') 单阶段 */
147
+ export function snapshot(stage) {
148
+ const at = Date.now();
149
+ if (stage) {
150
+ const name = String(stage);
151
+ if (!STAGES.includes(name)) return { ok: false, error: `未登记的阶段:${name}`, known: STAGES.slice() };
152
+ const rec = st.stages[name];
153
+ // 还没上报 ⇒ pending(与 HTTP 通道同口径;不用 unknown,契约 §四 没这个值)
154
+ return rec ? { at, rev: st.rev, stage: name, ...rec } : { at, rev: st.rev, stage: name, state: 'pending' };
155
+ }
156
+ return { at, rev: st.rev, stages: { ...st.stages } };
157
+ }
158
+
159
+ /** 去抖调度上报 */
160
+ function schedule() {
161
+ if (st.timer) return;
162
+ st.timer = setTimeout(() => {
163
+ st.timer = null;
164
+ flush();
165
+ }, FLUSH_DEBOUNCE_MS);
166
+ }
167
+
168
+ /**
169
+ * 上报待发送的变更(就绪通道是旁路信号,坏掉不影响页面)。
170
+ *
171
+ * `keepalive` **只允许在"页面即将卸载"时用**(flushNow → pagehide),因为浏览器对它有两条硬限制:
172
+ * ① 所有 in-flight keepalive 请求的 body 总量 ≤ 64KB,超了直接 `Failed to fetch`;
173
+ * ② 它**占用 fetch 连接槽**(HTTP/1.1 每主机 6 个),普通请求(如模型文件)会被挤出去。
174
+ * 曾经常规上报也带 keepalive:切换模型 → 每次 resetStage('panel') → flush() → keepalive 请求堆积
175
+ * → 模型文件(几十个并发)被挤出连接池 → **所有模型加载失败、刷新页面才恢复**,日志里刷
176
+ * `net.fail · POST /api/ready · Failed to fetch`。所以默认值必须是 false。
177
+ *
178
+ * @param {{keepalive?: boolean}} [opts]
179
+ */
180
+ function flush(opts) {
181
+ const keepalive = !!(opts && opts.keepalive);
182
+ if (!st.pending.size) return;
183
+ // 并发保护:上一次还没回来就跳过本次(丢一次上报是可接受的——就绪信号是旁路,
184
+ // 下一次会带全量阶段快照;而堆积的请求会挤占连接槽,代价大得多)。
185
+ if (st.inflight) { st.dropped += 1; return; }
186
+ const updates = [...st.pending.values()].flat();
187
+ st.pending.clear();
188
+ st.flushes += 1;
189
+ st.inflight = true;
190
+ const payload = JSON.stringify({ updates });
191
+ const headers = { 'Content-Type': 'application/json' };
192
+ const onFail = (e, attempts) => {
193
+ st.fails += 1;
194
+ // 记最后一次失败的结构化原因(不打印,避免刷屏;由 readyStats() 暴露)
195
+ st.lastFail = {
196
+ at: Date.now(),
197
+ name: (e && e.name) || null,
198
+ msg: String((e && e.message) || e).slice(0, 120),
199
+ attempts: attempts || 1,
200
+ };
201
+ const now = Date.now();
202
+ if (now - st.lastFailAt > 10000) {
203
+ st.lastFailAt = now;
204
+ try {
205
+ if (typeof console !== 'undefined' && console.warn) {
206
+ console.warn(`[ready] 上报失败 ${st.fails} 次(最近): ${st.lastFail.msg}`);
207
+ }
208
+ } catch { /* 忽略 */ }
209
+ }
210
+ };
211
+ try {
212
+ if (keepalive) {
213
+ // 卸载前:**不重试**(页面已经在走,没时间退避),keepalive 保证尽力送达
214
+ fetch('/api/ready', { method: 'POST', headers, body: payload, keepalive: true, signal: AbortSignal.timeout(5000) })
215
+ .catch((e) => onFail(e, 1))
216
+ .finally(() => { st.inflight = false; });
217
+ } else {
218
+ // 常规上报:与模型文件走同一条重试实现(旁路信号 ⇒ 3s 超时 + 2 次快速重试)
219
+ fetchWithRetry('/api/ready', { method: 'POST', headers, body: payload, timeoutMs: 3000, retries: 2, label: 'ready-flush' })
220
+ .catch((e) => onFail(e, 3))
221
+ .finally(() => { st.inflight = false; });
222
+ }
223
+ } catch {
224
+ st.inflight = false;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * 安排一次合并上报(**常规路径都走这里**)。
230
+ *
231
+ * 为什么不是立刻发:模型装载完成那一刻,14 个 motion3.json + 纹理正占满同源连接槽(6 个),
232
+ * 此刻再发 POST 会**瞬间** Failed to fetch(实测:装载成功后 41ms 内两条同时失败,同一毫秒)。
233
+ * 所以:攒窗口 + 每次新阶段重置计时,等连接池冷下来再发。
234
+ */
235
+ export function scheduleFlush() {
236
+ if (st.timer) clearTimeout(st.timer);
237
+ st.timer = setTimeout(() => {
238
+ st.timer = null;
239
+ flush();
240
+ }, FLUSH_DEBOUNCE_MS);
241
+ }
242
+
243
+ /**
244
+ * 立刻上报。**这是唯一允许 keepalive 的路径**(页面隐藏/卸载前调用,避免最后一批状态丢失)。
245
+ * 注意:并发保护在这里不生效(卸载前必须尽力发出),所以显式绕过 inflight 检查。
246
+ */
247
+ export function flushNow() {
248
+ if (st.timer) { clearTimeout(st.timer); st.timer = null; }
249
+ st.inflight = false; // 卸载前不因"上一次还在飞"而丢最后一批
250
+ flush({ keepalive: true });
251
+ }
252
+
253
+ /**
254
+ * 跟踪「模型」阶段的三级状态(契约 §2.2)。
255
+ *
256
+ * 判据(**不用像素采样**):
257
+ * · mounted —— 画布 isConnected 且尺寸非零
258
+ * · painted —— 渲染器自己的已绘帧计数 ≥ 1(硬计数;像素采样在画布 detached 时也会"有颜色")
259
+ * · interactive —— 动作/动画清单非空
260
+ *
261
+ * @param {{kind: string, id: string|null, canvas: object|null, getFrames: Function, getActions: Function, timeoutMs?: number}} opts
262
+ * @returns {() => void} 停止跟踪
263
+ */
264
+ export function trackModelStage(opts) {
265
+ const { kind, id, canvas, getFrames, getActions } = opts || {};
266
+ const timeoutMs = Number(opts && opts.timeoutMs) || 8000;
267
+ const t0 = Date.now();
268
+ let stopped = false;
269
+ const base = { kind: kind || null, id: id || null };
270
+ // 换模型(或第一次装载)时重新起算 model 阶段:
271
+ // 否则上一轮的 interactive 会被新模型继承,"等 painted"在新模型上永远不会重新发生。
272
+ const prevId = st.stages.model && st.stages.model.detail ? st.stages.model.detail.id : null;
273
+ if (prevId !== base.id) resetStage('model');
274
+
275
+ const mountedNow = () => {
276
+ const el = canvas || null;
277
+ if (!el || el.isConnected !== true) return false;
278
+ const w = Math.max(el.width || 0, el.clientWidth || 0);
279
+ const h = Math.max(el.height || 0, el.clientHeight || 0);
280
+ return w > 0 && h > 0;
281
+ };
282
+ const frames = () => {
283
+ try { return Number(getFrames && getFrames()) || 0; } catch { return 0; }
284
+ };
285
+ const actions = () => {
286
+ try { return Number(getActions && getActions()) || 0; } catch { return 0; }
287
+ };
288
+
289
+ // 严格按 mounted → painted → interactive 推进(契约 §2.2):
290
+ // interactive 不得越过 painted —— 动作清单在装载完成瞬间就可能非空,
291
+ // 若不拦住,会在"一帧都还没画"时报 interactive,等于把分级做成了摆设。
292
+ let painted = false;
293
+ const tick = () => {
294
+ if (stopped) return;
295
+ if (mountedNow()) markStage('model', 'mounted', { ...base });
296
+ const f = frames();
297
+ if (!painted && f >= 1) {
298
+ painted = true;
299
+ markStage('model', 'painted', { ...base, frames: f });
300
+ }
301
+ const a = actions();
302
+ if (painted && a > 0) {
303
+ markStage('model', 'interactive', { ...base, frames: f, actions: a });
304
+ stop();
305
+ return;
306
+ }
307
+ if (Date.now() - t0 > timeoutMs) { stop(); return; }
308
+ setTimeout(tick, MODEL_POLL_MS);
309
+ };
310
+
311
+ function stop() {
312
+ if (stopped) return;
313
+ stopped = true;
314
+ scheduleFlush(); // 走合并窗口:装载完成会连标多个阶段,立刻发会撞上模型文件的连接占用
315
+ }
316
+
317
+ tick();
318
+ return stop;
319
+ }
320
+
321
+ /**
322
+ * 跟踪「某个 DOM 阶段」的三级状态(契约 §2.1)。
323
+ *
324
+ * 判据(三级不是摆设,尤其 settled):
325
+ * · mounted —— 元素已插入文档(isConnected)
326
+ * · settled —— **动画/布局稳定**:该元素(含子树)没有正在跑的动画(Web Animations 的
327
+ * running/pending),且连续两次采样的包围盒一致。影院模式 header.bottom 48→0
328
+ * 这类 transition 期间会故意不报 settled。
329
+ * · interactive —— settled 之后仍稳定,且没有 pointer-events:none、子树里没有 data-pending
330
+ * (即"点了不会打偏、也没有待接线控件")。
331
+ *
332
+ * @param {{name: string, el: Element, detail?: object, timeoutMs?: number}} opts
333
+ * @returns {() => void} 停止跟踪
334
+ */
335
+ /** 执行阶段的附加判据;抛错按"未就绪"处理(不能因探针异常就报绿) */
336
+ function safeProbe(fn) {
337
+ try {
338
+ const r = fn();
339
+ return r && typeof r === 'object' ? { ok: r.ok === true, detail: r.detail || (r.ok ? {} : { probeError: 'ok=false' }) } : { ok: false, detail: { probeError: '探针无返回值' } };
340
+ } catch (e) {
341
+ return { ok: false, detail: { probeError: String((e && e.message) || e).slice(0, 80) } };
342
+ }
343
+ }
344
+
345
+ /** 元素当前包围盒的紧凑表示(两次采样相等 ⇒ 布局稳定) */
346
+ function boxOf(node) {
347
+ try {
348
+ const r = node.getBoundingClientRect();
349
+ return `${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.width)},${Math.round(r.height)}`;
350
+ } catch { return null; }
351
+ }
352
+
353
+ /** 该元素(含子树)是否还有正在跑/待跑的动画 —— settled 的核心判据 */
354
+ function animating(node) {
355
+ try {
356
+ if (typeof node.getAnimations !== 'function') return false;
357
+ return node.getAnimations({ subtree: true }).some((a) => a.playState === 'running' || a.playState === 'pending');
358
+ } catch { return false; }
359
+ }
360
+
361
+ /** 是否还有"不可交互"的迹象:pointer-events:none 或待接线控件 */
362
+ function blocked(node) {
363
+ try {
364
+ const cs = getComputedStyle(node);
365
+ if (cs && cs.pointerEvents === 'none') return true;
366
+ return !!node.querySelector('[data-pending="true"]');
367
+ } catch { return false; }
368
+ }
369
+
370
+ export function trackElementStage(opts) {
371
+ const { name, el } = opts || {};
372
+ const timeoutMs = Number(opts && opts.timeoutMs) || 8000;
373
+ const t0 = Date.now();
374
+ let stopped = false;
375
+ let prevBox = null;
376
+ let stableHits = 0;
377
+
378
+ const stop = () => { if (!stopped) { stopped = true; scheduleFlush(); } };
379
+
380
+ const tick = () => {
381
+ if (stopped) return;
382
+ if (!el || el.isConnected !== true) {
383
+ if (Date.now() - t0 > timeoutMs) stop(); else setTimeout(tick, 80);
384
+ return;
385
+ }
386
+ markStage(name, 'mounted', opts && opts.detail ? { ...opts.detail } : null);
387
+ const box = boxOf(el);
388
+ const busy = animating(el);
389
+ if (!busy && box && box === prevBox) stableHits += 1; else stableHits = 0;
390
+ prevBox = box;
391
+ // 连续两次采样都稳定且无动画 ⇒ 视为布局/动画稳定
392
+ if (stableHits >= 1) {
393
+ markStage(name, 'settled', { ...(opts && opts.detail ? opts.detail : {}), box });
394
+ // interactive 可在通用判据之外再加该阶段自己的条件(如"声明内的控件都构建了"):
395
+ // 探针返回 {ok, detail};不 ok 就继续等,绝不为了"报绿"放宽。
396
+ const probe = opts && typeof opts.interactiveProbe === 'function' ? safeProbe(opts.interactiveProbe) : { ok: true, detail: {} };
397
+ if (!blocked(el) && probe.ok) {
398
+ markStage(name, 'interactive', { ...(opts && opts.detail ? opts.detail : {}), ...(probe.detail || {}), box });
399
+ stop();
400
+ return;
401
+ }
402
+ }
403
+ if (Date.now() - t0 > timeoutMs) { stop(); return; }
404
+ setTimeout(tick, ELEMENT_POLL_MS);
405
+ };
406
+ tick();
407
+ return stop;
408
+ }
409
+
410
+ /**
411
+ * 等元素出现后再开始三级跟踪(元素可能由别的模块异步挂载)。
412
+ * @param {{name: string, selector: string, detail?: object, timeoutMs?: number}} opts
413
+ * @returns {() => void} 停止
414
+ */
415
+ export function trackElementWhenReady(opts) {
416
+ const { name, selector } = opts || {};
417
+ const timeoutMs = Number(opts && opts.timeoutMs) || 10000;
418
+ const t0 = Date.now();
419
+ let inner = null;
420
+ let stopped = false;
421
+ const tryStart = () => {
422
+ if (stopped || inner) return;
423
+ const el = document.querySelector(selector);
424
+ if (el) { inner = trackElementStage({ ...opts, name, el }); return; }
425
+ if (Date.now() - t0 > timeoutMs) { stopped = true; return; }
426
+ setTimeout(tryStart, MODEL_POLL_MS);
427
+ };
428
+ tryStart();
429
+ return () => { stopped = true; if (inner) inner(); };
430
+ }
431
+
432
+ /** 标记模型阶段失败(带可读原因,测试可立即报错而不必等超时) */
433
+ export function markModelFailed(reason, detail) {
434
+ return markStage('model', 'failed', detail || null, reason);
435
+ }
436
+
437
+ /** 装到 window.__WEB__ 上(契约 §5.1) */
438
+ export function installReadyApi(web) {
439
+ if (!web || st.installed) return;
440
+ st.installed = true;
441
+ // 新页面实例接入:先让服务端清掉上一轮阶段。
442
+ // 否则"刷新后还没装载模型"的这段时间里,服务端仍报着上一轮模型的 interactive —— 测试会据此误判。
443
+ try {
444
+ fetch('/api/ready', {
445
+ method: 'POST',
446
+ headers: { 'Content-Type': 'application/json' },
447
+ body: JSON.stringify({ clear: true }),
448
+ // 这处是**页面加载时**发的(清上一轮阶段),不属于"卸载前送达"场景
449
+ // ⇒ 刻意不传 keepalive(默认 false)
450
+ // 就绪通道是旁路信号:服务端卡住不能把页面拖住(5s 超时,超时即放弃本次上报)
451
+ signal: AbortSignal.timeout(5000),
452
+ }).catch(() => {});
453
+ } catch { /* 忽略 */ }
454
+ web.ready = (stage) => snapshot(stage);
455
+ // 上报统计(自动化断言用:flushes / dropped / fails / inflight)
456
+ web.readyStats = () => stats();
457
+ // 页面要走了:把最后一批状态落地
458
+ try {
459
+ if (typeof window !== 'undefined') window.addEventListener('pagehide', () => { try { flushNow(); } catch { /* 忽略 */ } });
460
+ } catch { /* 忽略 */ }
461
+ }
@@ -0,0 +1,51 @@
1
+ // infra/schedule.js —— rAF 合批调度内核(从 roster/view.js 抽出,行为一字不改)
2
+ // 三条性质(原实现已验证,抽出来一条都不许丢):
3
+ // ① 不漏最后一帧:pending 只在真正 run() 之后清除;另有 flush() 强制落地
4
+ // ② 不闪烁:同一帧内多次 request 只执行一次,因此只会更少中间态
5
+ // ③ 后台标签页兜底:rAF 被节流/不触发时用定时器兜底,绝不让首屏永久空白
6
+ // 零依赖,不碰 DOM,不认识任何业务概念("跑什么"由调用方决定)。
7
+
8
+ /**
9
+ * @param {() => void} run 要执行的动作
10
+ * @param {object} [opts]
11
+ * @param {() => void} [opts.onRequest] 每次 request 时回调(计"请求次数",不是"执行次数")
12
+ * @param {number} [opts.fallbackMs] 兜底间隔,缺省 120。
13
+ * 不同消费方的合理值不同:名册加载期可能长时间无 rAF,宁可晚一点也别让首屏空白 ⇒ 120;
14
+ * "只靠 rAF 就够、且不希望定时器抢先"的消费方(如调试日志面板)传 **0 = 不挂兜底**。
15
+ * 注意贴近帧率的值(如 16ms)是陷阱:定时器会早于 rAF 触发,flush 后 pending 清零、
16
+ * 下一批又在同一帧里被定时器再 flush 一次 ⇒ 从"每帧最多一次"退化成"每帧最多两次"。
17
+ */
18
+ export function createScheduler(run, opts = {}) {
19
+ const onRequest = typeof opts.onRequest === 'function' ? opts.onRequest : null;
20
+ const fallbackMs = Number.isFinite(opts.fallbackMs) ? opts.fallbackMs : 120;
21
+ let pending = false; let rafId = null; let timer = null;
22
+
23
+ /** 清掉已排队的调度:rAF 与兜底定时器都要清,否则会多跑一次 */
24
+ function cancel() {
25
+ if (rafId !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(rafId);
26
+ if (timer !== null) clearTimeout(timer);
27
+ rafId = null; timer = null; pending = false;
28
+ }
29
+
30
+ /** 立刻执行(若有待执行请求)。幂等:没有 pending 时什么都不做 */
31
+ function flush() {
32
+ if (!pending) return;
33
+ cancel();
34
+ run();
35
+ }
36
+
37
+ /** 下一帧执行(同帧内多次调用合并为一次) */
38
+ function request() {
39
+ if (onRequest) onRequest();
40
+ if (pending) return;
41
+ pending = true;
42
+ // 没有 rAF 的环境(node 测试 / 极老浏览器)同步跑,不能把请求丢掉
43
+ if (typeof requestAnimationFrame !== 'function') { flush(); return; }
44
+ rafId = requestAnimationFrame(() => { rafId = null; flush(); });
45
+ // 兜底:后台标签页 / 被节流的 rAF 可能长时间不回调,绝不能让首屏永久空白。
46
+ // fallbackMs <= 0 表示调用方明确不要兜底(只要 rAF),此时连定时器都不挂
47
+ if (fallbackMs > 0) timer = setTimeout(() => { timer = null; flush(); }, fallbackMs);
48
+ }
49
+
50
+ return { request, flush, cancel };
51
+ }
@@ -0,0 +1,129 @@
1
+ // infra/shortcuts.js —— 快捷键的唯一真相源
2
+ //
3
+ // 设计口径:
4
+ // ① **所有**键位/动作名/文案只在这个文件出现;其他地方只能通过 registerShortcuts 注册 handler,
5
+ // 不得再各自 window.addEventListener('keydown')(否则"某个键在某个模块偷偷生效"永远查不清)。
6
+ // ② 焦点在输入类元素里时**一律不响应** —— 用户在搜索框打 a 不该跳模型。
7
+ // ③ 键位重复在**启动时**就抛错(早暴露,别等运行时出现"按一下同时干两件事")。
8
+ //
9
+ // 不做什么:
10
+ // · 不处理组合键(Ctrl/⌘/Alt 一律放行给浏览器,避免抢刷新/查找/截图等原生快捷键);
11
+ // · 不管长按连发(浏览器自带 repeat,交给各 action 自己决定要不要忽略)。
12
+
13
+ /**
14
+ * 快捷键表(唯一声明处)。
15
+ *
16
+ * `key` 用 KeyboardEvent.key 的**小写**形式:字母键写作 'a',空格写作 ' ',Esc 写作 'Escape'。
17
+ * `action` 是注册表里的动作名(registerShortcuts 的 key)。
18
+ * `label` 用于文档与调试输出,不参与匹配。
19
+ */
20
+ export const SHORTCUTS = [
21
+ { key: ' ', action: 'togglePlay', label: '播放 / 暂停' },
22
+ { key: 'a', action: 'prev', label: '上一个' },
23
+ { key: 's', action: 'next', label: '下一个' },
24
+ { key: 'z', action: 'prevMotion', label: '上一个动作' },
25
+ { key: 'x', action: 'nextMotion', label: '下一个动作' },
26
+ { key: 'r', action: 'resetView', label: '重置视图' },
27
+ { key: 'e', action: 'screenshot', label: '截图导出' },
28
+ { key: 'h', action: 'toggleCinema', label: '影院模式(进/出)' },
29
+ { key: 'Escape', action: 'exitCinema', label: '退出影院(只退不进)' },
30
+ ];
31
+
32
+ /** 焦点落在这些元素里时,快捷键一律不响应 */
33
+ const TYPING_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
34
+
35
+ /**
36
+ * 判断事件是否发生在"用户正在输入"的上下文里。
37
+ * @param {KeyboardEvent} e
38
+ * @returns {boolean} true = 正在输入,应跳过快捷键
39
+ */
40
+ export function isTypingContext(e) {
41
+ const t = e && e.target;
42
+ if (!t) return false;
43
+ if (t.isContentEditable) return true;
44
+ const tag = (t.tagName || '').toUpperCase();
45
+ return TYPING_TAGS.has(tag);
46
+ }
47
+
48
+ /** 带修饰键的组合留给浏览器(Ctrl+R 刷新、⌘F 查找、Alt+Tab 等) */
49
+ function hasModifier(e) {
50
+ return !!(e.ctrlKey || e.metaKey || e.altKey);
51
+ }
52
+
53
+ /**
54
+ * 校验键位表:重复键直接抛错。
55
+ *
56
+ * 为什么在启动时抛:重复键的运行时表现是"按一下触发两件事"或"只有后注册的那个生效",
57
+ * 排查时很难想到是按键表写重了 —— 早失败比晚诡异好。
58
+ * @param {Array<{key:string, action:string}>} [list]
59
+ */
60
+ export function validateShortcuts(list = SHORTCUTS) {
61
+ const seen = new Map();
62
+ for (const s of list) {
63
+ const k = String(s.key);
64
+ if (seen.has(k)) {
65
+ throw new Error(`快捷键冲突:${k} 同时绑定了 ${seen.get(k)} 与 ${s.action}`);
66
+ }
67
+ if (!s.action) throw new Error(`快捷键 ${k} 缺少 action`);
68
+ seen.set(k, s.action);
69
+ }
70
+ return true;
71
+ }
72
+
73
+ /** 已注册的 handler(action → fn);重复注册会被拒绝,避免"两个模块抢同一个动作" */
74
+ const handlers = new Map();
75
+ let bound = false;
76
+ /** 最近一次按键命中记录(诊断/自动化断言用,不参与逻辑) */
77
+ let lastHit = null;
78
+
79
+ /**
80
+ * 注册快捷键处理器并挂上**唯一**的全局键盘监听。
81
+ *
82
+ * 可重复调用(幂等):后传入的 handler 会覆盖同名动作,监听只挂一次。
83
+ * @param {Record<string, Function>} map action → handler
84
+ * @returns {{bound:boolean, count:number}} 注册结果
85
+ */
86
+ export function registerShortcuts(map) {
87
+ validateShortcuts();
88
+ for (const [action, fn] of Object.entries(map || {})) {
89
+ if (typeof fn !== 'function') continue;
90
+ handlers.set(action, fn);
91
+ }
92
+ if (!bound) {
93
+ window.addEventListener('keydown', onKeyDown);
94
+ bound = true;
95
+ }
96
+ return { bound, count: handlers.size };
97
+ }
98
+
99
+ /**
100
+ * 全局键盘分发。
101
+ * @param {KeyboardEvent} e
102
+ */
103
+ function onKeyDown(e) {
104
+ if (hasModifier(e)) return; // 组合键交给浏览器
105
+ if (isTypingContext(e)) return; // 正在输入:一个键都不抢
106
+ if (e.defaultPrevented) return; // 已被更具体的组件处理(如自绘下拉的键盘导航)
107
+
108
+ const raw = typeof e.key === 'string' ? e.key : '';
109
+ const key = raw.length === 1 ? raw.toLowerCase() : raw;
110
+ const item = SHORTCUTS.find((s) => s.key === key);
111
+ if (!item) return;
112
+ const fn = handlers.get(item.action);
113
+ if (!fn) return;
114
+
115
+ // 空格默认会滚动页面、'/' 会唤起快速查找等:命中快捷键时压掉默认行为
116
+ e.preventDefault();
117
+ lastHit = { key, action: item.action, at: Date.now() };
118
+ try { fn(e); } catch (err) { /* 单个动作失败不能影响键盘本身 */ console.warn('[shortcuts]', item.action, err); }
119
+ }
120
+
121
+ /** 诊断快照(自动化断言用):已绑动作数 / 是否已挂监听 / 最近命中 */
122
+ export function shortcutStats() {
123
+ return {
124
+ bound,
125
+ actions: [...handlers.keys()],
126
+ keys: SHORTCUTS.map((s) => s.key),
127
+ lastHit,
128
+ };
129
+ }