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,29 @@
1
+ // render/reset-view.js —— 「视图复位」的唯一入口
2
+ //
3
+ // 为什么需要收口:复位以前在调用点直连 `stage.resetView()`,而 stage 恒为 Spine 舞台。
4
+ // 于是 Live2D 面点「重置视图」打的是隐藏的 Spine 画布(INV-01 同类问题):
5
+ // Live2D 的缩放/平移一点没变,Spine 那边白白被复位一次。
6
+ //
7
+ // 口径:**调用方只认当前渲染器**,不判断"现在是哪一面"。
8
+ // 两个渲染器都提供 resetView()(各自落到自己的相机 backend):
9
+ // Spine → stage.resetView() (相机归位 + 重取平移基准)
10
+ // Live2D → bridge.resetView() (zoom 归 1、pan 归 0 并重排)
11
+ // 万一某个渲染器没实现顶层 resetView,退一步用它统一相机对象的 reset()。
12
+
13
+ /**
14
+ * 复位当前渲染面的视图。
15
+ * @param {object|null} renderer 当前渲染器(state.currentRenderer)
16
+ * @returns {'renderer.resetView'|'renderer.camera.reset'|'none'|'no-renderer'} 实际走的通路(便于断言)
17
+ */
18
+ export function resetViewViaRenderer(renderer) {
19
+ if (!renderer) return 'no-renderer';
20
+ if (typeof renderer.resetView === 'function') {
21
+ renderer.resetView();
22
+ return 'renderer.resetView';
23
+ }
24
+ if (renderer.camera && typeof renderer.camera.reset === 'function') {
25
+ renderer.camera.reset();
26
+ return 'renderer.camera.reset';
27
+ }
28
+ return 'none';
29
+ }
@@ -0,0 +1,386 @@
1
+ // render/spine.js —— Spine 渲染器(注册到 StageSurface 的渲染面实现)
2
+ //
3
+ // 契约(StageSurface.renderer):
4
+ // { id, capabilities, mount(document, handle) → info, dispose(), isActive(), camera }
5
+ // 边界:不碰 DOM 尺寸、不建 canvas —— 只通过 handle 取画布/尺寸/DPR,用 handle.requestRender 请求渲染。
6
+
7
+ import { createCamera } from './camera/index.js';
8
+ import { trackModelStage, markModelFailed } from '../infra/ready.js';
9
+ import { buildDocument } from '../infra/document.js';
10
+ import { applyFilter } from '../engine/appearance.js';
11
+
12
+ /** Spine 渲染器支持的设置项 */
13
+ // 前 7 项是"引擎参数",后 4 项是"内容操作"(皮肤/部件筛除/动作混合/动作切换):
14
+ // 它们同样只能经 setSetting 落地(INV-01),否则 Live2D 模式下会打到隐藏的 spine 舞台。
15
+ const SETTINGS = ['fps', 'dpr', 'msaa', 'textureFilter', 'presetFilter', 'pma', 'fit', 'skin', 'mixMode', 'mixTime', 'animation', 'speed'];
16
+ /** Spine 渲染器支持的调试绘制项
17
+ * 注:不含 points —— 3.8/4.1/4.2/4.3 的 SkeletonDebugRenderer 均无 drawPoints(实测 grep 全为 0),
18
+ * 该项永远画不出来,按「声明即必须可实现」不列入能力声明;运行时若探测到缺失项,
19
+ * 由引擎 getRendererInfo().debugUnsupported 上报。 */
20
+ const DEBUG_DRAW = ['bones', 'meshes', 'boundingBoxes', 'paths', 'regions', 'clipping', 'hulls'];
21
+
22
+ /**
23
+ * 能力键 → 舞台方法名。
24
+ * 为什么这样分:舞台(spine/canvas.js)已有一批经过验收的 setter,渲染 + 转发",
25
+ * 不自己解释设置语义;面板因此可以完全不知道舞台的存在(契约 INV-01)。
26
+ */
27
+ const STAGE_SETTERS = {
28
+ speed: 'setSpeed',
29
+ mixTime: 'setMixTime',
30
+ fps: 'setFpsLimit',
31
+ dpr: 'setDprMode',
32
+ textureFilter: 'setTextureFilter',
33
+ presetFilter: 'setFilter',
34
+ pma: 'setPma',
35
+ fit: 'setFit',
36
+ };
37
+
38
+ /**
39
+ * 创建 Spine 渲染器。
40
+ * @param {{stage: object}} options stage 为舞台(提供 load/unload/dispose/stats/camera)
41
+ */
42
+ /**
43
+ * 画面指纹:对 2D canvas 采样求和,用于断言"截到的是哪一面、内容是否变化"。
44
+ * 采样步长取质数,避免与行宽成整数比导致漏采。
45
+ * @param {HTMLCanvasElement} canvas
46
+ * @returns {number} 指纹(0 表示取不到)
47
+ */
48
+ function digestOf(canvas) {
49
+ try {
50
+ const ctx = canvas.getContext('2d');
51
+ if (!ctx) return 0;
52
+ const { width, height } = canvas;
53
+ const data = ctx.getImageData(0, 0, width, height).data;
54
+ let sum = 0;
55
+ for (let i = 0; i < data.length; i += 997) sum = (sum + data[i]) % 1000003;
56
+ return sum;
57
+ } catch { return 0; }
58
+ }
59
+
60
+ export function createSpineRenderer({ stage, onMsaa } = {}) {
61
+ /** 暂停标记(渲染器自己维护:相机/设置变更后需要判断"是否要补一帧") */
62
+ let pausedRef = false;
63
+ if (!stage || typeof stage.load !== 'function') {
64
+ throw new Error('createSpineRenderer: 需要提供 load 能力的 stage');
65
+ }
66
+ /**
67
+ * 取当前引擎实例。
68
+ * @returns {object|null}
69
+ */
70
+ const engineOf = () => {
71
+ try { return stage.debug ? stage.debug().engine : null; } catch { return null; }
72
+ };
73
+
74
+ /**
75
+ * 统一相机的 backend:只做「读/写状态 + 怎么落地」,不做任何坐标换算。
76
+ * 全部委托引擎原生方法:
77
+ * getScale → getCameraZoom()(引擎的 userScale 是唯一真相)
78
+ * setScale → setCameraZoom(v)(引擎内部按 SCALE_MIN/MAX 再夹一次)
79
+ * pan → applyPan(dx, dy)(dx/dy 为屏幕像素,倍率与 flip 由引擎处理)
80
+ * resetView→ resetCamera()(重新 fit,userScale 归 1)
81
+ */
82
+ const cameraBackend = {
83
+ getScale() {
84
+ const engine = engineOf();
85
+ try { return engine && typeof engine.getCameraZoom === 'function' ? Number(engine.getCameraZoom()) : null; } catch { return null; }
86
+ },
87
+ setScale(v) {
88
+ const engine = engineOf();
89
+ try { if (engine && typeof engine.setCameraZoom === 'function') engine.setCameraZoom(v); } catch { /* 忽略 */ }
90
+ if (pausedRef) { try { stage.repaint && stage.repaint(); } catch { /* 忽略 */ } }
91
+ },
92
+ pan(dx, dy) {
93
+ const engine = engineOf();
94
+ try { if (engine && typeof engine.applyPan === 'function') engine.applyPan(dx, dy); } catch { /* 忽略 */ }
95
+ if (pausedRef) { try { stage.repaint && stage.repaint(); } catch { /* 忽略 */ } }
96
+ },
97
+ resetView() {
98
+ // 走舞台的 resetView:它除了相机归位,还会重取"平移基准"(viewBaseScale/viewPan),
99
+ // 只调 engine.resetCamera() 会让基准停在旧值,复位后一拖就跳。
100
+ try { if (typeof stage.resetView === 'function') { stage.resetView(); return; } } catch { /* 落到下面的兜底 */ }
101
+ const engine = engineOf();
102
+ try { if (engine && typeof engine.resetCamera === 'function') engine.resetCamera(); } catch { /* 忽略 */ }
103
+ },
104
+ };
105
+
106
+ // 统一相机:档位 / 范围 / 复位语义与 Live2D 侧完全一致(差异只在 backend)
107
+ const camera = createCamera({ kind: 'spine', backend: cameraBackend });
108
+
109
+ return {
110
+ id: 'spine',
111
+ // 声明的 kind 供 surface.register 做面元数据表校验(防"注册了渲染器却忘了加表项")
112
+ kind: 'spine',
113
+ /**
114
+ * 下发插件效果链(**与 Live2D 同一套语义**:顺序/激活/销毁都在 effect-chain.js,
115
+ * 这里只管"怎么把它挂到 Spine 的 batcherShader 上")。
116
+ * 参数值变化只更新 uniform;只有链**结构**变化才重编译着色器(热更新判据)。
117
+ * @param {Array<{id:string, fragment:string, uniforms:object}>} chain
118
+ * @returns {boolean} 是否成功应用
119
+ */
120
+ setEffectChain(chain) {
121
+ const eng = engineOf();
122
+ if (!eng) return false;
123
+ eng.effectChain = Array.isArray(chain) ? chain : [];
124
+ applyFilter(eng);
125
+ // 暂停态也要立刻看到变化:效果是"实时预览"用的,不能等下一次自然出帧
126
+ try { if (pausedRef && stage && typeof stage.renderOnce === 'function') stage.renderOnce(); } catch { /* 忽略 */ }
127
+ return true;
128
+ },
129
+ /** 当前生效链的结构签名(断言用:切档位时它不应变化) */
130
+ effectChainSignature() {
131
+ const eng = engineOf();
132
+ return eng && eng.filterState ? String(eng.filterState.signature || '') : '';
133
+ },
134
+ /**
135
+ * 暂停:停掉引擎时间推进(动作与渲染循环都不再走)。
136
+ * 走 stage.pause():舞台层的暂停是唯一实现(引擎 stopp),渲染器不重复实现一套。
137
+ */
138
+ pause() {
139
+ pausedRef = true; // 之后相机/设置变化都要显式补帧(引擎自己不再画)
140
+ try { if (stage && typeof stage.pause === 'function') { stage.pause(); return true; } } catch { /* 忽略 */ }
141
+ return false;
142
+ },
143
+ /** 继续:恢复引擎推进 */
144
+ resume() {
145
+ pausedRef = false;
146
+ try { if (stage && typeof stage.play === 'function') { stage.play(); return true; } } catch { /* 忽略 */ }
147
+ return false;
148
+ },
149
+
150
+ /**
151
+ * 截图取像素:读 Spine 舞台画布的 WebGL 缓冲(缓冲不保留,只能 readPixels),
152
+ * 逐行翻转后画进一张 2D canvas 返回。
153
+ * 这段逻辑原在 app/topbar-actions.js 里写死 `#stage-canvas`,搬到渲染器后
154
+ * **行为不变**,只是让"当前面是谁"由渲染器自己决定。
155
+ * @returns {{canvas: HTMLCanvasElement, width: number, height: number, nonEmpty: number}|null}
156
+ */
157
+ capture() {
158
+ const canvas = typeof document !== 'undefined' ? document.getElementById('stage-canvas') : null;
159
+ if (!canvas || !canvas.width || !canvas.height) return null;
160
+ const w = canvas.width;
161
+ const h = canvas.height;
162
+ const out = document.createElement('canvas');
163
+ out.width = w;
164
+ out.height = h;
165
+ const outCtx = out.getContext('2d');
166
+ if (!outCtx) return null;
167
+ let nonEmpty = 0;
168
+ const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
169
+ if (gl && typeof gl.readPixels === 'function') {
170
+ try {
171
+ const buf = new Uint8Array(w * h * 4);
172
+ gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
173
+ const img = outCtx.createImageData(w, h);
174
+ // WebGL 原点在左下:逐行翻转,否则导出上下颠倒
175
+ for (let y = 0; y < h; y += 1) {
176
+ const src = (h - 1 - y) * w * 4;
177
+ img.data.set(buf.subarray(src, src + w * 4), y * w * 4);
178
+ }
179
+ for (let i = 3; i < img.data.length; i += 4) if (img.data[i] > 8) nonEmpty += 1;
180
+ outCtx.putImageData(img, 0, 0);
181
+ } catch { /* 读不到就走 drawImage 兜底 */ }
182
+ }
183
+ if (nonEmpty === 0) {
184
+ try { outCtx.drawImage(canvas, 0, 0); } catch { /* 兜底失败就返回空白 */ }
185
+ }
186
+ return { canvas: out, width: w, height: h, nonEmpty, digest: digestOf(out) };
187
+ },
188
+
189
+ /** 显式补一帧(暂停中截图前调用,保证拿到的是最新画面) */
190
+ repaint() {
191
+ try { if (stage && typeof stage.repaint === 'function') { stage.repaint(); return true; } } catch { /* 忽略 */ }
192
+ return false;
193
+ },
194
+ capabilities: {
195
+ settings: SETTINGS.slice(),
196
+ debugDraw: DEBUG_DRAW.slice(),
197
+ camera: 'ortho-2d',
198
+ },
199
+ /** 相机由渲染器提供(统一相机,backend 委托引擎原生方法) */
200
+ /** Assets 层入口(测试/工具用):catalog entry → ready Document。渲染器自身不取资源 */
201
+ buildDocument,
202
+ camera,
203
+ /**
204
+ * 装载:入口 url 映射成引擎认识的 modelUrl(catalog 只给 entry.url)。
205
+ * @param {object} document
206
+ * @param {object} handle StageSurface 句柄(本渲染器目前不需要使用,保留参数位)
207
+ */
208
+ /**
209
+ * 装载:入参是 **ready 的 Document**(契约 Document契约 §二 / 渲染器契约 §二)。
210
+ * 本层不再把 catalog entry 改写成引擎字段、也不取任何资源 ——
211
+ * 资源地址与字节都已在 infra/document.js 组装时备好。
212
+ * @param {object} document ready 的 Document
213
+ * @returns {Promise<object>} info
214
+ */
215
+ async mount(document, handle) {
216
+ // 只接受 ready 的 Document,**不提供旧形状兜底**:
217
+ // 兜底一旦留着,将来任何新调用点忘了组装就会静默走它,契约就形同虚设
218
+ // (现存的调用点 surface-switch 与 app/model-actions 都已在调用前 buildDocument)。
219
+ if (!document || document.state !== 'ready') {
220
+ const why = (document && (document.warnings || []).join(';')) || '未提供 Document';
221
+ // 就绪信号:失败要立刻可见(带原因),测试据此直接报错而不是等超时
222
+ markModelFailed(`Document 不可用:${why}`, { kind: 'spine' });
223
+ throw new Error(`spine.mount 只接受 ready 的 Document(请先 buildDocument):${why}`);
224
+ }
225
+ try {
226
+ const info = await stage.load({ document });
227
+ // 就绪信号(契约 §2.2):mounted → painted → interactive 由 trackModelStage 逐级推进。
228
+ // painted 用引擎自己的已绘帧计数(stage.debug().frames),**不用像素采样**:
229
+ // 画布 detach 之后像素采样仍可能"有颜色",那是弱证据。
230
+ trackModelStage({
231
+ kind: 'spine',
232
+ id: document.id || (document.meta && document.meta.id) || null,
233
+ canvas: handle && handle.canvas,
234
+ getFrames: () => stage.debug().frames,
235
+ getActions: () => ((stage.debug().animations || []).length),
236
+ });
237
+ return info;
238
+ } catch (e) {
239
+ markModelFailed(`装载失败:${(e && e.message) || e}`, { kind: 'spine' });
240
+ throw e;
241
+ }
242
+ },
243
+ /**
244
+ * 应用一项设置(面板唯一入口,契约 renderer/v1)。
245
+ * 未知键返回 false 不抛;支持的键全部委托舞台既有 setter(行为与现在完全一致)。
246
+ * @param {string} key 能力键(fps/dpr/msaa/textureFilter/presetFilter/pma/fit 或 debug:<项>)
247
+ * @param {any} value
248
+ * @returns {boolean}
249
+ */
250
+ setSetting(key, value) {
251
+ // 同值短路:生效值没变就不重复应用(msaa 会重建上下文、pma 会重传纹理、fit 会重置取景,
252
+ // 无谓的重复执行既拖慢也破坏现场;能力自检因此可以安全地"同值回灌")
253
+ // 同值短路:animation 例外——重复点同一条动作是"重播",不能被短路吞掉
254
+ if (key !== 'animation') {
255
+ const cur = this.getSetting(key);
256
+ if (cur !== null && cur !== undefined && String(cur) === String(value)) return true;
257
+ }
258
+ const dbg = /^debug:(.+)$/.exec(String(key || ''));
259
+ if (dbg) {
260
+ if (!DEBUG_DRAW.includes(dbg[1]) || typeof stage.setDebug !== 'function') return false;
261
+ try { stage.setDebug({ [dbg[1]]: !!value }); return true; } catch { return false; }
262
+ }
263
+ // ---- 内容操作四键:返回值语义与改动前一致(皮肤=是否真的切过去;mixMode=是否生效;
264
+ // animation=舞台是否接受)
265
+ if (key === 'skin') {
266
+ try { return stage.setSkin(String(value)) !== false; } catch { return false; }
267
+ }
268
+ if (key === 'mixMode') {
269
+ try { stage.setMixMode(!!value); } catch { return false; }
270
+ return this.getSetting('mixMode') === !!value;
271
+ }
272
+ if (key === 'animation') {
273
+ // 空值 = "没有要切的动作":接受但不做事(能力自检会用同值回灌探活,
274
+ // 此刻通常还没装模型,返回 false 会被误判成"声明支持但未实现")
275
+ const name = value === null || value === undefined ? '' : String(value);
276
+ if (!name) return true;
277
+ try { return stage.setAnimation(name) === true; } catch { return false; }
278
+ }
279
+ if (key === 'msaa') {
280
+ // 抗锯齿要重建上下文,属外壳职责(applyMsaa);渲染器只提供入口,不自己重建
281
+ if (typeof onMsaa !== 'function') return false;
282
+ try { onMsaa(value); return true; } catch { return false; }
283
+ }
284
+ const setter = STAGE_SETTERS[key];
285
+ if (!setter || typeof stage[setter] !== 'function') return false;
286
+ try {
287
+ stage[setter](value);
288
+ // 以"回显的生效值"为准判定成功:舞台 setter 的返回值口径不一(有的返回 null 表示未生效),
289
+ // 而设置快照是唯一的真相来源,读它最可靠
290
+ const applied = this.getSetting(key);
291
+ if (applied === null || applied === undefined) return false;
292
+ return String(applied) === String(value) || key === 'msaa';
293
+ } catch { return false; }
294
+ },
295
+
296
+ /** 回显实际生效值(读舞台 settings,不是请求值) */
297
+ getSetting(key) {
298
+ const dbg = /^debug:(.+)$/.exec(String(key || ''));
299
+ const st = (() => { try { return stage.stats().settings; } catch { return null; } })();
300
+ /** 取引擎实例(多处要用;抽出来避免同一段 IIFE 反复出现在每个分支里) */
301
+ const engineOf = () => { try { return stage.debug ? stage.debug().engine : null; } catch { return null; } };
302
+ if (!st) return null;
303
+ if (dbg) return !!(st.debug && st.debug[dbg[1]]);
304
+ // 内容操作四键:回显**生效值**(INV-08),读舞台/引擎的真实状态,不是请求值
305
+ if (key === 'skin') {
306
+ // 必须读引擎**真正在用**的皮肤(activeSkins),不能只读舞台的 settings.skin:
307
+ // 装载时会自动挑一个"能画出东西"的皮肤(有些资源的 default 是空壳、附件全在具名皮肤里),
308
+ // 那条路径不经过 api.setSkin ⇒ st.skin 会停在旧值/默认值。
309
+ // 拿假值回显会让 setSetting 的"同值短路"误判 —— 表现为"点回 default 完全没反应"
310
+ // (它以为已经是 default,直接 return true 什么都不做)。实测踩到过。
311
+ const engSkin = engineOf();
312
+ const active = engSkin && Array.isArray(engSkin.activeSkins) && engSkin.activeSkins.length
313
+ ? engSkin.activeSkins[0]
314
+ : null;
315
+ return active || st.skin || 'default';
316
+ }
317
+ if (key === 'mixMode') return (Number(st.mixTime) || 0) > 0;
318
+ // 混合时长:回显生效秒数(0 = 不混合)
319
+ if (key === 'mixTime') return Number(st.mixTime) || 0;
320
+ if (key === 'animation') {
321
+ // 无模型时回显 ''(而不是 null):null 会让能力自检判成"没有生效值"
322
+ const eng = engineOf();
323
+ return (eng && eng.animationName) || '';
324
+ }
325
+ // 播放速度:回显**生效值**(引擎侧 timeScale 才是真正在用的),舞台设置里的 speed 是请求值
326
+ if (key === 'speed') {
327
+ const eng = engineOf();
328
+ const scale = eng && eng.state && typeof eng.state.timeScale === 'number' ? eng.state.timeScale : null;
329
+ if (scale !== null) return scale;
330
+ return st.speed !== undefined ? st.speed : 1;
331
+ }
332
+ const map = { fps: 'fpsLimit', dpr: 'dprMode', msaa: 'msaa', textureFilter: 'textureFilter', presetFilter: 'filter', pma: 'pma', fit: 'fit' };
333
+ const k = map[key];
334
+ return k && st[k] !== undefined ? st[k] : null;
335
+ },
336
+ /**
337
+ * 尺寸变化:把 CSS 尺寸交给引擎,由引擎按自己的 getEffectiveDpr 决定像素尺寸。
338
+ * 为什么不让 Surface 直接写 canvas.width:引擎的 resize() 会同时更新
339
+ * gl.viewport / logicalW / camera.viewportWidth 并在尺寸变化时 refit,
340
+ * 绕过它只改 canvas.width 会造成视口与像素网格不一致(BUG-2 根因)。
341
+ * @param {{cssW:number, cssH:number, dpr:number}} box
342
+ */
343
+ resize(box) {
344
+ if (!box) return;
345
+ let engine = null;
346
+ try { engine = stage.debug ? stage.debug().engine : null; } catch { engine = null; }
347
+ // res 由 StageSurface 下发(唯一尺寸来源);引擎再按软件渲染 / dprMode 收口
348
+ const dpr = box.res && box.res > 0 ? box.res : (box.dpr && box.dpr > 0 ? box.dpr : 1);
349
+ if (engine && typeof engine.getEffectiveDpr === 'function' && typeof engine.resize === 'function') {
350
+ const effective = engine.getEffectiveDpr(dpr);
351
+ const w = Math.max(1, Math.floor(box.cssW * effective));
352
+ const h = Math.max(1, Math.floor(box.cssH * effective));
353
+ engine.resize(w, h);
354
+ return;
355
+ }
356
+ // 引擎还没建:交给舞台的兜底入口(等引擎初始化时自己 refreshResolution)
357
+ try { if (typeof stage.resize === 'function') stage.resize(); } catch { /* 忽略 */ }
358
+ },
359
+ /**
360
+ * 期望 DPR 变化:下发给引擎,由引擎决定实际生效值。
361
+ * @param {number|string} value 像素比或 auto
362
+ */
363
+ setDpr(value) {
364
+ let engine = null;
365
+ try { engine = stage.debug ? stage.debug().engine : null; } catch { engine = null; }
366
+ if (engine && typeof engine.setDprMode === 'function') engine.setDprMode(value === null || value === undefined ? 'auto' : value);
367
+ },
368
+ /**
369
+ * 卸载:释放当前模型内容并**停掉渲染循环**(不再出帧),引擎与 GL 上下文保留以便快速切回。
370
+ * stage.unload() 内部已做 engine.stop() + clearSkeleton() + 让在途加载失效。
371
+ * 画布可见性由 StageSurface 负责(渲染器不碰 DOM,契约 §一)。
372
+ */
373
+ unmount() {
374
+ try { if (typeof stage.unload === 'function') stage.unload(); } catch { /* 忽略 */ }
375
+ },
376
+ /** 彻底释放:销毁引擎(含 WebGL 上下文);幂等(Surface 销毁时走这里) */
377
+ dispose() {
378
+ try { if (typeof stage.dispose === 'function') stage.dispose(); } catch { /* 忽略 */ }
379
+ },
380
+ isActive() {
381
+ try { return !!(stage.stats && stage.stats().model); } catch { return false; }
382
+ },
383
+ };
384
+ }
385
+
386
+ export default createSpineRenderer;
@@ -0,0 +1,176 @@
1
+ import { markModelFailed } from '../infra/ready.js';
2
+ // 面元数据表:kind / rendererId → 面属性一律查表,不在这里写二分判断
3
+ import { byIdOrKind, byKind } from './surfaces.js';
4
+
5
+ /**
6
+ * 回显"切换前的那个面"。previous 是**渲染器 id**(可能为 null)。
7
+ * 查不到就退回 'loading'(遮住)而不是猜一个面:
8
+ * 此刻旧面已经卸载,写错面会让 CSS 布局与覆盖层显隐按错误的面走,比遮住更糟。
9
+ * @param {string|null} previous
10
+ * @returns {string} 可直接交给 setMode 的值
11
+ */
12
+ function modeOfPrevious(previous) {
13
+ const s = byIdOrKind(previous);
14
+ return s ? s.stageMode : 'loading';
15
+ }
16
+ // render/surface-switch.js —— 渲染面切换状态机(last-wins 排队 + 单次切换时序)
17
+ //
18
+ // 从 surface.js 拆出的原因:切换是"渲染面"最核心也最易错的一段(串行锁、排队、失败回滚、
19
+ // 唯一写入 data-stage-mode),单独成文件便于审计与回归;时序与副作用仍全部通过 ctx 回调
20
+ // 走 Surface 既有的 setMode / disposeActive / handle,不新增第二条切换入口。
21
+ //
22
+ // 组装 Document 走 Assets 层(infra/document.js):本文件只做切换时序,不取模型资源。
23
+
24
+ /**
25
+ * 创建切换器。
26
+ * @param {{
27
+ * renderers: Map,
28
+ * state: {activeId: string|null, document: object|null, swapping: boolean, pending: object|null},
29
+ * setMode: (mode: string) => string,
30
+ * disposeActive: () => Promise<void>,
31
+ * handle: (id: string) => object,
32
+ * measure: () => void,
33
+ * requestRender: () => void,
34
+ * note: (message: string, level?: string) => void,
35
+ * noteSwitch: (tag: string) => string,
36
+ * onSwitched: (activeId: string|null) => void,
37
+ * }} ctx
38
+ */
39
+ export function createSwitcher(ctx) {
40
+ const { renderers, state, setMode, disposeActive, handle, measure, requestRender, note, noteSwitch, onSwitched } = ctx;
41
+
42
+ /**
43
+ * 切换到一份文档(全项目唯一的渲染面切换入口)。
44
+ * 竞态语义(last-wins):切换进行中再次点击时**不丢弃**,记成 pending,
45
+ * 本轮完成后立刻继续切到最新那份 —— 避免出现"旧面内容已释放 + 新面没装"的空白中间态。
46
+ * @param {object} document 舞台文档
47
+ * @returns {Promise<object|null>} 装载结果;null 表示本次请求被排队(由后面那次切换生效)
48
+ */
49
+ async function activate(document) {
50
+ if (state.swapping) {
51
+ state.pending = document;
52
+ note(`切换进行中,本次请求排队(${(document && document.kind) || '?'})`);
53
+ return null;
54
+ }
55
+ let current = document;
56
+ state.swapping = true;
57
+ try {
58
+ for (;;) {
59
+ state.pending = null;
60
+ const info = await runSwitch(current);
61
+ onSwitched(state.activeId);
62
+ if (!state.pending) return info;
63
+ current = state.pending;
64
+ }
65
+ } finally {
66
+ state.swapping = false;
67
+ state.pending = null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * ① 切换前收尾:算出卸载方式并执行卸载。
73
+ *
74
+ * 分流规则(契约 D1,**最容易在重构里被顺手改错的地方**):
75
+ * · 同一渲染器换内容(spine→spine / live2d→live2d)→ unmount:释放内容与自建 GL 资源,
76
+ * 但保留引擎/PIXI 覆盖层,再装载更快
77
+ * · 换渲染器(spine↔live2d)→ dispose:彻底拆,**不留旧 GL 上下文**
78
+ * (留着就是契约 §五-7 说的"遗留自建 GL 资源",且连续切换会累积上下文)
79
+ * @param {object} document 目标文档(用来推断 nextId)
80
+ * @param {string|null} previous 切换前的渲染器 id
81
+ */
82
+ async function unloadPrevious(document, previous) {
83
+ // nextId 推导查表:document.rendererId 优先,否则用 kind 对应的面 id。
84
+ // 查不到(未知 kind)⇒ nextId=null ⇒ 下面按"不同渲染器"处理走 dispose,
85
+ // 即彻底拆掉旧引擎与 GL 资源 —— 这是保守方向(契约 §五-7 禁止遗留旧 GL 上下文)。
86
+ const nextId = (document && (document.rendererId || (byKind(document.kind) || {}).id)) || null;
87
+ const unloadMode = previous && previous === nextId ? 'unmount' : 'dispose';
88
+ note(`卸载模式=${unloadMode}(previous=${previous || 'none'} next=${nextId || 'none'})`);
89
+ await disposeActive(unloadMode);
90
+ }
91
+
92
+ /**
93
+ * ② 装配新渲染器 + 切换后通知。成功返回 {skipped:false, info};失败**原样抛出**交给 ③ 回滚。
94
+ *
95
+ * 顺序即契约:measure → mount → 写 activeId/document → setMode(kind) → requestRender → noteSwitch。
96
+ * 尤其 activeId 必须**在 mount 成功之后**才写(失败时由 ③ 回滚到 previous)。
97
+ * @param {object} document
98
+ * @param {string|null} previous 切换前的渲染器 id(Document 不可用时用它回显旧面)
99
+ */
100
+ async function assembleAndActivate(document, previous) {
101
+ // 门禁(契约 D1):activate 只接受 Document。
102
+ // 传旧形状(catalog entry,没有 state 字段)→ **直接抛错**,不做兜底组装:
103
+ // 兜底一旦留着,忘了组装的调用点就会静默走它,契约形同虚设。
104
+ if (!document || typeof document.state !== 'string') {
105
+ const got = document && typeof document === 'object' ? `普通对象(kind=${document.kind || '?'})` : String(document);
106
+ // 就绪信号:失败必须是显式 failed + 原因(测试当场能报,而不是等超时)
107
+ markModelFailed(`不是 Document:收到 ${got}`);
108
+ throw new Error(`surface.activate 只接受 Document(请先 buildDocument 组装),收到:${got}`);
109
+ }
110
+ // ready 之外(如 error)不进入渲染层,但属可预期的用户侧失败:给提示、不抛
111
+ if (document.state !== 'ready') {
112
+ const why = (document.warnings || []).join(';') || '缺少 Document';
113
+ note(`跳过装载:Document 不可用(${document.state || 'none'}):${why}`);
114
+ markModelFailed(`Document 不可用(${document.state || 'none'}):${why}`);
115
+ setMode(modeOfPrevious(previous));
116
+ return { skipped: true, info: null };
117
+ }
118
+ // kind 查表:未知 kind **显式抛错**,不再"不是 live2d 就当 spine"。
119
+ // 二分语义只有两个面时成立;加第三个面时它会把新面标成 spine ⇒ setMode 写错面、
120
+ // handle() 拿错画布、卸载模式也可能算错,全都无声发生。
121
+ const meta = byKind(document.kind);
122
+ if (!meta) throw new Error(`不支持的渲染面「${document.kind || '?'}」:未在 render/surfaces.js 注册`);
123
+ const kind = meta.kind;
124
+ const rendererId = document.rendererId || kind;
125
+ const entry = renderers.get(rendererId);
126
+ if (!entry) throw new Error(`没有注册渲染器:${rendererId}`);
127
+ measure();
128
+ const info = await entry.mount(document, handle(rendererId)); // ③ 装载
129
+ state.activeId = rendererId; state.document = document || null;
130
+ setMode(kind); // ④ 切面(全项目唯一写入点)
131
+ requestRender();
132
+ noteSwitch(`activated:${rendererId}`);
133
+ return { skipped: false, info };
134
+ }
135
+
136
+ /**
137
+ * ③ 失败回滚:回到上一个面(没有就回到 spine),并如实记日志。
138
+ *
139
+ * 注意顺序:先 setMode 回显旧面,再把 activeId 改回 previous —— 与原实现一致;
140
+ * 顺序颠倒会让"切面"读到已经回滚的 activeId。
141
+ * 若期间已有新请求(state.pending),把失败**吞掉**:以最新那份为准,
142
+ * 旧失败不再外抛(否则用户看到无意义的中间态错误)。
143
+ * @returns {{swallowed:boolean}}
144
+ */
145
+ function rollbackOnFailure(document, previous, err) {
146
+ setMode(modeOfPrevious(previous));
147
+ state.activeId = previous;
148
+ noteSwitch(`activate-failed:${(document && document.kind) || '?'}`);
149
+ if (state.pending) {
150
+ note(`切换已被新的选择取代:${(err && err.message) || err}`);
151
+ return { swallowed: true };
152
+ }
153
+ return { swallowed: false };
154
+ }
155
+
156
+ /**
157
+ * 单次切换时序:loading 遮罩 → 收尾 → 装配 → 请求帧。本体只做编排,细节在三段里。
158
+ * @param {object} document
159
+ * @returns {Promise<object|null>} 装载结果;Document 不可用或有新请求取代时返回 null
160
+ */
161
+ async function runSwitch(document) {
162
+ const previous = state.activeId;
163
+ setMode('loading'); // ① 先遮住:任何方向都不可能叠加
164
+ try {
165
+ await unloadPrevious(document, previous);
166
+ const r = await assembleAndActivate(document, previous);
167
+ return r.skipped ? null : r.info;
168
+ } catch (err) {
169
+ const rb = rollbackOnFailure(document, previous, err);
170
+ if (rb.swallowed) return null;
171
+ throw err;
172
+ }
173
+ }
174
+
175
+ return { activate };
176
+ }